Skip to content

Commit 6ae1e5f

Browse files
committed
📛 feat(notes): show agent author in inline notes and popovers
The AgentAnnotation schema has carried an optional `author` field end-to-end (sidecar JSON, session daemon, wire protocol) but the TUI never surfaced it. Render it in the note title bar and the matching agent popover so reviewers can tell which agent left which note when multiple agents annotate the same diff. Falls back to "AI note" when author is absent for backward compat.
1 parent b0a22c1 commit 6ae1e5f

6 files changed

Lines changed: 170 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file.
66

77
### Added
88

9+
- Surfaced the agent author name in inline notes and the matching agent popover so multi-agent reviews are readable at a glance, with a fallback title when an annotation has no author.
10+
911
### Changed
1012

1113
### Fixed

examples/3-agent-review-demo/agent-context.json

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
{
1010
"newRange": [1, 3],
1111
"summary": "Adds one normalization helper for whitespace, case, and dashed shortcut terms.",
12-
"rationale": "This lets the search layer reason about one normalized token shape instead of repeating slightly different cleanup logic in multiple places."
12+
"rationale": "This lets the search layer reason about one normalized token shape instead of repeating slightly different cleanup logic in multiple places.",
13+
"author": "sonnet"
1314
}
1415
]
1516
},
@@ -20,7 +21,14 @@
2021
{
2122
"newRange": [15, 35],
2223
"summary": "Prefix and exact keyword matches now outrank weaker substring hits before the result list is sorted.",
23-
"rationale": "The old behavior made every match look equally good, which was fine for filtering but weak for command-palette ranking where the top result should usually be the most obvious intent."
24+
"rationale": "The old behavior made every match look equally good, which was fine for filtering but weak for command-palette ranking where the top result should usually be the most obvious intent.",
25+
"author": "sonnet"
26+
},
27+
{
28+
"newRange": [20, 27],
29+
"summary": "Worth checking the score floor — could mask edge cases.",
30+
"rationale": "The scoring thresholds (4, 3, 2, 1) look good but validate that zero-score items are properly filtered out.",
31+
"author": "prism"
2432
}
2533
]
2634
},
@@ -31,7 +39,8 @@
3139
{
3240
"newRange": [1, 8],
3341
"summary": "The preview now shows only the top three ranked commands.",
34-
"rationale": "Once ranking is reliable, the preview can stay compact and let the best results carry the review without flooding the UI."
42+
"rationale": "Once ranking is reliable, the preview can stay compact and let the best results carry the review without flooding the UI.",
43+
"author": "prism"
3544
}
3645
]
3746
},
@@ -42,7 +51,8 @@
4251
{
4352
"newRange": [1, 8],
4453
"summary": "The test covers a dashed query form so the new normalization helper has a visible behavioral contract.",
45-
"rationale": "Without a test that exercises `short-cuts` specifically, it would be easy to regress the helper and still pass on simpler substring-only cases."
54+
"rationale": "Without a test that exercises `short-cuts` specifically, it would be easy to regress the helper and still pass on simpler substring-only cases.",
55+
"author": "sonnet"
4656
}
4757
]
4858
}

