Skip to content

Commit eb49912

Browse files
authored
test: harden review render plan contract (#65)
1 parent 67b86f5 commit eb49912

2 files changed

Lines changed: 323 additions & 71 deletions

File tree

src/ui/diff/reviewRenderPlan.ts

Lines changed: 82 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,18 @@ import type { DiffRow } from "./pierre";
55

66
const EMPTY_VISIBLE_AGENT_NOTES: VisibleAgentNote[] = [];
77

8-
interface SelectedInlineNote {
8+
type DiffLineRow = Extract<DiffRow, { type: "split-line" | "stack-line" }>;
9+
10+
interface PrimaryVisibleInlineNote {
911
anchorKey: string;
1012
anchorSide?: "old" | "new";
11-
coveredRowKeys: Set<string>;
12-
endGuideAfterKey: string;
13+
guidedRowKeys: Set<string>;
14+
endGuideAfterKey?: string;
1315
note: VisibleAgentNote;
1416
noteCount: number;
1517
noteIndex: number;
1618
}
1719

18-
type DiffLineRow = Extract<DiffRow, { type: "split-line" | "stack-line" }>;
19-
2020
export type PlannedReviewRow =
2121
| {
2222
kind: "diff-row";
@@ -46,6 +46,16 @@ export type PlannedReviewRow =
4646
side: "old" | "new";
4747
};
4848

49+
function hunkRows(rows: DiffRow[], hunkIndex: number) {
50+
return rows.filter((row) => row.hunkIndex === hunkIndex);
51+
}
52+
53+
function hunkLineRows(rows: DiffRow[], hunkIndex: number) {
54+
return hunkRows(rows, hunkIndex).filter(
55+
(row): row is DiffLineRow => row.type === "split-line" || row.type === "stack-line",
56+
);
57+
}
58+
4959
/** Check whether a rendered diff row visually covers the note anchor line. */
5060
function rowMatchesNote(row: DiffLineRow, annotation: AgentAnnotation) {
5161
const anchor = annotationAnchor(annotation);
@@ -92,62 +102,81 @@ function rowOverlapsAnnotation(row: DiffLineRow, annotation: AgentAnnotation) {
92102
);
93103
}
94104

95-
/** Resolve the rendered diff row before which the visible inline note should appear. */
105+
/**
106+
* Resolve the rendered diff row before which the inline note should appear.
107+
* Range-less notes intentionally anchor beside the first code row in the hunk,
108+
* not above the hunk header metadata.
109+
*/
96110
function findInlineNoteAnchorRow(
97111
rows: DiffRow[],
98112
annotation: AgentAnnotation,
99113
selectedHunkIndex: number,
100114
) {
101-
const selectedHunkRows = rows.filter((row) => row.hunkIndex === selectedHunkIndex);
102-
const lineRows = selectedHunkRows.filter(
103-
(row): row is DiffLineRow => row.type === "split-line" || row.type === "stack-line",
104-
);
115+
const selectedHunkRows = hunkRows(rows, selectedHunkIndex);
116+
const lineRows = hunkLineRows(rows, selectedHunkIndex);
105117
const headerRow = selectedHunkRows.find((row) => row.type === "hunk-header");
106118

107119
return lineRows.find((row) => rowMatchesNote(row, annotation)) ?? lineRows[0] ?? headerRow;
108120
}
109121

110-
/** Return the one visible note, plus the diff rows that should show its guide rail. */
111-
function buildSelectedInlineNote(
122+
/**
123+
* The render plan shows at most one inline note at a time.
124+
* The first entry in visibleAgentNotes is the primary visible note, while
125+
* noteIndex/noteCount preserve its position within the current visible list.
126+
*/
127+
function selectPrimaryVisibleNote(visibleAgentNotes: VisibleAgentNote[]) {
128+
if (visibleAgentNotes.length === 0) {
129+
return null;
130+
}
131+
132+
return {
133+
note: visibleAgentNotes[0]!,
134+
noteIndex: 0,
135+
noteCount: visibleAgentNotes.length,
136+
};
137+
}
138+
139+
/** Return the primary visible note, plus the diff rows that should show its guide rail. */
140+
function buildPrimaryVisibleInlineNote(
112141
rows: DiffRow[],
113142
visibleAgentNotes: VisibleAgentNote[],
114143
selectedHunkIndex: number,
115144
) {
116-
if (visibleAgentNotes.length === 0 || selectedHunkIndex < 0) {
145+
if (selectedHunkIndex < 0) {
117146
return null;
118147
}
119148

120-
const note = visibleAgentNotes[0]!;
121-
const anchorRow = findInlineNoteAnchorRow(rows, note.annotation, selectedHunkIndex);
149+
const selectedNote = selectPrimaryVisibleNote(visibleAgentNotes);
150+
if (!selectedNote) {
151+
return null;
152+
}
153+
154+
const anchorRow = findInlineNoteAnchorRow(rows, selectedNote.note.annotation, selectedHunkIndex);
122155
if (!anchorRow) {
123156
return null;
124157
}
125158

126-
const selectedHunkLineRows = rows.filter(
127-
(row): row is DiffLineRow =>
128-
row.hunkIndex === selectedHunkIndex &&
129-
(row.type === "split-line" || row.type === "stack-line"),
130-
);
159+
const selectedHunkLineRows = hunkLineRows(rows, selectedHunkIndex);
160+
const anchorSide = annotationAnchor(selectedNote.note.annotation)?.side;
131161
const coveredRows = selectedHunkLineRows.filter((row) =>
132-
rowOverlapsAnnotation(row, note.annotation),
162+
rowOverlapsAnnotation(row, selectedNote.note.annotation),
133163
);
134164
const fallbackGuideRow =
135-
anchorRow.type === "split-line" || anchorRow.type === "stack-line"
165+
anchorSide && (anchorRow.type === "split-line" || anchorRow.type === "stack-line")
136166
? anchorRow
137-
: selectedHunkLineRows[0];
167+
: undefined;
138168
const guideRows =
139169
coveredRows.length > 0 ? coveredRows : fallbackGuideRow ? [fallbackGuideRow] : [];
140-
const endGuideAfterKey = guideRows.at(-1)?.key ?? anchorRow.key;
141170

142171
return {
143172
anchorKey: anchorRow.key,
144-
anchorSide: annotationAnchor(note.annotation)?.side,
145-
coveredRowKeys: new Set(guideRows.map((row) => row.key)),
146-
endGuideAfterKey,
147-
note,
148-
noteIndex: 0,
149-
noteCount: visibleAgentNotes.length,
150-
} satisfies SelectedInlineNote;
173+
anchorSide,
174+
guidedRowKeys: new Set(guideRows.map((row) => row.key)),
175+
endGuideAfterKey: guideRows.at(-1)?.key,
176+
note: selectedNote.note,
177+
noteIndex: selectedNote.noteIndex,
178+
noteCount: selectedNote.noteCount,
179+
} satisfies PrimaryVisibleInlineNote;
151180
}
152181

153182
function rowCanAnchorHunk(row: DiffRow, showHunkHeaders: boolean) {
@@ -158,7 +187,11 @@ function rowCanAnchorHunk(row: DiffRow, showHunkHeaders: boolean) {
158187
return row.type !== "collapsed" && row.type !== "hunk-header";
159188
}
160189

161-
/** Build the explicit presentational row plan for one file diff body. */
190+
/**
191+
* Build the explicit presentational row plan for one file diff body.
192+
* The plan always preserves diff-row order and may insert one inline note plus
193+
* one trailing guide cap for the primary visible note.
194+
*/
162195
export function buildReviewRenderPlan({
163196
fileId,
164197
rows,
@@ -172,7 +205,11 @@ export function buildReviewRenderPlan({
172205
showHunkHeaders: boolean;
173206
visibleAgentNotes?: VisibleAgentNote[];
174207
}) {
175-
const selectedInlineNote = buildSelectedInlineNote(rows, visibleAgentNotes, selectedHunkIndex);
208+
const primaryVisibleNote = buildPrimaryVisibleInlineNote(
209+
rows,
210+
visibleAgentNotes,
211+
selectedHunkIndex,
212+
);
176213
const plannedRows: PlannedReviewRow[] = [];
177214
const anchoredHunks = new Set<number>();
178215

@@ -185,17 +222,17 @@ export function buildReviewRenderPlan({
185222
anchoredHunks.add(row.hunkIndex);
186223
}
187224

188-
if (selectedInlineNote?.anchorKey === row.key) {
225+
if (primaryVisibleNote?.anchorKey === row.key) {
189226
plannedRows.push({
190227
kind: "inline-note",
191-
key: `inline-note:${selectedInlineNote.note.id}:${row.key}`,
228+
key: `inline-note:${primaryVisibleNote.note.id}:${row.key}`,
192229
fileId,
193230
hunkIndex: row.hunkIndex,
194-
annotationId: selectedInlineNote.note.id,
195-
annotation: selectedInlineNote.note.annotation,
196-
anchorSide: selectedInlineNote.anchorSide,
197-
noteCount: selectedInlineNote.noteCount,
198-
noteIndex: selectedInlineNote.noteIndex,
231+
annotationId: primaryVisibleNote.note.id,
232+
annotation: primaryVisibleNote.note.annotation,
233+
anchorSide: primaryVisibleNote.anchorSide,
234+
noteCount: primaryVisibleNote.noteCount,
235+
noteIndex: primaryVisibleNote.noteIndex,
199236
});
200237
}
201238

@@ -206,18 +243,18 @@ export function buildReviewRenderPlan({
206243
hunkIndex: row.hunkIndex,
207244
row,
208245
anchorId,
209-
noteGuideSide: selectedInlineNote?.coveredRowKeys.has(row.key)
210-
? selectedInlineNote.anchorSide
246+
noteGuideSide: primaryVisibleNote?.guidedRowKeys.has(row.key)
247+
? primaryVisibleNote.anchorSide
211248
: undefined,
212249
});
213250

214-
if (selectedInlineNote?.endGuideAfterKey === row.key && selectedInlineNote.anchorSide) {
251+
if (primaryVisibleNote?.anchorSide && primaryVisibleNote.endGuideAfterKey === row.key) {
215252
plannedRows.push({
216253
kind: "note-guide-cap",
217-
key: `note-guide-cap:${selectedInlineNote.note.id}:${row.key}`,
254+
key: `note-guide-cap:${primaryVisibleNote.note.id}:${row.key}`,
218255
fileId,
219256
hunkIndex: row.hunkIndex,
220-
side: selectedInlineNote.anchorSide,
257+
side: primaryVisibleNote.anchorSide,
221258
});
222259
}
223260
}

0 commit comments

Comments
 (0)