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,