Skip to content

Commit ee52b4f

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 d6ed06f commit ee52b4f

6 files changed

Lines changed: 171 additions & 8 deletions

File tree

CHANGELOG.md

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

99
- Added Windows x64 prebuilt artifact publishing to the release workflow.
1010
- Added Nix flake app outputs for `nix run` and a named `hunk` package output.
11+
- 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.
1112

1213
### Changed
1314

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: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ 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) {
7+
function inlineNoteTitle(noteIndex: number, noteCount: number, author?: string) {
8+
if (author) {
9+
return noteCount > 1 ? `${author} ${noteIndex + 1}/${noteCount}` : author;
10+
}
811
return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note";
912
}
1013

@@ -83,7 +86,7 @@ export function AgentInlineNote({
8386
width: number;
8487
}) {
8588
const closeText = onClose ? "[x]" : "";
86-
const titleText = `${inlineNoteTitle(noteIndex, noteCount)} · ${annotationRangeLabel(annotation)}`;
89+
const titleText = `${inlineNoteTitle(noteIndex, noteCount, annotation.author)} · ${annotationRangeLabel(annotation)}`;
8790
const splitWidths = splitColumnWidths(width);
8891
const canDockRight = layout === "split" && anchorSide === "new" && width >= 84;
8992
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: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ export function wrapText(text: string, width: number) {
5050
}
5151

5252
/** Build the framed agent-popover title shown in the card header. */
53-
function agentPopoverTitle(noteIndex: number, noteCount: number) {
53+
function agentPopoverTitle(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: agentPopoverTitle(noteIndex, noteCount, author),
8287
summaryLines,
8388
rationaleLines,
8489
footer,

0 commit comments

Comments
 (0)