From c75c801f13ffbabbc0661132b453fb6023f808ec Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 2 Jun 2026 11:22:38 -0400 Subject: [PATCH 1/4] feat(ui): distinguish active review notes --- src/ui/App.tsx | 1 + src/ui/components/panes/AgentInlineNote.tsx | 64 ++++++-- src/ui/components/panes/DiffPane.tsx | 39 +++-- src/ui/components/ui-components.test.tsx | 26 +++ src/ui/diff/PierreDiffView.tsx | 1 + src/ui/hooks/useReviewController.test.tsx | 49 ++++++ src/ui/hooks/useReviewController.ts | 168 ++++++++++++++++---- src/ui/lib/agentAnnotations.ts | 14 +- 8 files changed, 302 insertions(+), 60 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index b2f40184..23d41bcd 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -872,6 +872,7 @@ export function App({ scrollRef={diffScrollRef} selectedFileId={selectedFile?.id} selectedHunkIndex={selectedHunkIndex} + activeNoteId={review.activeNoteId} scrollToNote={review.scrollToNote} draftNote={review.draftNote} draftNoteFocused={focusArea === "note"} diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index ed442cda..d073fe62 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -122,6 +122,7 @@ export function AgentInlineNote({ layout, noteCount = 1, noteIndex = 0, + active = false, draft, onClose, theme, @@ -133,6 +134,7 @@ export function AgentInlineNote({ layout: Exclude; noteCount?: number; noteIndex?: number; + active?: boolean; draft?: { body: string; focused: boolean; @@ -192,6 +194,7 @@ export function AgentInlineNote({ const closeText = onClose ? "[x]" : ""; const titleText = `${inlineNoteTitle(annotation, noteIndex, noteCount)} - ${annotationRangeLabel(annotation, file)}`; + const savedDisplayTitleText = titleText; const splitWidths = splitColumnWidths(width); const canDockRight = layout === "split" && anchorSide === "new" && width >= 84; const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84; @@ -257,7 +260,7 @@ export function AgentInlineNote({ : []), ]; const savedTitleText = fitText( - ` ${titleText} `, + ` ${savedDisplayTitleText} `, Math.max(0, boxWidth - 4 - closeGapWidth - closeWidth), ); const savedTopBorderSuffixWidth = Math.max( @@ -266,6 +269,29 @@ export function AgentInlineNote({ ); const savedTopPrefixWidth = 2 + savedTitleText.length + savedTopBorderSuffixWidth; const bottomBorder = `╰${"─".repeat(Math.max(0, boxWidth - 2))}╯`; + const savedBorderColor = theme.noteBorder; + const savedHeaderBackground = theme.panel; + const activeCursor = "»"; + const savedTopLeft = active && boxLeft === 0 ? activeCursor : "╭"; + const savedTopLeftColor = active && boxLeft === 0 ? theme.text : savedBorderColor; + const renderSavedHeaderGutter = () => { + if (!active || boxLeft <= 0) { + return {" ".repeat(boxLeft)}; + } + + if (boxLeft === 1) { + return {activeCursor}; + } + + return ( + + {" ".repeat(Math.max(0, boxLeft - 2))} + + {activeCursor} + {" "} + + ); + }; if (draft) { const draftVisibleLineCount = draftVisibleRows; @@ -504,7 +530,7 @@ export function AgentInlineNote({ {" ".repeat(boxLeft)} - + @@ -516,7 +542,7 @@ export function AgentInlineNote({ - + @@ -527,38 +553,44 @@ export function AgentInlineNote({ - {" ".repeat(boxLeft)} + {renderSavedHeaderGutter()} - + - - ╭─ + + {savedTopLeft}─ - + {savedTitleText} - + {"─".repeat(savedTopBorderSuffixWidth)} {closeText ? ( - - {" ".repeat(closeGapWidth)} + + {" ".repeat(closeGapWidth)} ) : null} {closeText ? ( - + {closeText} ) : null} - - + + @@ -575,7 +607,7 @@ export function AgentInlineNote({ {" ".repeat(boxLeft)} - + {bottomBorder} diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index d3bb6950..c51d6ef0 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -25,6 +25,7 @@ import type { DraftReviewNote } from "../../hooks/useReviewController"; import { alwaysShowReviewNote, reviewNoteSource, + visibleReviewNoteId, type VisibleAgentNote, } from "../../lib/agentAnnotations"; import { @@ -178,6 +179,7 @@ export function DiffPane({ scrollRef, selectedFileId, selectedHunkIndex, + activeNoteId = null, scrollToNote = false, draftNote = null, draftNoteFocused = false, @@ -224,6 +226,7 @@ export function DiffPane({ scrollRef: RefObject; selectedFileId?: string; selectedHunkIndex: number; + activeNoteId?: string | null; scrollToNote?: boolean; draftNote?: DraftReviewNote | null; draftNoteFocused?: boolean; @@ -346,16 +349,19 @@ export function DiffPane({ (annotation) => showAgentNotes || alwaysShowReviewNote(annotation), ); const notes: VisibleAgentNote[] = annotations.map((annotation, index) => { + const id = visibleReviewNoteId(file, annotation, index); const source = reviewNoteSource(annotation); if (source !== "user") { return { - id: `annotation:${file.id}:${annotation.id ?? index}`, + id, + active: id === activeNoteId, annotation, }; } return { - id: `annotation:${file.id}:${annotation.id ?? index}`, + id, + active: id === activeNoteId, annotation, source, editable: true, @@ -396,6 +402,7 @@ export function DiffPane({ return next; }, [ + activeNoteId, draftNote, draftNoteFocused, files, @@ -1215,12 +1222,22 @@ export function DiffPane({ const sectionRelativeHunkTop = selectedEstimatedHunkBounds.top - selectedEstimatedHunkBounds.sectionTop; const sectionRelativeHunkBottom = sectionRelativeHunkTop + selectedEstimatedHunkBounds.height; - const noteRow = geometry.rowBounds.find( - (row) => - row.key.startsWith("inline-note:") && - row.top >= sectionRelativeHunkTop && - row.top < sectionRelativeHunkBottom, - ); + const noteRow = activeNoteId + ? geometry.rowBoundsByStableKey.get(`inline-note:${activeNoteId}`) + : geometry.rowBounds.find( + (row) => + row.key.startsWith("inline-note:") && + row.top >= sectionRelativeHunkTop && + row.top < sectionRelativeHunkBottom, + ); + + if ( + noteRow && + activeNoteId && + (noteRow.top < sectionRelativeHunkTop || noteRow.top >= sectionRelativeHunkBottom) + ) { + return null; + } if (!noteRow) { return null; @@ -1230,7 +1247,7 @@ export function DiffPane({ top: selectedEstimatedHunkBounds.sectionTop + noteRow.top, height: noteRow.height, }; - }, [scrollToNote, sectionGeometry, selectedEstimatedHunkBounds, selectedFileIndex]); + }, [activeNoteId, scrollToNote, sectionGeometry, selectedEstimatedHunkBounds, selectedFileIndex]); const selectedEstimatedHunkTop = selectedEstimatedHunkBounds?.top ?? null; const selectedEstimatedHunkHeight = selectedEstimatedHunkBounds?.height ?? null; const selectedEstimatedHunkStartRowId = selectedEstimatedHunkBounds?.startRowId ?? null; @@ -1714,7 +1731,9 @@ export function DiffPane({ headerLabelWidth={headerLabelWidth} headerStatsWidth={headerStatsWidth} layout={layout} - selectedHunkIndex={file.id === selectedFileId ? selectedHunkIndex : -1} + selectedHunkIndex={ + activeNoteId ? -1 : file.id === selectedFileId ? selectedHunkIndex : -1 + } copySelectedRowRanges={copySelectedRowKeysByFile.get(file.id)} copySelectedSide={copySelectionSide} shouldLoadHighlight={highlightPrefetchFileIds.has(file.id)} diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 52f70191..71781729 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1711,6 +1711,32 @@ describe("UI components", () => { expect(lines[4]?.trimStart().startsWith("╰")).toBe(true); }); + test("AgentInlineNote marks the active saved note with a neutral left arrow", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + , + 100, + 5, + ); + + const lines = frame.split("\n"); + expect(lines[0]).toContain("» ╭─ Agent note - R2–R4"); + expect(lines[0]).not.toContain("ACTIVE"); + expect(lines[2]).not.toContain("»"); + expect(lines[2]).toContain("Summary line"); + }); + test("AgentInlineNote renders draft notes as an editable composer", async () => { const theme = resolveTheme("midnight", null); const file = createTestDiffFile( diff --git a/src/ui/diff/PierreDiffView.tsx b/src/ui/diff/PierreDiffView.tsx index 15bf6855..e4ce9799 100644 --- a/src/ui/diff/PierreDiffView.tsx +++ b/src/ui/diff/PierreDiffView.tsx @@ -304,6 +304,7 @@ export function PierreDiffView({ > { } }); + test("annotated navigation advances note-by-note and tracks the active note", async () => { + const { controllerRef, setup } = await renderReviewController([ + createDiffFile( + "alpha", + "alpha.ts", + "export const alpha = 1;\n", + "export const alpha = 2;\n", + { + path: "alpha.ts", + summary: "file note", + annotations: [ + { id: "first", newRange: [1, 1], summary: "First note" }, + { id: "second", newRange: [1, 1], summary: "Second note" }, + ], + }, + ), + createDiffFile("beta", "beta.ts", "export const beta = 1;\n", "export const beta = 2;\n"), + ]); + + try { + await flush(setup); + + await act(async () => { + expectValue(controllerRef.current).moveToAnnotatedHunk(1); + }); + await flush(setup); + expect(expectValue(controllerRef.current).activeNoteId).toBe("annotation:alpha:first"); + expect(expectValue(controllerRef.current).scrollToNote).toBe(true); + + await act(async () => { + expectValue(controllerRef.current).moveToAnnotatedHunk(1); + }); + await flush(setup); + expect(expectValue(controllerRef.current).activeNoteId).toBe("annotation:alpha:second"); + expect(expectValue(controllerRef.current).selectedHunkIndex).toBe(0); + + await act(async () => { + expectValue(controllerRef.current).moveToHunk(1); + }); + await flush(setup); + expect(expectValue(controllerRef.current).activeNoteId).toBeNull(); + expect(expectValue(controllerRef.current).selectedFile?.path).toBe("beta.ts"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("batch live comments validate together and reveal the first applied hunk", async () => { const { controllerRef, setup } = await renderReviewController([createTwoHunkFile()]); diff --git a/src/ui/hooks/useReviewController.ts b/src/ui/hooks/useReviewController.ts index 0feab177..262b4d01 100644 --- a/src/ui/hooks/useReviewController.ts +++ b/src/ui/hooks/useReviewController.ts @@ -40,7 +40,11 @@ import type { FileSourceStatus } from "../diff/expandCollapsedRows"; import { selectGapForKeyboardToggle } from "../diff/expandCollapsedRows"; import { trailingCollapsedLines } from "../diff/pierre"; import { findNextHunkCursor } from "../lib/hunks"; -import { reviewNoteSource } from "../lib/agentAnnotations"; +import { + annotationHunkIndex, + reviewNoteSource, + visibleReviewNoteId, +} from "../lib/agentAnnotations"; import { buildReviewState, buildSelectedHunkSummary, @@ -83,6 +87,75 @@ function removeKeys(record: Record, keys: ReadonlySet): Re return changed ? next : record; } +/** Move through note cursors using the active note first, then the current hunk as a fallback. */ +function findNextReviewNoteCursor({ + activeNoteId, + currentFileId, + currentHunkIndex, + cursors, + delta, + streamCursors, +}: { + activeNoteId: string | null; + currentFileId: string | undefined; + currentHunkIndex: number; + cursors: ReviewNoteCursor[]; + delta: number; + streamCursors: { fileId: string; hunkIndex: number }[]; +}) { + if (cursors.length === 0) { + return null; + } + + const activeIndex = activeNoteId + ? cursors.findIndex((cursor) => cursor.noteId === activeNoteId) + : -1; + if (activeIndex >= 0) { + return cursors[Math.min(Math.max(activeIndex + delta, 0), cursors.length - 1)] ?? null; + } + + const currentHunkNoteIndices = cursors + .map((cursor, index) => ({ cursor, index })) + .filter( + ({ cursor }) => cursor.fileId === currentFileId && cursor.hunkIndex === currentHunkIndex, + ) + .map(({ index }) => index); + if (currentHunkNoteIndices.length > 0) { + return ( + cursors[delta >= 0 ? currentHunkNoteIndices[0]! : currentHunkNoteIndices.at(-1)!] ?? null + ); + } + + const currentStreamIndex = streamCursors.findIndex( + (cursor) => cursor.fileId === currentFileId && cursor.hunkIndex === currentHunkIndex, + ); + if (currentStreamIndex < 0) { + return cursors[delta >= 0 ? 0 : cursors.length - 1] ?? null; + } + + const streamIndexByHunk = new Map( + streamCursors.map((cursor, index) => [`${cursor.fileId}\0${cursor.hunkIndex}`, index] as const), + ); + const indexedCursors = cursors + .map((cursor, index) => ({ + index, + streamIndex: streamIndexByHunk.get(`${cursor.fileId}\0${cursor.hunkIndex}`) ?? -1, + })) + .filter(({ streamIndex }) => streamIndex >= 0); + + if (delta >= 0) { + return ( + cursors[ + indexedCursors.find(({ streamIndex }) => streamIndex > currentStreamIndex)?.index ?? + cursors.length - 1 + ] ?? null + ); + } + + const previous = indexedCursors.findLast(({ streamIndex }) => streamIndex < currentStreamIndex); + return cursors[previous?.index ?? 0] ?? null; +} + export interface UserReviewNote extends AgentAnnotation { id: string; source: "user"; @@ -114,7 +187,14 @@ interface SourceLoadRequest { side: "old" | "new"; } +interface ReviewNoteCursor { + noteId: string; + fileId: string; + hunkIndex: number; +} + export interface ReviewSelectionOptions { + activeNoteId?: string | null; alignFileHeaderTop?: boolean; preserveViewport?: boolean; scrollToNote?: boolean; @@ -124,6 +204,7 @@ export interface ReviewController { allFiles: DiffFile[]; expandedGapsByFileId: Record>; filter: string; + activeNoteId: string | null; draftNote: DraftReviewNote | null; liveCommentCount: number; liveCommentSummaries: SessionLiveCommentSummary[]; @@ -183,6 +264,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon const [selectedFileTopAlignRequestId, setSelectedFileTopAlignRequestId] = useState(0); const [selectedHunkRevealRequestId, setSelectedHunkRevealRequestId] = useState(0); const [scrollToNote, setScrollToNote] = useState(false); + const [activeNoteId, setActiveNoteId] = useState(null); const [liveCommentsByFileId, setLiveCommentsByFileId] = useState>( {}, ); @@ -233,38 +315,51 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon const deferredFilter = useDeferredValue(filter); - const { - allFiles, - visibleFiles, - sidebarEntries, - selectedFile, - selectedHunk, - hunkCursors, - annotatedHunkCursors, - } = useMemo( - () => - buildReviewState({ + const { allFiles, visibleFiles, sidebarEntries, selectedFile, selectedHunk, hunkCursors } = + useMemo( + () => + buildReviewState({ + files, + liveCommentsByFileId: mergeAnnotationMaps(liveCommentsByFileId, userNotesByFileId), + filterQuery: deferredFilter, + selectedFileId, + selectedHunkIndex, + }), + [ + deferredFilter, files, - liveCommentsByFileId: mergeAnnotationMaps(liveCommentsByFileId, userNotesByFileId), - filterQuery: deferredFilter, + liveCommentsByFileId, selectedFileId, selectedHunkIndex, - }), - [ - deferredFilter, - files, - liveCommentsByFileId, - selectedFileId, - selectedHunkIndex, - userNotesByFileId, - ], + userNotesByFileId, + ], + ); + + const reviewNoteCursors = useMemo( + () => + visibleFiles.flatMap((file) => + (file.agent?.annotations ?? []).flatMap((annotation, index) => { + const hunkIndex = annotationHunkIndex(file, annotation); + return hunkIndex >= 0 + ? [{ noteId: visibleReviewNoteId(file, annotation, index), fileId: file.id, hunkIndex }] + : []; + }), + ), + [visibleFiles], ); - /** Update the selection and reveal intent together so diff scrolling stays explicit. */ + useEffect(() => { + if (activeNoteId && !reviewNoteCursors.some((cursor) => cursor.noteId === activeNoteId)) { + setActiveNoteId(null); + } + }, [activeNoteId, reviewNoteCursors]); + + /** Update the active target, selection, and reveal intent together so diff scrolling stays explicit. */ const selectHunk = useCallback( (fileId: string, hunkIndex: number, options?: ReviewSelectionOptions) => { setSelectedFileId(fileId); setSelectedHunkIndex(hunkIndex); + setActiveNoteId(options?.activeNoteId ?? null); setScrollToNote(Boolean(options?.scrollToNote)); if (options?.alignFileHeaderTop) { @@ -354,23 +449,27 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon [hunkCursors, selectHunk, selectedFile?.id, selectedHunkIndex], ); - /** Move through only hunks that currently have agent notes or live comments. */ + /** Move through notes in review-stream order and make the destination note active. */ const moveToAnnotatedHunk = useCallback( (delta: number) => { - const nextCursor = findNextHunkCursor( - annotatedHunkCursors, - selectedFile?.id, - selectedHunkIndex, + const nextCursor = findNextReviewNoteCursor({ + activeNoteId, + currentFileId: selectedFile?.id, + currentHunkIndex: selectedHunkIndex, + cursors: reviewNoteCursors, delta, - hunkCursors, - ); + streamCursors: hunkCursors, + }); if (!nextCursor) { return; } - selectHunk(nextCursor.fileId, nextCursor.hunkIndex, { scrollToNote: true }); + selectHunk(nextCursor.fileId, nextCursor.hunkIndex, { + activeNoteId: nextCursor.noteId, + scrollToNote: true, + }); }, - [annotatedHunkCursors, hunkCursors, selectHunk, selectedFile?.id, selectedHunkIndex], + [activeNoteId, hunkCursors, reviewNoteCursors, selectHunk, selectedFile?.id, selectedHunkIndex], ); /** Cycle through only the currently visible files that carry annotations. */ @@ -807,6 +906,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon ...notesByFile, [draftNote.fileId]: [...(notesByFile[draftNote.fileId] ?? []), savedNote], })); + setActiveNoteId(`annotation:${draftNote.fileId}:${savedNote.id}`); setDraftNote(null); return savedNote; }, [draftNote]); @@ -831,6 +931,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon throw new Error(`No user note matches id ${noteId}.`); } + setActiveNoteId((current) => (current?.endsWith(`:${noteId}`) ? null : current)); setUserNotesByFileId(next); }, [userNotesByFileId], @@ -922,6 +1023,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon return { allFiles, + activeNoteId, draftNote, expandedGapsByFileId, filter, diff --git a/src/ui/lib/agentAnnotations.ts b/src/ui/lib/agentAnnotations.ts index 49bcf3cb..6384084f 100644 --- a/src/ui/lib/agentAnnotations.ts +++ b/src/ui/lib/agentAnnotations.ts @@ -7,6 +7,7 @@ export interface VisibleAgentNote { id: string; annotation: AgentAnnotation; source?: ReviewNoteSource | "draft"; + active?: boolean; editable?: boolean; draft?: { body: string; @@ -43,13 +44,18 @@ export function alwaysShowReviewNote(annotation: AgentAnnotation) { return reviewNoteSource(annotation) === "user"; } +/** Build the stable UI id used for selecting and rendering one review note. */ +export function visibleReviewNoteId(file: DiffFile, annotation: AgentAnnotation, index: number) { + return `annotation:${file.id}:${annotation.id ?? index}`; +} + /** Check whether two inclusive line ranges overlap. */ function overlap(rangeA: [number, number], rangeB: [number, number]) { return rangeA[0] <= rangeB[1] && rangeB[0] <= rangeA[1]; } /** Check whether an annotation belongs to the visible span of a hunk. */ -function annotationOverlapsHunk(annotation: AgentAnnotation, hunk: Hunk) { +export function annotationOverlapsHunk(annotation: AgentAnnotation, hunk: Hunk) { const hunkRange = hunkLineRange(hunk); if (annotation.newRange && overlap(annotation.newRange, hunkRange.newRange)) { @@ -63,6 +69,12 @@ function annotationOverlapsHunk(annotation: AgentAnnotation, hunk: Hunk) { return false; } +/** Resolve the hunk that owns one annotation, falling back to the first hunk for range-less notes. */ +export function annotationHunkIndex(file: DiffFile, annotation: AgentAnnotation) { + const index = file.metadata.hunks.findIndex((hunk) => annotationOverlapsHunk(annotation, hunk)); + return index >= 0 ? index : file.metadata.hunks.length > 0 ? 0 : -1; +} + /** Return the annotations relevant to the currently selected hunk. */ export function getSelectedAnnotations(file: DiffFile | undefined, hunk: Hunk | undefined) { if (!file?.agent || !hunk) { From f7423917829cb32f16bd82d9d85f34ddbe5e28be Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 2 Jun 2026 11:25:02 -0400 Subject: [PATCH 2/4] feat(ui): reply to active review notes --- src/ui/App.tsx | 10 ++++++ src/ui/hooks/useAppKeyboardShortcuts.ts | 17 +++++++-- src/ui/hooks/useReviewController.test.tsx | 44 +++++++++++++++++++++++ src/ui/hooks/useReviewController.ts | 34 ++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 23d41bcd..3db870fa 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -574,6 +574,14 @@ export function App({ [activeAddNoteTarget, review.startUserNote], ); + /** Start a reply draft for the active review note and move keyboard focus into it. */ + const replyToActiveNote = useCallback(() => { + const draft = review.replyToActiveNote(); + if (draft) { + setFocusArea("note"); + } + }, [review.replyToActiveNote]); + /** Mark the inline draft note textarea as the active keyboard input. */ const focusDraftNote = useCallback(() => { setFocusArea("note"); @@ -686,6 +694,7 @@ export function App({ activeMenuId, activateCurrentMenuItem, canRefreshCurrentInput, + canReplyToActiveNote: Boolean(review.activeNoteId), closeHelp, closeMenu, cycleTheme, @@ -699,6 +708,7 @@ export function App({ openMenu, pagerMode, requestQuit, + replyToActiveNote, scrollCodeHorizontally, saveDraftNote, scrollDiff, diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index 85b1ca20..1e8f2d95 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -46,6 +46,7 @@ export interface UseAppKeyboardShortcutsOptions { activeMenuId: MenuId | null; activateCurrentMenuItem: () => void; canRefreshCurrentInput: boolean; + canReplyToActiveNote: boolean; closeHelp: () => void; closeMenu: () => void; cycleTheme: () => void; @@ -59,6 +60,7 @@ export interface UseAppKeyboardShortcutsOptions { openMenu: (menuId: MenuId) => void; pagerMode: boolean; requestQuit: () => void; + replyToActiveNote: () => void; scrollCodeHorizontally: (delta: number) => void; scrollDiff: (delta: number, unit: ScrollUnit) => void; saveDraftNote: () => void; @@ -83,6 +85,7 @@ export function useAppKeyboardShortcuts({ activeMenuId, activateCurrentMenuItem, canRefreshCurrentInput, + canReplyToActiveNote, closeHelp, closeMenu, cycleTheme, @@ -96,6 +99,7 @@ export function useAppKeyboardShortcuts({ openMenu, pagerMode, requestQuit, + replyToActiveNote, scrollCodeHorizontally, saveDraftNote, scrollDiff, @@ -431,8 +435,17 @@ export function useAppKeyboardShortcuts({ return; } - if ((key.name === "r" || key.sequence === "r") && canRefreshCurrentInput) { - runAndCloseMenu(triggerRefreshCurrentInput); + if (key.name === "r" || key.sequence === "r") { + runAndCloseMenu(() => { + if (canReplyToActiveNote) { + replyToActiveNote(); + return; + } + + if (canRefreshCurrentInput) { + triggerRefreshCurrentInput(); + } + }); return; } diff --git a/src/ui/hooks/useReviewController.test.tsx b/src/ui/hooks/useReviewController.test.tsx index 5d4e642d..102283ca 100644 --- a/src/ui/hooks/useReviewController.test.tsx +++ b/src/ui/hooks/useReviewController.test.tsx @@ -394,6 +394,50 @@ describe("useReviewController", () => { } }); + test("replyToActiveNote starts a draft at the active note anchor", async () => { + const { controllerRef, setup } = await renderReviewController([ + createDiffFile( + "alpha", + "alpha.ts", + "export const alpha = 1;\n", + "export const alpha = 2;\n", + { + path: "alpha.ts", + annotations: [{ id: "review", newRange: [1, 1], summary: "Review note" }], + }, + ), + ]); + + try { + await flush(setup); + + await act(async () => { + expectValue(controllerRef.current).moveToAnnotatedHunk(1); + }); + await flush(setup); + expect(expectValue(controllerRef.current).activeNoteId).toBe("annotation:alpha:review"); + + let draft = null as ReturnType; + await act(async () => { + draft = expectValue(controllerRef.current).replyToActiveNote(); + }); + await flush(setup); + + expect(draft).toMatchObject({ fileId: "alpha", hunkIndex: 0, side: "new", line: 1 }); + expect(expectValue(controllerRef.current).draftNote).toMatchObject({ + fileId: "alpha", + hunkIndex: 0, + side: "new", + line: 1, + }); + expect(expectValue(controllerRef.current).activeNoteId).toBeNull(); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("batch live comments validate together and reveal the first applied hunk", async () => { const { controllerRef, setup } = await renderReviewController([createTwoHunkFile()]); diff --git a/src/ui/hooks/useReviewController.ts b/src/ui/hooks/useReviewController.ts index 262b4d01..8f52952e 100644 --- a/src/ui/hooks/useReviewController.ts +++ b/src/ui/hooks/useReviewController.ts @@ -41,6 +41,7 @@ import { selectGapForKeyboardToggle } from "../diff/expandCollapsedRows"; import { trailingCollapsedLines } from "../diff/pierre"; import { findNextHunkCursor } from "../lib/hunks"; import { + annotationAnchor, annotationHunkIndex, reviewNoteSource, visibleReviewNoteId, @@ -244,6 +245,7 @@ export interface ReviewController { removeLiveComment: (commentId: string) => RemovedCommentResult; cancelDraftNote: () => void; removeUserNote: (noteId: string) => void; + replyToActiveNote: () => DraftReviewNote | null; saveDraftNote: () => UserReviewNote | null; selectFile: (fileId: string, nextHunkIndex?: number, options?: ReviewSelectionOptions) => void; selectHunk: (fileId: string, hunkIndex: number, options?: ReviewSelectionOptions) => void; @@ -865,6 +867,37 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon [allFiles, selectHunk, selectedFile?.id, selectedHunkIndex], ); + /** Start a reply draft at the active note's hunk and line anchor. */ + const replyToActiveNote = useCallback((): DraftReviewNote | null => { + if (!activeNoteId) { + return null; + } + + for (const file of allFiles) { + const annotationIndex = file.agent?.annotations.findIndex( + (annotation, index) => visibleReviewNoteId(file, annotation, index) === activeNoteId, + ); + if (annotationIndex === undefined || annotationIndex < 0) { + continue; + } + + const annotation = file.agent!.annotations[annotationIndex]!; + const hunkIndex = annotationHunkIndex(file, annotation); + if (hunkIndex < 0) { + return null; + } + + const anchor = annotationAnchor(annotation); + return startUserNote( + file.id, + hunkIndex, + anchor ? { side: anchor.side, line: anchor.lineNumber } : undefined, + ); + } + + return null; + }, [activeNoteId, allFiles, startUserNote]); + /** Update the body of the active draft note. */ const updateDraftNote = useCallback((body: string) => { setDraftNote((current) => (current ? { ...current, body } : current)); @@ -1057,6 +1090,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon navigateToLocation, removeLiveComment, removeUserNote, + replyToActiveNote, saveDraftNote, selectFile, selectHunk, From f760b9a0d2add743b82e7b2720fe3d3daeb0faac Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Fri, 5 Jun 2026 12:09:09 -0400 Subject: [PATCH 3/4] feat(ui): show active note reply hint --- src/ui/components/panes/AgentInlineNote.tsx | 53 ++++++++++----------- src/ui/components/ui-components.test.tsx | 7 +-- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index d073fe62..9bf8a9c8 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -194,7 +194,7 @@ export function AgentInlineNote({ const closeText = onClose ? "[x]" : ""; const titleText = `${inlineNoteTitle(annotation, noteIndex, noteCount)} - ${annotationRangeLabel(annotation, file)}`; - const savedDisplayTitleText = titleText; + const savedDisplayTitleText = active ? `› ${titleText}` : titleText; const splitWidths = splitColumnWidths(width); const canDockRight = layout === "split" && anchorSide === "new" && width >= 84; const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84; @@ -268,30 +268,19 @@ export function AgentInlineNote({ boxWidth - 3 - savedTitleText.length - closeGapWidth - closeWidth, ); const savedTopPrefixWidth = 2 + savedTitleText.length + savedTopBorderSuffixWidth; - const bottomBorder = `╰${"─".repeat(Math.max(0, boxWidth - 2))}╯`; + const bottomBorderAction = " [ Reply (r) ] "; + const bottomBorderActionFits = active && boxWidth - 2 >= bottomBorderAction.length + 2; + const bottomBorderLeftWidth = bottomBorderActionFits + ? Math.max(1, boxWidth - 2 - bottomBorderAction.length - 1) + : Math.max(0, boxWidth - 2); + const bottomBorderRightWidth = bottomBorderActionFits + ? Math.max(0, boxWidth - 2 - bottomBorderAction.length - bottomBorderLeftWidth) + : 0; + const bottomBorder = `╰${"─".repeat(bottomBorderLeftWidth)}${ + bottomBorderActionFits ? bottomBorderAction : "" + }${"─".repeat(bottomBorderRightWidth)}╯`; const savedBorderColor = theme.noteBorder; const savedHeaderBackground = theme.panel; - const activeCursor = "»"; - const savedTopLeft = active && boxLeft === 0 ? activeCursor : "╭"; - const savedTopLeftColor = active && boxLeft === 0 ? theme.text : savedBorderColor; - const renderSavedHeaderGutter = () => { - if (!active || boxLeft <= 0) { - return {" ".repeat(boxLeft)}; - } - - if (boxLeft === 1) { - return {activeCursor}; - } - - return ( - - {" ".repeat(Math.max(0, boxLeft - 2))} - - {activeCursor} - {" "} - - ); - }; if (draft) { const draftVisibleLineCount = draftVisibleRows; @@ -553,7 +542,7 @@ export function AgentInlineNote({ - {renderSavedHeaderGutter()} + {" ".repeat(boxLeft)} - - {savedTopLeft}─ - - - {savedTitleText} + + ╭─ + {active ? ( + + {savedTitleText} + + ) : ( + + {savedTitleText} + + )} {"─".repeat(savedTopBorderSuffixWidth)} diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 71781729..8fa0b11c 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1711,7 +1711,7 @@ describe("UI components", () => { expect(lines[4]?.trimStart().startsWith("╰")).toBe(true); }); - test("AgentInlineNote marks the active saved note with a neutral left arrow", async () => { + test("AgentInlineNote marks the active saved note with a bold title chevron", async () => { const theme = resolveTheme("midnight", null); const frame = await captureFrame( { ); const lines = frame.split("\n"); - expect(lines[0]).toContain("» ╭─ Agent note - R2–R4"); + expect(lines[0]).toContain("╭─ › Agent note - R2–R4"); expect(lines[0]).not.toContain("ACTIVE"); - expect(lines[2]).not.toContain("»"); + expect(lines[2]).not.toContain("›"); expect(lines[2]).toContain("Summary line"); + expect(lines[4]).toContain("[ Reply (r) ]"); }); test("AgentInlineNote renders draft notes as an editable composer", async () => { From 5f68adc127bc572c4909ae58aa2ff43a100c3f48 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 8 Jun 2026 17:16:47 -0400 Subject: [PATCH 4/4] feat(ui): add active note actions --- src/ui/App.tsx | 12 ++ src/ui/components/panes/AgentInlineNote.tsx | 91 +++++++++++++-- src/ui/components/panes/DiffPane.tsx | 22 +++- src/ui/components/ui-components.test.tsx | 117 +++++++++++++++++++- src/ui/diff/PierreDiffView.tsx | 2 + src/ui/hooks/useAppKeyboardShortcuts.ts | 9 ++ src/ui/hooks/useReviewController.test.tsx | 19 ++++ src/ui/hooks/useReviewController.ts | 84 +++++++++++++- src/ui/lib/agentAnnotations.ts | 2 + 9 files changed, 344 insertions(+), 14 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 3db870fa..e94d6d19 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -574,6 +574,14 @@ export function App({ [activeAddNoteTarget, review.startUserNote], ); + /** Start editing the active user note and move keyboard focus into it. */ + const editActiveNote = useCallback(() => { + const draft = review.editActiveNote(); + if (draft) { + setFocusArea("note"); + } + }, [review.editActiveNote]); + /** Start a reply draft for the active review note and move keyboard focus into it. */ const replyToActiveNote = useCallback(() => { const draft = review.replyToActiveNote(); @@ -693,12 +701,14 @@ export function App({ useAppKeyboardShortcuts({ activeMenuId, activateCurrentMenuItem, + canEditActiveNote: review.activeNoteCanEdit, canRefreshCurrentInput, canReplyToActiveNote: Boolean(review.activeNoteId), closeHelp, closeMenu, cycleTheme, cancelDraftNote, + editActiveNote, focusArea, focusFilter, moveToAnnotatedHunk, @@ -900,7 +910,9 @@ export function App({ theme={activeTheme} width={diffPaneWidth} onActiveAddNoteAffordanceChange={setActiveAddNoteTarget} + onEditActiveNote={editActiveNote} onRemoveUserNote={review.removeUserNote} + onReplyToActiveNote={replyToActiveNote} onSaveDraftNote={saveDraftNote} onStartUserNoteAtHunk={startUserNote} onUpdateDraftNote={review.updateDraftNote} diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index 9bf8a9c8..be2467b1 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -125,6 +125,8 @@ export function AgentInlineNote({ active = false, draft, onClose, + onEdit, + onReply, theme, width, }: { @@ -145,6 +147,8 @@ export function AgentInlineNote({ onSave: () => void; }; onClose?: () => void; + onEdit?: () => void; + onReply?: () => void; theme: AppTheme; width: number; }) { @@ -268,7 +272,11 @@ export function AgentInlineNote({ boxWidth - 3 - savedTitleText.length - closeGapWidth - closeWidth, ); const savedTopPrefixWidth = 2 + savedTitleText.length + savedTopBorderSuffixWidth; - const bottomBorderAction = " [ Reply (r) ] "; + const replyButtonText = "[ Reply (r) ]"; + const editButtonText = "[ Edit (e) ]"; + const bottomBorderAction = annotation.editable + ? ` ${replyButtonText} ${editButtonText} ` + : ` ${replyButtonText} `; const bottomBorderActionFits = active && boxWidth - 2 >= bottomBorderAction.length + 2; const bottomBorderLeftWidth = bottomBorderActionFits ? Math.max(1, boxWidth - 2 - bottomBorderAction.length - 1) @@ -276,9 +284,7 @@ export function AgentInlineNote({ const bottomBorderRightWidth = bottomBorderActionFits ? Math.max(0, boxWidth - 2 - bottomBorderAction.length - bottomBorderLeftWidth) : 0; - const bottomBorder = `╰${"─".repeat(bottomBorderLeftWidth)}${ - bottomBorderActionFits ? bottomBorderAction : "" - }${"─".repeat(bottomBorderRightWidth)}╯`; + const bottomBorder = `╰${"─".repeat(Math.max(0, boxWidth - 2))}╯`; const savedBorderColor = theme.noteBorder; const savedHeaderBackground = theme.panel; @@ -601,11 +607,78 @@ export function AgentInlineNote({ {" ".repeat(boxLeft)} - - - {bottomBorder} - - + {bottomBorderActionFits ? ( + + + + ╰ + + + + + {"─".repeat(bottomBorderLeftWidth)} + + + + + {" "} + + + + + {replyButtonText} + + + {annotation.editable ? ( + + + {" "} + + + ) : null} + {annotation.editable ? ( + + + {editButtonText} + + + ) : null} + + + {" "} + + + + + {"─".repeat(bottomBorderRightWidth)} + + + + + ╯ + + + + ) : ( + + + {bottomBorder} + + + )} ); diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index c51d6ef0..5ab1f62e 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -202,7 +202,9 @@ export function DiffPane({ width, cancelCopySelectionRef, onActiveAddNoteAffordanceChange, + onEditActiveNote, onRemoveUserNote, + onReplyToActiveNote, onSaveDraftNote, onStartUserNoteAtHunk, onUpdateDraftNote, @@ -251,7 +253,9 @@ export function DiffPane({ onActiveAddNoteAffordanceChange?: ( affordance: (ActiveAddNoteAffordance & { fileId: string }) | null, ) => void; + onEditActiveNote?: () => void; onRemoveUserNote?: (noteId: string) => void; + onReplyToActiveNote?: () => void; onSaveDraftNote?: () => void; onStartUserNoteAtHunk?: (fileId: string, hunkIndex: number, target?: UserNoteLineTarget) => void; onUpdateDraftNote?: (body: string) => void; @@ -350,22 +354,26 @@ export function DiffPane({ ); const notes: VisibleAgentNote[] = annotations.map((annotation, index) => { const id = visibleReviewNoteId(file, annotation, index); + const active = id === activeNoteId; const source = reviewNoteSource(annotation); if (source !== "user") { return { id, - active: id === activeNoteId, + active, annotation, + onReply: active ? onReplyToActiveNote : undefined, }; } return { id, - active: id === activeNoteId, + active, annotation, source, editable: true, + onEdit: active ? onEditActiveNote : undefined, onRemove: annotation.id ? () => onRemoveUserNote?.(annotation.id!) : undefined, + onReply: active ? onReplyToActiveNote : undefined, }; }); @@ -408,8 +416,10 @@ export function DiffPane({ files, onBlurDraftNote, onCancelDraftNote, + onEditActiveNote, onFocusDraftNote, onRemoveUserNote, + onReplyToActiveNote, onSaveDraftNote, onUpdateDraftNote, showAgentNotes, @@ -1541,6 +1551,14 @@ export function DiffPane({ // hunk can request a top offset that is no longer reachable once the viewport hits EOF. // Using the reachable value keeps the reveal logic from fighting later manual scrolling. if (selectedNoteBounds) { + const currentScrollTop = scrollBox.scrollTop ?? 0; + const noteTop = selectedNoteBounds.top; + const noteBottom = selectedNoteBounds.top + selectedNoteBounds.height; + const viewportBottom = currentScrollTop + viewportHeight; + if (noteTop >= currentScrollTop && noteBottom <= viewportBottom) { + return; + } + const revealScrollTop = computeHunkRevealScrollTop({ hunkTop: selectedNoteBounds.top, hunkHeight: selectedNoteBounds.height, diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 8fa0b11c..a830068b 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1655,6 +1655,74 @@ describe("UI components", () => { } }); + test("DiffPane scrollToNote leaves an already-visible inline note in place", async () => { + const theme = resolveTheme("midnight", null); + const scrollRef = createRef(); + const beforeLines = Array.from( + { length: 12 }, + (_, index) => `export const line${index + 1} = ${index + 1};`, + ); + const afterLines = [...beforeLines]; + afterLines[3] = "export const line4 = 400;"; + const file = createTestDiffFile( + "visible-note", + "visible-note.ts", + lines(...beforeLines), + lines(...afterLines), + ); + file.agent = { + path: file.path, + annotations: [{ newRange: [4, 4], summary: "Already visible note." }], + }; + let revealNote = () => {}; + + function VisibleNoteHarness() { + const [request, setRequest] = useState({ id: 0, scrollToNote: false }); + revealNote = () => setRequest({ id: 1, scrollToNote: true }); + + return ( + + ); + } + + const setup = await testRender(, { width: 104, height: 12 }); + + try { + await settleDiffPane(setup); + await act(async () => { + scrollRef.current?.scrollTo(2); + }); + await settleDiffPane(setup); + const scrollTopBeforeReveal = scrollRef.current?.scrollTop ?? 0; + expect(setup.captureCharFrame()).toContain("Already visible note."); + + await act(async () => { + revealNote(); + }); + await settleDiffPane(setup); + + expect(scrollRef.current?.scrollTop ?? 0).toBe(scrollTopBeforeReveal); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("AgentCard removes top and bottom padding while keeping the footer inside the frame", async () => { const theme = resolveTheme("midnight", null); const frame = await captureFrame( @@ -1720,6 +1788,7 @@ describe("UI components", () => { newRange: [2, 4], summary: "Summary line", rationale: "Rationale line.", + editable: true, }} anchorSide="new" layout="split" @@ -1735,7 +1804,53 @@ describe("UI components", () => { expect(lines[0]).not.toContain("ACTIVE"); expect(lines[2]).not.toContain("›"); expect(lines[2]).toContain("Summary line"); - expect(lines[4]).toContain("[ Reply (r) ]"); + expect(lines[4]).toContain("[ Reply (r) ] [ Edit (e) ]"); + }); + + test("AgentInlineNote active action hints are clickable", async () => { + const theme = resolveTheme("midnight", null); + const onReply = mock(() => {}); + const onEdit = mock(() => {}); + const setup = await testRender( + , + { width: 100, height: 5 }, + ); + + try { + await act(async () => { + await setup.renderOnce(); + }); + const lines = setup.captureCharFrame().split("\n"); + const actionY = lines.findIndex((line) => line.includes("[ Reply (r) ]")); + const replyX = lines[actionY]!.indexOf("[ Reply (r) ]") + 1; + const editX = lines[actionY]!.indexOf("[ Edit (e) ]") + 1; + + await act(async () => { + await setup.mockMouse.click(replyX, actionY); + await setup.mockMouse.click(editX, actionY); + await setup.renderOnce(); + }); + + expect(onReply).toHaveBeenCalledTimes(1); + expect(onEdit).toHaveBeenCalledTimes(1); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } }); test("AgentInlineNote renders draft notes as an editable composer", async () => { diff --git a/src/ui/diff/PierreDiffView.tsx b/src/ui/diff/PierreDiffView.tsx index e4ce9799..e97162e1 100644 --- a/src/ui/diff/PierreDiffView.tsx +++ b/src/ui/diff/PierreDiffView.tsx @@ -312,6 +312,8 @@ export function PierreDiffView({ noteCount={plannedRow.noteCount} noteIndex={plannedRow.noteIndex} onClose={plannedRow.note.onRemove} + onEdit={plannedRow.note.onEdit} + onReply={plannedRow.note.onReply} theme={theme} width={width} /> diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index 1e8f2d95..49bca9ad 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -45,12 +45,14 @@ function isUppercaseGKey(key: KeyEvent) { export interface UseAppKeyboardShortcutsOptions { activeMenuId: MenuId | null; activateCurrentMenuItem: () => void; + canEditActiveNote: boolean; canRefreshCurrentInput: boolean; canReplyToActiveNote: boolean; closeHelp: () => void; closeMenu: () => void; cycleTheme: () => void; cancelDraftNote: () => void; + editActiveNote: () => void; focusArea: FocusArea; focusFilter: () => void; moveToAnnotatedHunk: (delta: number) => void; @@ -84,12 +86,14 @@ export interface UseAppKeyboardShortcutsOptions { export function useAppKeyboardShortcuts({ activeMenuId, activateCurrentMenuItem, + canEditActiveNote, canRefreshCurrentInput, canReplyToActiveNote, closeHelp, closeMenu, cycleTheme, cancelDraftNote, + editActiveNote, focusArea, focusFilter, moveToAnnotatedHunk, @@ -449,6 +453,11 @@ export function useAppKeyboardShortcuts({ return; } + if ((key.name === "e" || key.sequence === "e") && canEditActiveNote) { + runAndCloseMenu(editActiveNote); + return; + } + if (key.name === "t") { runAndCloseMenu(cycleTheme); return; diff --git a/src/ui/hooks/useReviewController.test.tsx b/src/ui/hooks/useReviewController.test.tsx index 102283ca..d0fbf6b8 100644 --- a/src/ui/hooks/useReviewController.test.tsx +++ b/src/ui/hooks/useReviewController.test.tsx @@ -615,6 +615,25 @@ describe("useReviewController", () => { editable: true, }, ]); + expect(expectValue(controllerRef.current).activeNoteCanEdit).toBe(true); + + await act(async () => { + expectValue(controllerRef.current).editActiveNote(); + expectValue(controllerRef.current).updateDraftNote("Please add two regression tests."); + }); + await flush(setup); + + await act(async () => { + expectValue(controllerRef.current).saveDraftNote(); + }); + await flush(setup); + + expect(expectValue(controllerRef.current).userNotesByFileId.alpha).toHaveLength(1); + expect(expectValue(controllerRef.current).reviewNoteSummaries[0]).toMatchObject({ + noteId: savedNoteId, + body: "Please add two regression tests.", + editable: true, + }); await act(async () => { expectValue(controllerRef.current).removeUserNote(savedNoteId); diff --git a/src/ui/hooks/useReviewController.ts b/src/ui/hooks/useReviewController.ts index 8f52952e..7a5e377d 100644 --- a/src/ui/hooks/useReviewController.ts +++ b/src/ui/hooks/useReviewController.ts @@ -180,6 +180,7 @@ export interface DraftReviewNote { oldRange?: [number, number]; newRange?: [number, number]; body: string; + editingNoteId?: string; } interface SourceLoadRequest { @@ -192,6 +193,7 @@ interface ReviewNoteCursor { noteId: string; fileId: string; hunkIndex: number; + editable: boolean; } export interface ReviewSelectionOptions { @@ -205,6 +207,7 @@ export interface ReviewController { allFiles: DiffFile[]; expandedGapsByFileId: Record>; filter: string; + activeNoteCanEdit: boolean; activeNoteId: string | null; draftNote: DraftReviewNote | null; liveCommentCount: number; @@ -245,6 +248,7 @@ export interface ReviewController { removeLiveComment: (commentId: string) => RemovedCommentResult; cancelDraftNote: () => void; removeUserNote: (noteId: string) => void; + editActiveNote: () => DraftReviewNote | null; replyToActiveNote: () => DraftReviewNote | null; saveDraftNote: () => UserReviewNote | null; selectFile: (fileId: string, nextHunkIndex?: number, options?: ReviewSelectionOptions) => void; @@ -343,7 +347,14 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon (file.agent?.annotations ?? []).flatMap((annotation, index) => { const hunkIndex = annotationHunkIndex(file, annotation); return hunkIndex >= 0 - ? [{ noteId: visibleReviewNoteId(file, annotation, index), fileId: file.id, hunkIndex }] + ? [ + { + noteId: visibleReviewNoteId(file, annotation, index), + fileId: file.id, + hunkIndex, + editable: annotation.editable === true, + }, + ] : []; }), ), @@ -356,6 +367,9 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon } }, [activeNoteId, reviewNoteCursors]); + const activeNoteCanEdit = + reviewNoteCursors.find((cursor) => cursor.noteId === activeNoteId)?.editable ?? false; + /** Update the active target, selection, and reveal intent together so diff scrolling stays explicit. */ const selectHunk = useCallback( (fileId: string, hunkIndex: number, options?: ReviewSelectionOptions) => { @@ -867,6 +881,45 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon [allFiles, selectHunk, selectedFile?.id, selectedHunkIndex], ); + /** Start editing the active user note in the inline composer. */ + const editActiveNote = useCallback((): DraftReviewNote | null => { + if (!activeNoteId) { + return null; + } + + for (const file of allFiles) { + const note = (userNotesByFileId[file.id] ?? []).find( + (candidate) => visibleReviewNoteId(file, candidate, 0) === activeNoteId, + ); + if (!note) { + continue; + } + + const hunkIndex = note.hunkIndex ?? annotationHunkIndex(file, note); + if (hunkIndex < 0) { + return null; + } + + const draft: DraftReviewNote = { + id: `draft:${file.id}:${hunkIndex}:${Date.now()}`, + fileId: file.id, + filePath: file.path, + hunkIndex, + side: note.side, + line: note.line, + oldRange: note.oldRange, + newRange: note.newRange, + body: note.summary, + editingNoteId: note.id, + }; + setDraftNote(draft); + selectHunk(file.id, hunkIndex, { preserveViewport: true }); + return draft; + } + + return null; + }, [activeNoteId, allFiles, selectHunk, userNotesByFileId]); + /** Start a reply draft at the active note's hunk and line anchor. */ const replyToActiveNote = useCallback((): DraftReviewNote | null => { if (!activeNoteId) { @@ -920,6 +973,31 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon return null; } + if (draftNote.editingNoteId) { + const existingNote = (userNotesByFileId[draftNote.fileId] ?? []).find( + (note) => note.id === draftNote.editingNoteId, + ); + if (!existingNote) { + setDraftNote(null); + return null; + } + + const updatedNote: UserReviewNote = { + ...existingNote, + summary: body, + updatedAt: new Date().toISOString(), + }; + setUserNotesByFileId((notesByFile) => ({ + ...notesByFile, + [draftNote.fileId]: (notesByFile[draftNote.fileId] ?? []).map((note) => + note.id === draftNote.editingNoteId ? updatedNote : note, + ), + })); + setActiveNoteId(`annotation:${draftNote.fileId}:${draftNote.editingNoteId}`); + setDraftNote(null); + return updatedNote; + } + const savedNote: UserReviewNote = { id: `user:${Date.now()}`, source: "user", @@ -942,7 +1020,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon setActiveNoteId(`annotation:${draftNote.fileId}:${savedNote.id}`); setDraftNote(null); return savedNote; - }, [draftNote]); + }, [draftNote, userNotesByFileId]); /** Remove one in-memory user note by id. */ const removeUserNote = useCallback( @@ -1056,6 +1134,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon return { allFiles, + activeNoteCanEdit, activeNoteId, draftNote, expandedGapsByFileId, @@ -1087,6 +1166,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon moveToAnnotatedHunk, moveToFile, moveToHunk, + editActiveNote, navigateToLocation, removeLiveComment, removeUserNote, diff --git a/src/ui/lib/agentAnnotations.ts b/src/ui/lib/agentAnnotations.ts index 6384084f..f6a5f154 100644 --- a/src/ui/lib/agentAnnotations.ts +++ b/src/ui/lib/agentAnnotations.ts @@ -18,7 +18,9 @@ export interface VisibleAgentNote { onInput: (value: string) => void; onSave: () => void; }; + onEdit?: () => void; onRemove?: () => void; + onReply?: () => void; } export interface AnnotationAnchor {