From 6ae1e5fe763d22ff8b7a9a52254a06ac14879f72 Mon Sep 17 00:00:00 2001 From: Doug Brown Date: Mon, 11 May 2026 15:19:28 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9B=20feat(notes):=20show=20agent=20au?= =?UTF-8?q?thor=20in=20inline=20notes=20and=20popovers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 2 + .../3-agent-review-demo/agent-context.json | 18 ++- src/ui/components/panes/AgentCard.tsx | 3 + src/ui/components/panes/AgentInlineNote.tsx | 8 +- src/ui/components/ui-components.test.tsx | 141 ++++++++++++++++++ src/ui/lib/agentPopover.ts | 11 +- 6 files changed, 170 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 712d8b44..bfc17736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file. ### Added +- 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. + ### Changed ### Fixed diff --git a/examples/3-agent-review-demo/agent-context.json b/examples/3-agent-review-demo/agent-context.json index 583bb031..725692cc 100644 --- a/examples/3-agent-review-demo/agent-context.json +++ b/examples/3-agent-review-demo/agent-context.json @@ -9,7 +9,8 @@ { "newRange": [1, 3], "summary": "Adds one normalization helper for whitespace, case, and dashed shortcut terms.", - "rationale": "This lets the search layer reason about one normalized token shape instead of repeating slightly different cleanup logic in multiple places." + "rationale": "This lets the search layer reason about one normalized token shape instead of repeating slightly different cleanup logic in multiple places.", + "author": "sonnet" } ] }, @@ -20,7 +21,14 @@ { "newRange": [15, 35], "summary": "Prefix and exact keyword matches now outrank weaker substring hits before the result list is sorted.", - "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." + "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.", + "author": "sonnet" + }, + { + "newRange": [20, 27], + "summary": "Worth checking the score floor — could mask edge cases.", + "rationale": "The scoring thresholds (4, 3, 2, 1) look good but validate that zero-score items are properly filtered out.", + "author": "prism" } ] }, @@ -31,7 +39,8 @@ { "newRange": [1, 8], "summary": "The preview now shows only the top three ranked commands.", - "rationale": "Once ranking is reliable, the preview can stay compact and let the best results carry the review without flooding the UI." + "rationale": "Once ranking is reliable, the preview can stay compact and let the best results carry the review without flooding the UI.", + "author": "prism" } ] }, @@ -42,7 +51,8 @@ { "newRange": [1, 8], "summary": "The test covers a dashed query form so the new normalization helper has a visible behavioral contract.", - "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." + "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.", + "author": "sonnet" } ] } diff --git a/src/ui/components/panes/AgentCard.tsx b/src/ui/components/panes/AgentCard.tsx index b996059d..01804ed7 100644 --- a/src/ui/components/panes/AgentCard.tsx +++ b/src/ui/components/panes/AgentCard.tsx @@ -12,6 +12,7 @@ export function AgentCard({ summary, theme, width, + author, }: { locationLabel: string; noteCount?: number; @@ -21,6 +22,7 @@ export function AgentCard({ summary: string; theme: AppTheme; width: number; + author?: string; }) { const popover = buildAgentPopoverContent({ summary, @@ -29,6 +31,7 @@ export function AgentCard({ noteIndex, noteCount, width, + author, }); const titleWidth = Math.max(1, popover.innerWidth - (onClose ? 4 : 0)); diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index a14be446..bd00281e 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -1,13 +1,9 @@ import type { AgentAnnotation, LayoutMode } from "../../../core/types"; -import { wrapText } from "../../lib/agentPopover"; +import { formatAgentNoteTitle, wrapText } from "../../lib/agentPopover"; import { annotationRangeLabel } from "../../lib/agentAnnotations"; import { fitText, padText } from "../../lib/text"; import type { AppTheme } from "../../themes"; -function inlineNoteTitle(noteIndex: number, noteCount: number) { - return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note"; -} - interface AgentInlineNoteLine { kind: "summary" | "rationale"; text: string; @@ -83,7 +79,7 @@ export function AgentInlineNote({ width: number; }) { const closeText = onClose ? "[x]" : ""; - const titleText = `${inlineNoteTitle(noteIndex, noteCount)} · ${annotationRangeLabel(annotation)}`; + const titleText = `${formatAgentNoteTitle(noteIndex, noteCount, annotation.author)} · ${annotationRangeLabel(annotation)}`; const splitWidths = splitColumnWidths(width); const canDockRight = layout === "split" && anchorSide === "new" && width >= 84; const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84; diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 668a3f15..fa16ed98 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1285,6 +1285,147 @@ describe("UI components", () => { expect(lines[4]?.trimStart().startsWith("└")).toBe(true); }); + test("AgentInlineNote shows author name in title when author is set", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 100, + 5, + ); + + const lines = frame.split("\n"); + expect(lines[1]).toContain("sonnet"); + expect(lines[1]).not.toContain("AI note"); + }); + + test("AgentInlineNote falls back to 'AI note' when author is absent", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 100, + 5, + ); + + const lines = frame.split("\n"); + expect(lines[1]).toContain("AI note"); + }); + + test("AgentInlineNote includes index when multiple notes share a hunk", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 100, + 5, + ); + + const lines = frame.split("\n"); + expect(lines[1]).toContain("sonnet"); + expect(lines[1]).toContain("1/2"); + }); + + test("AgentInlineNote preserves special characters in author", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 100, + 5, + ); + + const lines = frame.split("\n"); + expect(lines[1]).toContain("prism (arbiter)"); + }); + + test("AgentCard shows author in title when set", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 40, + 12, + ); + + const lines = frame + .split("\n") + .slice(0, 8) + .map((line) => line.trimEnd()); + expect(lines[1]).toContain("sonnet"); + expect(lines[1]).not.toContain("AI note"); + }); + + test("AgentCard falls back to 'AI note' when author absent", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 40, + 12, + ); + + const lines = frame + .split("\n") + .slice(0, 8) + .map((line) => line.trimEnd()); + expect(lines[1]).toContain("AI note"); + }); + test("DiffPane renders all visible hunk notes across the review stream", async () => { const bootstrap = createBootstrap(); bootstrap.changeset.files[1]!.agent = { diff --git a/src/ui/lib/agentPopover.ts b/src/ui/lib/agentPopover.ts index 9b7ea8c1..c25f8821 100644 --- a/src/ui/lib/agentPopover.ts +++ b/src/ui/lib/agentPopover.ts @@ -49,8 +49,11 @@ export function wrapText(text: string, width: number) { return lines.length > 0 ? lines : [""]; } -/** Build the framed agent-popover title shown in the card header. */ -function agentPopoverTitle(noteIndex: number, noteCount: number) { +/** Title shown above an agent note — author name if present, otherwise "AI note", with optional "i/n" suffix. */ +export function formatAgentNoteTitle(noteIndex: number, noteCount: number, author?: string) { + if (author) { + return noteCount > 1 ? `${author} ${noteIndex + 1}/${noteCount}` : author; + } return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note"; } @@ -62,6 +65,7 @@ export function buildAgentPopoverContent({ rationale, summary, width, + author, }: { locationLabel: string; noteCount: number; @@ -69,6 +73,7 @@ export function buildAgentPopoverContent({ rationale?: string; summary: string; width: number; + author?: string; }) { const innerWidth = Math.max(1, width - 4); const summaryLines = wrapText(summary, innerWidth); @@ -78,7 +83,7 @@ export function buildAgentPopoverContent({ 1 + summaryLines.length + (rationaleLines.length > 0 ? 1 + rationaleLines.length : 0) + 1 + 1; return { - title: agentPopoverTitle(noteIndex, noteCount), + title: formatAgentNoteTitle(noteIndex, noteCount, author), summaryLines, rationaleLines, footer,