src/ui/components/panes/AgentCard.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export function AgentCard({
1212
summary,
1313
theme,
1414
width,
15+
author,
1516
}: {
1617
locationLabel: string;
1718
noteCount?: number;
@@ -21,6 +22,7 @@ export function AgentCard({
2122
summary: string;
2223
theme: AppTheme;
2324
width: number;
25+
author?: string;
2426
}) {
2527
const popover = buildAgentPopoverContent({
2628
summary,
@@ -29,6 +31,7 @@ export function AgentCard({
2931
noteIndex,
3032
noteCount,
3133
width,
34+
author,
3235
});
3336
const titleWidth = Math.max(1, popover.innerWidth - (onClose ? 4 : 0));
3437

src/ui/components/panes/AgentInlineNote.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import type { AgentAnnotation, LayoutMode } from "../../../core/types";
2-
import { wrapText } from "../../lib/agentPopover";
2+
import { formatAgentNoteTitle, wrapText } from "../../lib/agentPopover";
33
import { annotationRangeLabel } from "../../lib/agentAnnotations";
44
import { fitText, padText } from "../../lib/text";
55
import type { AppTheme } from "../../themes";
66

7-
function inlineNoteTitle(noteIndex: number, noteCount: number) {
8-
return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note";
9-
}
10-
117
interface AgentInlineNoteLine {
128
kind: "summary" | "rationale";
139
text: string;
@@ -83,7 +79,7 @@ export function AgentInlineNote({
8379
width: number;
8480
}) {
8581
const closeText = onClose ? "[x]" : "";
86-
const titleText = `${inlineNoteTitle(noteIndex, noteCount)} · ${annotationRangeLabel(annotation)}`;
82+
const titleText = `${formatAgentNoteTitle(noteIndex, noteCount, annotation.author)} · ${annotationRangeLabel(annotation)}`;
8783
const splitWidths = splitColumnWidths(width);
8884
const canDockRight = layout === "split" && anchorSide === "new" && width >= 84;
8985
const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84;

src/ui/components/ui-components.test.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,147 @@ describe("UI components", () => {
12851285
expect(lines[4]?.trimStart().startsWith("└")).toBe(true);
12861286
});
12871287

1288+
test("AgentInlineNote shows author name in title when author is set", async () => {
1289+
const theme = resolveTheme("midnight", null);
1290+
const frame = await captureFrame(
1291+
<AgentInlineNote
1292+
annotation={{
1293+
newRange: [2, 4],
1294+
summary: "Summary line",
1295+
author: "sonnet",
1296+
}}
1297+
anchorSide="new"
1298+
layout="split"
1299+
theme={theme}
1300+
width={96}
1301+
onClose={() => {}}
1302+
/>,
1303+
100,
1304+
5,
1305+
);
1306+
1307+
const lines = frame.split("\n");
1308+
expect(lines[1]).toContain("sonnet");
1309+
expect(lines[1]).not.toContain("AI note");
1310+
});
1311+
1312+
test("AgentInlineNote falls back to 'AI note' when author is absent", async () => {
1313+
const theme = resolveTheme("midnight", null);
1314+
const frame = await captureFrame(
1315+
<AgentInlineNote
1316+
annotation={{
1317+
newRange: [2, 4],
1318+
summary: "Summary line",
1319+
}}
1320+
anchorSide="new"
1321+
layout="split"
1322+
theme={theme}
1323+
width={96}
1324+
onClose={() => {}}
1325+
/>,
1326+
100,
1327+
5,
1328+
);
1329+
1330+
const lines = frame.split("\n");
1331+
expect(lines[1]).toContain("AI note");
1332+
});
1333+
1334+
test("AgentInlineNote includes index when multiple notes share a hunk", async () => {
1335+
const theme = resolveTheme("midnight", null);
1336+
const frame = await captureFrame(
1337+
<AgentInlineNote
1338+
annotation={{
1339+
newRange: [2, 4],
1340+
summary: "Summary line",
1341+
author: "sonnet",
1342+
}}
1343+
anchorSide="new"
1344+
layout="split"
1345+
noteCount={2}
1346+
noteIndex={0}
1347+
theme={theme}
1348+
width={96}
1349+
onClose={() => {}}
1350+
/>,
1351+
100,
1352+
5,
1353+
);
1354+
1355+
const lines = frame.split("\n");
1356+
expect(lines[1]).toContain("sonnet");
1357+
expect(lines[1]).toContain("1/2");
1358+
});
1359+
1360+
test("AgentInlineNote preserves special characters in author", async () => {
1361+
const theme = resolveTheme("midnight", null);
1362+
const frame = await captureFrame(
1363+
<AgentInlineNote
1364+
annotation={{
1365+
newRange: [2, 4],
1366+
summary: "Summary line",
1367+
author: "prism (arbiter)",
1368+
}}
1369+
anchorSide="new"
1370+
layout="split"
1371+
theme={theme}
1372+
width={96}
1373+
onClose={() => {}}
1374+
/>,
1375+
100,
1376+
5,
1377+
);
1378+
1379+
const lines = frame.split("\n");
1380+
expect(lines[1]).toContain("prism (arbiter)");
1381+
});
1382+
1383+
test("AgentCard shows author in title when set", async () => {
1384+
const theme = resolveTheme("midnight", null);
1385+
const frame = await captureFrame(
1386+
<AgentCard
1387+
locationLabel="alpha.ts +2"
1388+
rationale="Why alpha.ts changed"
1389+
summary="Annotation for alpha.ts"
1390+
author="sonnet"
1391+
theme={theme}
1392+
width={34}
1393+
onClose={() => {}}
1394+
/>,
1395+
40,
1396+
12,
1397+
);
1398+
1399+
const lines = frame
1400+
.split("\n")
1401+
.slice(0, 8)
1402+
.map((line) => line.trimEnd());
1403+
expect(lines[1]).toContain("sonnet");
1404+
expect(lines[1]).not.toContain("AI note");
1405+
});
1406+
1407+
test("AgentCard falls back to 'AI note' when author absent", async () => {
1408+
const theme = resolveTheme("midnight", null);
1409+
const frame = await captureFrame(
1410+
<AgentCard
1411+
locationLabel="alpha.ts +2"
1412+
rationale="Why alpha.ts changed"
1413+
summary="Annotation for alpha.ts"
1414+
theme={theme}
1415+
width={34}
1416+
onClose={() => {}}
1417+
/>,
1418+
40,
1419+
12,
1420+
);
1421+
1422+
const lines = frame
1423+
.split("\n")
1424+
.slice(0, 8)
1425+
.map((line) => line.trimEnd());
1426+
expect(lines[1]).toContain("AI note");
1427+
});
1428+
12881429
test("DiffPane renders all visible hunk notes across the review stream", async () => {
12891430
const bootstrap = createBootstrap();
12901431
bootstrap.changeset.files[1]!.agent = {

src/ui/lib/agentPopover.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,11 @@ export function wrapText(text: string, width: number) {
4949
return lines.length > 0 ? lines : [""];
5050
}
5151

52-
/** Build the framed agent-popover title shown in the card header. */
53-
function agentPopoverTitle(noteIndex: number, noteCount: number) {
52+
/** Title shown above an agent note — author name if present, otherwise "AI note", with optional "i/n" suffix. */
53+
export function formatAgentNoteTitle(noteIndex: number, noteCount: number, author?: string) {
54+
if (author) {
55+
return noteCount > 1 ? `${author} ${noteIndex + 1}/${noteCount}` : author;
56+
}
5457
return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note";
5558
}
5659

@@ -62,13 +65,15 @@ export function buildAgentPopoverContent({
6265
rationale,
6366
summary,
6467
width,
68+
author,
6569
}: {
6670
locationLabel: string;
6771
noteCount: number;
6872
noteIndex: number;
6973
rationale?: string;
7074
summary: string;
7175
width: number;
76+
author?: string;
7277
}) {
7378
const innerWidth = Math.max(1, width - 4);
7479
const summaryLines = wrapText(summary, innerWidth);
@@ -78,7 +83,7 @@ export function buildAgentPopoverContent({
7883
1 + summaryLines.length + (rationaleLines.length > 0 ? 1 + rationaleLines.length : 0) + 1 + 1;
7984

8085
return {
81-
title: agentPopoverTitle(noteIndex, noteCount),
86+
title: formatAgentNoteTitle(noteIndex, noteCount, author),
8287
summaryLines,
8388
rationaleLines,
8489
footer,

0 commit comments

Comments
 (0)