Skip to content

Commit d655330

Browse files
committed
feat: scroll to inline note card on comment-to-comment navigation
When navigating via {/} or --next-comment/--prev-comment, scroll the viewport so the inline note card is positioned near the top (with 25% padding) rather than scrolling to the hunk top. This keeps the review note visible without requiring the user to scroll down through large hunks to find it. - Add scrollToNote prop to DiffPane that switches the scroll anchor from hunk bounds to the first inline-note row offset - Set scrollToNote in moveAnnotatedHunk, clear it on other navigation - Find the note row offset via section metrics row key prefix match - Add TDD-style component test verifying note visibility with and without the flag on a large hunk with a deep annotation
1 parent 05e491c commit d655330

File tree

3 files changed

+155
-1
lines changed

3 files changed

+155
-1
lines changed

src/ui/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ function AppShell({
126126
const [resizeStartWidth, setResizeStartWidth] = useState<number | null>(null);
127127
const [selectedFileId, setSelectedFileId] = useState(bootstrap.changeset.files[0]?.id ?? "");
128128
const [selectedHunkIndex, setSelectedHunkIndex] = useState(0);
129+
const [scrollToNote, setScrollToNote] = useState(false);
129130
const deferredFilter = useDeferredValue(filter);
130131

131132
const pagerMode = Boolean(bootstrap.input.options.pager);
@@ -135,6 +136,7 @@ function AppShell({
135136
filesScrollRef.current?.scrollChildIntoView(fileRowId(fileId));
136137
setSelectedFileId(fileId);
137138
setSelectedHunkIndex(nextHunkIndex);
139+
setScrollToNote(false);
138140
}, []);
139141

140142
const openAgentNotes = useCallback(() => {
@@ -285,6 +287,7 @@ function AppShell({
285287
filesScrollRef.current?.scrollChildIntoView(fileRowId(nextCursor.fileId));
286288
setSelectedFileId(nextCursor.fileId);
287289
setSelectedHunkIndex(nextCursor.hunkIndex);
290+
setScrollToNote(false);
288291
};
289292

290293
/** Move the review focus to the next or previous annotated hunk. */
@@ -302,6 +305,7 @@ function AppShell({
302305
filesScrollRef.current?.scrollChildIntoView(fileRowId(nextCursor.fileId));
303306
setSelectedFileId(nextCursor.fileId);
304307
setSelectedHunkIndex(nextCursor.hunkIndex);
308+
setScrollToNote(true);
305309
};
306310

307311
/** Scroll the main review pane by line steps, viewport fractions, or whole-content jumps. */
@@ -986,6 +990,7 @@ function AppShell({
986990
scrollRef={diffScrollRef}
987991
selectedFileId={selectedFile?.id}
988992
selectedHunkIndex={selectedHunkIndex}
993+
scrollToNote={scrollToNote}
989994
separatorWidth={diffSeparatorWidth}
990995
showAgentNotes={showAgentNotes}
991996
showLineNumbers={showLineNumbers}

src/ui/components/panes/DiffPane.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export function DiffPane({
129129
scrollRef,
130130
selectedFileId,
131131
selectedHunkIndex,
132+
scrollToNote = false,
132133
separatorWidth,
133134
pagerMode = false,
134135
showAgentNotes,
@@ -149,6 +150,7 @@ export function DiffPane({
149150
scrollRef: RefObject<ScrollBoxRenderable | null>;
150151
selectedFileId?: string;
151152
selectedHunkIndex: number;
153+
scrollToNote?: boolean;
152154
separatorWidth: number;
153155
pagerMode?: boolean;
154156
showAgentNotes: boolean;
@@ -439,9 +441,37 @@ export function DiffPane({
439441
height: hunkBounds.height,
440442
startRowId: hunkBounds.startRowId,
441443
endRowId: hunkBounds.endRowId,
444+
sectionTop,
442445
};
443446
}, [estimatedBodyHeights, sectionMetrics, selectedFile, selectedFileIndex, selectedHunkIndex]);
444447

448+
/** Absolute scroll offset and height of the first inline note in the selected hunk, if any. */
449+
const selectedNoteBounds = useMemo(() => {
450+
if (!scrollToNote || !selectedEstimatedHunkBounds || selectedFileIndex < 0) {
451+
return null;
452+
}
453+
454+
const metrics = sectionMetrics[selectedFileIndex];
455+
if (!metrics) {
456+
return null;
457+
}
458+
459+
const noteRow = metrics.rowMetrics.find(
460+
(row) =>
461+
row.key.startsWith("inline-note:") &&
462+
row.offset >= selectedEstimatedHunkBounds.top - selectedEstimatedHunkBounds.sectionTop,
463+
);
464+
465+
if (!noteRow) {
466+
return null;
467+
}
468+
469+
return {
470+
top: selectedEstimatedHunkBounds.sectionTop + noteRow.offset,
471+
height: noteRow.height,
472+
};
473+
}, [scrollToNote, sectionMetrics, selectedEstimatedHunkBounds, selectedFileIndex]);
474+
445475
// Track the previous selected anchor to detect actual selection changes.
446476
const prevSelectedAnchorIdRef = useRef<string | null>(null);
447477

@@ -520,6 +550,21 @@ export function DiffPane({
520550
const viewportHeight = Math.max(scrollViewport.height, scrollBox.viewport.height ?? 0);
521551
const preferredTopPadding = Math.max(2, Math.floor(viewportHeight * 0.25));
522552

553+
// When navigating comment-to-comment, scroll the inline note card near the viewport top
554+
// instead of positioning the entire hunk. Uses the same reveal function so the padding
555+
// behavior matches regular hunk navigation.
556+
if (selectedNoteBounds) {
557+
scrollBox.scrollTo(
558+
computeHunkRevealScrollTop({
559+
hunkTop: selectedNoteBounds.top,
560+
hunkHeight: selectedNoteBounds.height,
561+
preferredTopPadding,
562+
viewportHeight,
563+
}),
564+
);
565+
return;
566+
}
567+
523568
if (selectedEstimatedHunkBounds) {
524569
const viewportTop = scrollBox.viewport.y;
525570
const currentScrollTop = scrollBox.scrollTop;
@@ -564,7 +609,13 @@ export function DiffPane({
564609
return () => {
565610
timeouts.forEach((timeout) => clearTimeout(timeout));
566611
};
567-
}, [scrollRef, scrollViewport.height, selectedAnchorId, selectedEstimatedHunkBounds]);
612+
}, [
613+
scrollRef,
614+
scrollViewport.height,
615+
selectedAnchorId,
616+
selectedEstimatedHunkBounds,
617+
selectedNoteBounds,
618+
]);
568619

569620
// Configure scroll step size to scroll exactly 1 line per step
570621
useEffect(() => {

test/ui-components.test.tsx

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,104 @@ describe("UI components", () => {
590590
}
591591
});
592592

593+
test("DiffPane scrollToNote positions the inline note near the viewport top instead of the hunk top", async () => {
594+
const theme = resolveTheme("midnight", null);
595+
596+
// Build a file with two distant hunks so the second hunk is far below the first when scrolled
597+
// to the hunk top. The annotation anchors on the second hunk.
598+
const beforeLines = Array.from(
599+
{ length: 80 },
600+
(_, index) => `export const line${index + 1} = ${index + 1};`,
601+
);
602+
const afterLines = [...beforeLines];
603+
// Hunk 0: change at line 1
604+
afterLines[0] = "export const line1 = 100;";
605+
// Hunk 1: changes at lines 60-65 to make a multi-line hunk
606+
afterLines[59] = "export const line60 = 6000;";
607+
afterLines[60] = "export const line61 = 6100;";
608+
afterLines[61] = "export const line62 = 6200;";
609+
afterLines[62] = "export const line63 = 6300;";
610+
afterLines[63] = "export const line64 = 6400;";
611+
afterLines[64] = "export const line65 = 6500;";
612+
613+
const file = createDiffFile(
614+
"deep-note",
615+
"deep-note.ts",
616+
lines(...beforeLines),
617+
lines(...afterLines),
618+
);
619+
file.agent = {
620+
path: file.path,
621+
summary: "file note",
622+
annotations: [
623+
{
624+
newRange: [63, 63],
625+
summary: "Note anchored on second hunk.",
626+
},
627+
],
628+
};
629+
630+
// Without scrollToNote: hunk top (context before line 60) is near viewport top,
631+
// but the note card (anchored at line 63) may be below the visible area.
632+
const propsWithoutFlag = createDiffPaneProps([file], theme, {
633+
diffContentWidth: 96,
634+
headerLabelWidth: 48,
635+
selectedFileId: "deep-note",
636+
selectedHunkIndex: 1,
637+
separatorWidth: 92,
638+
showAgentNotes: true,
639+
showHunkHeaders: true,
640+
width: 100,
641+
});
642+
const setupWithout = await testRender(<DiffPane {...propsWithoutFlag} />, {
643+
width: 104,
644+
height: 12,
645+
});
646+
647+
try {
648+
await settleDiffPane(setupWithout);
649+
const frameWithout = setupWithout.captureCharFrame();
650+
651+
// Hunk context (lines near 57-59) should be visible at the top.
652+
expect(frameWithout).toContain("line57");
653+
// Note card should NOT be visible — it's below the 12-row viewport.
654+
expect(frameWithout).not.toContain("Note anchored on second hunk.");
655+
} finally {
656+
await act(async () => {
657+
setupWithout.renderer.destroy();
658+
});
659+
}
660+
661+
// With scrollToNote: note card should be near the viewport top.
662+
const propsWithFlag = createDiffPaneProps([file], theme, {
663+
diffContentWidth: 96,
664+
headerLabelWidth: 48,
665+
selectedFileId: "deep-note",
666+
selectedHunkIndex: 1,
667+
scrollToNote: true,
668+
separatorWidth: 92,
669+
showAgentNotes: true,
670+
showHunkHeaders: true,
671+
width: 100,
672+
});
673+
const setupWith = await testRender(<DiffPane {...propsWithFlag} />, {
674+
width: 104,
675+
height: 12,
676+
});
677+
678+
try {
679+
await settleDiffPane(setupWith);
680+
const frameWith = setupWith.captureCharFrame();
681+
682+
// Note should be visible.
683+
expect(frameWith).toContain("Note anchored on second hunk.");
684+
} finally {
685+
await act(async () => {
686+
setupWith.renderer.destroy();
687+
});
688+
}
689+
});
690+
593691
test("AgentCard removes top and bottom padding while keeping the footer inside the frame", async () => {
594692
const theme = resolveTheme("midnight", null);
595693
const frame = await captureFrame(

0 commit comments

Comments
 (0)