Skip to content

Commit 3ccecbf

Browse files
authored
feat: render compact floating agent note popovers (#40)
1 parent 13d8333 commit 3ccecbf

8 files changed

Lines changed: 573 additions & 96 deletions

File tree

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,50 @@
1+
import { buildAgentPopoverContent } from "../../lib/agentPopover";
2+
import { fitText, padText } from "../../lib/text";
13
import type { AppTheme } from "../../themes";
2-
import { fitText } from "../../lib/text";
34

4-
/** Render one inline agent note card beside the diff rows it explains. */
5+
/** Render one framed floating agent note popover. */
56
export function AgentCard({
67
locationLabel,
8+
noteCount = 1,
9+
noteIndex = 0,
710
rationale,
811
onClose,
912
summary,
1013
theme,
1114
width,
1215
}: {
1316
locationLabel: string;
17+
noteCount?: number;
18+
noteIndex?: number;
1419
rationale?: string;
1520
onClose?: () => void;
1621
summary: string;
1722
theme: AppTheme;
1823
width: number;
1924
}) {
25+
const popover = buildAgentPopoverContent({
26+
summary,
27+
rationale,
28+
locationLabel,
29+
noteIndex,
30+
noteCount,
31+
width,
32+
});
33+
const titleWidth = Math.max(1, popover.innerWidth - (onClose ? 4 : 0));
34+
2035
return (
2136
<box
2237
style={{
2338
width,
39+
height: popover.height,
2440
border: true,
25-
borderColor: theme.accentMuted,
26-
backgroundColor: theme.panelAlt,
27-
padding: 1,
41+
borderColor: theme.accent,
42+
backgroundColor: theme.panel,
43+
paddingLeft: 1,
44+
paddingRight: 1,
45+
paddingTop: 0,
46+
paddingBottom: 0,
2847
flexDirection: "column",
29-
gap: 1,
3048
}}
3149
>
3250
<box
@@ -35,17 +53,42 @@ export function AgentCard({
3553
height: 1,
3654
flexDirection: "row",
3755
justifyContent: "space-between",
56+
backgroundColor: theme.panel,
3857
}}
3958
>
40-
<text fg={theme.accent}>{fitText(locationLabel, Math.max(1, width - (onClose ? 6 : 2)))}</text>
59+
<text fg={theme.accent}>{padText(fitText(popover.title, titleWidth), titleWidth)}</text>
4160
{onClose ? (
42-
<box onMouseUp={onClose}>
61+
<box onMouseUp={onClose} style={{ backgroundColor: theme.panel }}>
4362
<text fg={theme.muted}>[x]</text>
4463
</box>
4564
) : null}
4665
</box>
47-
<text fg={theme.text}>{summary}</text>
48-
{rationale ? <text fg={theme.muted}>{rationale}</text> : null}
66+
67+
{popover.summaryLines.map((line, index) => (
68+
<box key={`summary:${index}`} style={{ width: "100%", height: 1, backgroundColor: theme.panel }}>
69+
<text fg={theme.text}>{padText(line, popover.innerWidth)}</text>
70+
</box>
71+
))}
72+
73+
{popover.rationaleLines.length > 0 ? (
74+
<>
75+
<box style={{ width: "100%", height: 1, backgroundColor: theme.panel }}>
76+
<text fg={theme.text}>{" ".repeat(popover.innerWidth)}</text>
77+
</box>
78+
{popover.rationaleLines.map((line, index) => (
79+
<box key={`rationale:${index}`} style={{ width: "100%", height: 1, backgroundColor: theme.panel }}>
80+
<text fg={theme.muted}>{padText(line, popover.innerWidth)}</text>
81+
</box>
82+
))}
83+
</>
84+
) : null}
85+
86+
<box style={{ width: "100%", height: 1, backgroundColor: theme.panel }}>
87+
<text fg={theme.text}>{" ".repeat(popover.innerWidth)}</text>
88+
</box>
89+
<box style={{ width: "100%", height: 1, backgroundColor: theme.panel }}>
90+
<text fg={theme.muted}>{padText(popover.footer, popover.innerWidth)}</text>
91+
</box>
4992
</box>
5093
);
5194
}

src/ui/components/panes/DiffPane.tsx

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { ScrollBoxRenderable } from "@opentui/core";
22
import { useCallback, useEffect, useLayoutEffect, useMemo, useState, type RefObject } from "react";
33
import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types";
4-
import type { VisibleAgentNote } from "../../lib/agentAnnotations";
4+
import { AgentCard } from "./AgentCard";
5+
import { annotationLocationLabel, type VisibleAgentNote } from "../../lib/agentAnnotations";
6+
import { buildAgentPopoverContent, resolveAgentPopoverPlacement } from "../../lib/agentPopover";
57
import { estimateDiffBodyRows, estimateHunkAnchorRow } from "../../lib/sectionHeights";
68
import { diffHunkId, diffSectionId } from "../../lib/ids";
79
import type { AppTheme } from "../../themes";
@@ -10,6 +12,24 @@ import { DiffSectionPlaceholder } from "./DiffSectionPlaceholder";
1012

1113
const EMPTY_VISIBLE_AGENT_NOTES: VisibleAgentNote[] = [];
1214

15+
function maxLineNumber(file: DiffFile) {
16+
return Math.max(file.metadata.additionLines.length, file.metadata.deletionLines.length, 0);
17+
}
18+
19+
function noteAnchorColumn(
20+
file: DiffFile,
21+
layout: Exclude<LayoutMode, "auto">,
22+
width: number,
23+
showLineNumbers: boolean,
24+
note: VisibleAgentNote,
25+
) {
26+
if (layout === "split") {
27+
return note.annotation.oldRange && !note.annotation.newRange ? 1 : Math.max(2, Math.floor(width * 0.58));
28+
}
29+
30+
return showLineNumbers ? Math.max(2, String(maxLineNumber(file)).length + 4) : 2;
31+
}
32+
1333
/** Render the main multi-file review stream. */
1434
export function DiffPane({
1535
activeAnnotations,
@@ -144,6 +164,65 @@ export function DiffPane({
144164
() => files.map((file) => estimateDiffBodyRows(file, layout, showHunkHeaders)),
145165
[files, layout, showHunkHeaders],
146166
);
167+
const selectedOverlayNote = useMemo(() => {
168+
if (!selectedFileId) {
169+
return null;
170+
}
171+
172+
const selectedFileIndex = files.findIndex((file) => file.id === selectedFileId);
173+
if (selectedFileIndex < 0) {
174+
return null;
175+
}
176+
177+
const selectedFile = files[selectedFileIndex]!;
178+
const visibleNotes = visibleAgentNotesByFile.get(selectedFileId) ?? EMPTY_VISIBLE_AGENT_NOTES;
179+
const note = visibleNotes[0];
180+
if (!note) {
181+
return null;
182+
}
183+
184+
let sectionTop = 0;
185+
for (let index = 0; index < selectedFileIndex; index += 1) {
186+
sectionTop += (index > 0 ? 1 : 0) + 1 + (estimatedBodyHeights[index] ?? 0);
187+
}
188+
189+
sectionTop += (selectedFileIndex > 0 ? 1 : 0) + 1;
190+
const anchorRowTop = sectionTop + estimateHunkAnchorRow(selectedFile, layout, showHunkHeaders, selectedHunkIndex);
191+
const anchorColumn = noteAnchorColumn(selectedFile, layout, diffContentWidth, showLineNumbers, note);
192+
const noteWidth = Math.min(Math.max(34, Math.floor(diffContentWidth * 0.42)), Math.max(12, diffContentWidth - 2));
193+
const locationLabel = annotationLocationLabel(selectedFile, note.annotation);
194+
const popover = buildAgentPopoverContent({
195+
summary: note.annotation.summary,
196+
rationale: note.annotation.rationale,
197+
locationLabel,
198+
noteIndex: 0,
199+
noteCount: visibleNotes.length,
200+
width: noteWidth,
201+
});
202+
203+
const contentHeight = files.reduce(
204+
(total, file, index) => total + (index > 0 ? 1 : 0) + 1 + (estimatedBodyHeights[index] ?? 0),
205+
0,
206+
);
207+
const placement = resolveAgentPopoverPlacement({
208+
anchorColumn,
209+
anchorRowTop,
210+
anchorRowHeight: 1,
211+
contentHeight,
212+
noteHeight: popover.height,
213+
noteWidth,
214+
viewportWidth: diffContentWidth,
215+
});
216+
217+
return {
218+
note,
219+
noteCount: visibleNotes.length,
220+
noteWidth,
221+
left: placement.left,
222+
top: placement.top,
223+
locationLabel,
224+
};
225+
}, [diffContentWidth, estimatedBodyHeights, files, layout, selectedFileId, selectedHunkIndex, showHunkHeaders, showLineNumbers, visibleAgentNotesByFile]);
147226

148227
const visibleViewportFileIds = useMemo(() => {
149228
const overscanRows = 8;
@@ -267,7 +346,7 @@ export function DiffPane({
267346
verticalScrollbarOptions={{ visible: false }}
268347
horizontalScrollbarOptions={{ visible: false }}
269348
>
270-
<box style={{ width: "100%", flexDirection: "column" }}>
349+
<box style={{ width: "100%", flexDirection: "column", position: "relative", overflow: "visible" }}>
271350
{files.map((file, index) => {
272351
const shouldRenderSection = visibleWindowedFileIds?.has(file.id) ?? true;
273352
const shouldPrefetchVisibleHighlight =
@@ -295,7 +374,7 @@ export function DiffPane({
295374
wrapLines={wrapLines}
296375
theme={theme}
297376
viewWidth={diffContentWidth}
298-
visibleAgentNotes={visibleAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES}
377+
visibleAgentNotes={EMPTY_VISIBLE_AGENT_NOTES}
299378
onDismissAgentNote={onDismissAgentNote}
300379
onOpenAgentNotesAtHunk={(hunkIndex) => onOpenAgentNotesAtHunk(file.id, hunkIndex)}
301380
onSelect={() => onSelectFile(file.id)}
@@ -314,6 +393,19 @@ export function DiffPane({
314393
/>
315394
);
316395
})}
396+
{selectedFileId && selectedOverlayNote ? (
397+
<box style={{ position: "absolute", top: selectedOverlayNote.top, left: selectedOverlayNote.left, zIndex: 20 }}>
398+
<AgentCard
399+
locationLabel={selectedOverlayNote.locationLabel}
400+
noteCount={selectedOverlayNote.noteCount}
401+
rationale={selectedOverlayNote.note.annotation.rationale}
402+
summary={selectedOverlayNote.note.annotation.summary}
403+
theme={theme}
404+
width={selectedOverlayNote.noteWidth}
405+
onClose={() => onDismissAgentNote(selectedOverlayNote.note.id)}
406+
/>
407+
</box>
408+
) : null}
317409
</box>
318410
</scrollbox>
319411
) : (

src/ui/components/panes/DiffSection.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ function DiffSectionComponent({
6262
width: "100%",
6363
flexDirection: "column",
6464
backgroundColor: theme.panel,
65+
overflow: "visible",
6566
}}
6667
>
6768
{showSeparator ? (

0 commit comments

Comments
 (0)