Skip to content

Commit 585e0cf

Browse files
authored
fix(ui): navigate comments by stream position (#315)
1 parent a9c84e3 commit 585e0cf

6 files changed

Lines changed: 208 additions & 5 deletions

File tree

src/ui/hooks/useReviewController.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,14 +225,15 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
225225
selectedFile?.id,
226226
selectedHunkIndex,
227227
delta,
228+
hunkCursors,
228229
);
229230
if (!nextCursor) {
230231
return;
231232
}
232233

233234
selectHunk(nextCursor.fileId, nextCursor.hunkIndex, { scrollToNote: true });
234235
},
235-
[annotatedHunkCursors, selectHunk, selectedFile?.id, selectedHunkIndex],
236+
[annotatedHunkCursors, hunkCursors, selectHunk, selectedFile?.id, selectedHunkIndex],
236237
);
237238

238239
/** Cycle through only the currently visible files that carry annotations. */

src/ui/lib/hunks.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,41 @@ describe("annotated hunk navigation", () => {
169169
hunkIndex: 1,
170170
});
171171
});
172+
173+
test("uses full stream position when annotated navigation starts on an unannotated hunk", () => {
174+
const streamCursors: HunkCursor[] = [
175+
{ fileId: "alpha", hunkIndex: 0 },
176+
{ fileId: "alpha", hunkIndex: 1 },
177+
{ fileId: "beta", hunkIndex: 0 },
178+
{ fileId: "gamma", hunkIndex: 0 },
179+
{ fileId: "gamma", hunkIndex: 1 },
180+
{ fileId: "omega", hunkIndex: 0 },
181+
];
182+
const annotatedCursors: HunkCursor[] = [
183+
{ fileId: "alpha", hunkIndex: 1 },
184+
{ fileId: "gamma", hunkIndex: 0 },
185+
{ fileId: "gamma", hunkIndex: 1 },
186+
];
187+
188+
expect(findNextHunkCursor(annotatedCursors, "beta", 0, 1, streamCursors)).toEqual({
189+
fileId: "gamma",
190+
hunkIndex: 0,
191+
});
192+
expect(findNextHunkCursor(annotatedCursors, "beta", 0, -1, streamCursors)).toEqual({
193+
fileId: "alpha",
194+
hunkIndex: 1,
195+
});
196+
expect(findNextHunkCursor(annotatedCursors, "alpha", 0, 1, streamCursors)).toEqual({
197+
fileId: "alpha",
198+
hunkIndex: 1,
199+
});
200+
expect(findNextHunkCursor(annotatedCursors, "gamma", 1, 1, streamCursors)).toEqual({
201+
fileId: "gamma",
202+
hunkIndex: 1,
203+
});
204+
expect(findNextHunkCursor(annotatedCursors, "omega", 0, 1, streamCursors)).toEqual({
205+
fileId: "gamma",
206+
hunkIndex: 1,
207+
});
208+
});
172209
});

