From aefe8c497ab251eb8d21092bc0122f6be4f1dcda 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 | 1 + .../3-agent-review-demo/agent-context.json | 18 ++- src/ui/components/panes/AgentCard.tsx | 3 + src/ui/components/panes/AgentInlineNote.tsx | 4 +- src/ui/components/ui-components.test.tsx | 141 ++++++++++++++++++ src/ui/lib/agentPopover.ts | 11 +- 6 files changed, 169 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfe524df..dc200d5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable user-visible changes to Hunk are documented in this file. - Added Catppuccin Latte and Mocha as built-in themes. - Added mouse-drag text selection in diff views that copies selected rows to the system clipboard via OSC 52. A `View > Copy decorations` toggle (or `copy_decorations` config) controls whether the clipboard includes diff rails, gutters, and file headers or only the changed code. - Added inline expansion for collapsed unchanged file content. Click an unchanged-context row (`▾ N unchanged lines` when expandable, otherwise the static `··· N unchanged lines ···` form) or press `e` while a hunk is selected to reveal surrounding and trailing file lines without leaving the review. The affordance is shown only for input modes that have reachable source content (`hunk diff`, `show`, `stash show`, file-pair `diff` and `difftool`, untracked files); raw `hunk patch` input still renders as before. Failed and in-flight loads surface a one-line status ("Loading…", "Could not load N unchanged lines") on the gap row. Expanded context rows use the same syntax highlighting as the surrounding diff. +- 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 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 be3cbe68..ac08a5fd 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -2,9 +2,9 @@ import type { TextareaRenderable } from "@opentui/core"; import { flushSync } from "@opentui/react"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types"; -import { isEscapeKey, isSaveDraftNoteKey } from "../../lib/keyboard"; -import { wrapText } from "../../lib/agentPopover"; import { annotationRangeLabel, reviewNoteSource } from "../../lib/agentAnnotations"; +import { wrapText } from "../../lib/agentPopover"; +import { isEscapeKey, isSaveDraftNoteKey } from "../../lib/keyboard"; import { fitText, padText } from "../../lib/text"; import type { AppTheme } from "../../themes"; diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 38b4e725..71711518 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1634,6 +1634,147 @@ describe("UI components", () => { expect(saveLineIndex).toBeGreaterThan(5); }); + 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[0]).toContain("sonnet"); + expect(lines[0]).not.toContain("AI note"); + }); + + test("AgentInlineNote falls back to 'Agent note' when author is absent", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 100, + 5, + ); + + const lines = frame.split("\n"); + expect(lines[0]).toContain("Agent 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[0]).toContain("sonnet"); + expect(lines[0]).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[0]).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,