src/ui/lib/hunks.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function findNextHunkCursor(
2929
currentFileId: string | undefined,
3030
currentHunkIndex: number,
3131
delta: number,
32+
streamCursors: HunkCursor[] = cursors,
3233
): HunkCursor | null {
3334
if (cursors.length === 0) {
3435
return null;
@@ -40,9 +41,56 @@ export function findNextHunkCursor(
4041
const nextIndex =
4142
currentIndex >= 0
4243
? Math.min(Math.max(currentIndex + delta, 0), cursors.length - 1)
43-
: delta >= 0
44-
? 0
45-
: cursors.length - 1;
44+
: findNearestCursorIndex(cursors, streamCursors, currentFileId, currentHunkIndex, delta);
4645

4746
return cursors[nextIndex] ?? null;
4847
}
48+
49+
/** Resolve relative movement when the current hunk is not in the target cursor subset. */
50+
function findNearestCursorIndex(
51+
cursors: HunkCursor[],
52+
streamCursors: HunkCursor[],
53+
currentFileId: string | undefined,
54+
currentHunkIndex: number,
55+
delta: number,
56+
) {
57+
if (!currentFileId) {
58+
return delta >= 0 ? 0 : cursors.length - 1;
59+
}
60+
61+
const currentStreamIndex = streamCursors.findIndex(
62+
(cursor) => cursor.fileId === currentFileId && cursor.hunkIndex === currentHunkIndex,
63+
);
64+
if (currentStreamIndex < 0) {
65+
return delta >= 0 ? 0 : cursors.length - 1;
66+
}
67+
68+
const streamIndexByCursor = new Map(
69+
streamCursors.map((cursor, index) => [`${cursor.fileId}\0${cursor.hunkIndex}`, index] as const),
70+
);
71+
const cursorStreamIndex = (cursor: HunkCursor) =>
72+
streamIndexByCursor.get(`${cursor.fileId}\0${cursor.hunkIndex}`) ?? -1;
73+
const indexedCursors = cursors
74+
.map((cursor, index) => ({ index, streamIndex: cursorStreamIndex(cursor) }))
75+
.filter(({ streamIndex }) => streamIndex >= 0);
76+
77+
if (indexedCursors.length === 0) {
78+
return delta >= 0 ? 0 : cursors.length - 1;
79+
}
80+
81+
// Comment navigation is non-cyclic like normal hunk navigation, so positions outside
82+
// the annotated span clamp to the nearest annotated edge instead of wrapping.
83+
if (delta >= 0) {
84+
const nextCursor = indexedCursors.find(({ streamIndex }) => streamIndex > currentStreamIndex);
85+
return nextCursor?.index ?? indexedCursors[indexedCursors.length - 1]!.index;
86+
}
87+
88+
for (let index = indexedCursors.length - 1; index >= 0; index -= 1) {
89+
const indexedCursor = indexedCursors[index]!;
90+
if (indexedCursor.streamIndex < currentStreamIndex) {
91+
return indexedCursor.index;
92+
}
93+
}
94+
95+
return indexedCursors[0]!.index;
96+
}

src/ui/lib/reviewState.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,15 @@ export function resolveReviewNavigationTarget({
132132
}): ReviewNavigationTarget {
133133
if (input.commentDirection) {
134134
const delta = input.commentDirection === "next" ? 1 : -1;
135+
const hunkCursors = buildHunkCursors(visibleFiles);
135136
const annotatedCursors = buildAnnotatedHunkCursors(visibleFiles);
136-
const nextCursor = findNextHunkCursor(annotatedCursors, currentFileId, currentHunkIndex, delta);
137+
const nextCursor = findNextHunkCursor(
138+
annotatedCursors,
139+
currentFileId,
140+
currentHunkIndex,
141+
delta,
142+
hunkCursors,
143+
);
137144

138145
if (!nextCursor) {
139146
throw new Error("No annotated hunks found in the current review.");

test/pty/harness.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,73 @@ export function createPtyHarness() {
155155
return { dir, before, after, agentContext };
156156
}
157157

158+
function createAgentNavigationRepoFixture() {
159+
const alphaBeforeLines = createNumberedExportLines(1, 80).split("\n");
160+
const alphaAfterLines = [...alphaBeforeLines];
161+
alphaAfterLines[0] = "export const line01 = 1001;";
162+
alphaAfterLines[59] = "export const line60 = 6000;";
163+
164+
const betaBeforeLines = createNumberedExportLines(81, 20).split("\n");
165+
const betaAfterLines = [...betaBeforeLines];
166+
betaAfterLines[0] = "export const line81 = 8100;";
167+
168+
const gammaBeforeLines = createNumberedExportLines(101, 80).split("\n");
169+
const gammaAfterLines = [...gammaBeforeLines];
170+
gammaAfterLines[0] = "export const line101 = 10100;";
171+
gammaAfterLines[59] = "export const line160 = 16000;";
172+
173+
const fixture = createGitRepoFixture([
174+
{
175+
path: "alpha.ts",
176+
before: `${alphaBeforeLines.join("\n")}\n`,
177+
after: `${alphaAfterLines.join("\n")}\n`,
178+
},
179+
{
180+
path: "beta.ts",
181+
before: `${betaBeforeLines.join("\n")}\n`,
182+
after: `${betaAfterLines.join("\n")}\n`,
183+
},
184+
{
185+
path: "gamma.ts",
186+
before: `${gammaBeforeLines.join("\n")}\n`,
187+
after: `${gammaAfterLines.join("\n")}\n`,
188+
},
189+
]);
190+
const agentContext = join(fixture.dir, "agent-context.json");
191+
192+
writeText(
193+
agentContext,
194+
JSON.stringify({
195+
version: 1,
196+
summary: "Agent navigation notes",
197+
files: [
198+
{
199+
path: "alpha.ts",
200+
annotations: [
201+
{
202+
newRange: [60, 60],
203+
summary: "Alpha note for navigation.",
204+
rationale: "Used to prove comment navigation can leave an earlier note.",
205+
},
206+
],
207+
},
208+
{
209+
path: "gamma.ts",
210+
annotations: [
211+
{
212+
newRange: [60, 60],
213+
summary: "Gamma note for navigation.",
214+
rationale: "Used to prove comment navigation resumes after an unannotated hunk.",
215+
},
216+
],
217+
},
218+
],
219+
}),
220+
);
221+
222+
return { ...fixture, agentContext };
223+
}
224+
158225
function createMultiHunkFilePair() {
159226
const dir = makeTempDir("hunk-tuistory-hunks-");
160227
const before = join(dir, "before.ts");
@@ -515,6 +582,7 @@ export function createPtyHarness() {
515582
cleanup,
516583
countMatches,
517584
createAgentFilePair,
585+
createAgentNavigationRepoFixture,
518586
createBottomClampedRepoFixture,
519587
createCollapsedTopRepoFixture,
520588
createCrossFileHunkNavigationRepoFixture,

test/pty/ui-integration.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,48 @@ describe("live UI integration", () => {
9292
}
9393
});
9494

95+
test("comment navigation resumes from an unannotated hunk in stream order", async () => {
96+
const fixture = harness.createAgentNavigationRepoFixture();
97+
const session = await harness.launchHunk({
98+
args: ["diff", "--mode", "split", "--agent-context", fixture.agentContext, "--agent-notes"],
99+
cwd: fixture.dir,
100+
cols: 160,
101+
rows: 14,
102+
});
103+
104+
try {
105+
const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
106+
timeout: 15_000,
107+
});
108+
expect(initial).not.toContain("Maximum update depth exceeded");
109+
110+
await session.press("}");
111+
const alphaNote = await harness.waitForSnapshot(
112+
session,
113+
(text) => text.includes("Alpha note for navigation."),
114+
5_000,
115+
);
116+
expect(alphaNote).toContain("Alpha note for navigation.");
117+
expect(alphaNote).not.toContain("Maximum update depth exceeded");
118+
119+
await session.press(".");
120+
await harness.waitForSnapshot(session, (text) => text.includes("line101 = 10100"), 5_000);
121+
122+
await session.press("}");
123+
const gammaNote = await harness.waitForSnapshot(
124+
session,
125+
(text) => text.includes("Gamma note for navigation."),
126+
5_000,
127+
);
128+
129+
expect(gammaNote).toContain("Gamma note for navigation.");
130+
expect(gammaNote).not.toContain("Alpha note for navigation.");
131+
expect(gammaNote).not.toContain("Maximum update depth exceeded");
132+
} finally {
133+
session.close();
134+
}
135+
});
136+
95137
test("real hunk navigation jumps to later hunks in the review stream", async () => {
96138
const fixture = harness.createMultiHunkFilePair();
97139
const session = await harness.launchHunk({

0 commit comments

Comments
 (0)