Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions examples/3-agent-review-demo/agent-context.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
},
Expand All @@ -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"
}
]
},
Expand All @@ -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"
}
]
},
Expand All @@ -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"
}
]
}
Expand Down
3 changes: 3 additions & 0 deletions src/ui/components/panes/AgentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function AgentCard({
summary,
theme,
width,
author,
}: {
locationLabel: string;
noteCount?: number;
Expand All @@ -21,6 +22,7 @@ export function AgentCard({
summary: string;
theme: AppTheme;
width: number;
author?: string;
}) {
const popover = buildAgentPopoverContent({
summary,
Expand All @@ -29,6 +31,7 @@ export function AgentCard({
noteIndex,
noteCount,
width,
author,
});
const titleWidth = Math.max(1, popover.innerWidth - (onClose ? 4 : 0));

Expand Down
8 changes: 2 additions & 6 deletions src/ui/components/panes/AgentInlineNote.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down
141 changes: 141 additions & 0 deletions src/ui/components/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<AgentInlineNote
annotation={{
newRange: [2, 4],
summary: "Summary line",
author: "sonnet",
}}
anchorSide="new"
layout="split"
theme={theme}
width={96}
onClose={() => {}}
/>,
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(
<AgentInlineNote
annotation={{
newRange: [2, 4],
summary: "Summary line",
}}
anchorSide="new"
layout="split"
theme={theme}
width={96}
onClose={() => {}}
/>,
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(
<AgentInlineNote
annotation={{
newRange: [2, 4],
summary: "Summary line",
author: "sonnet",
}}
anchorSide="new"
layout="split"
noteCount={2}
noteIndex={0}
theme={theme}
width={96}
onClose={() => {}}
/>,
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(
<AgentInlineNote
annotation={{
newRange: [2, 4],
summary: "Summary line",
author: "prism (arbiter)",
}}
anchorSide="new"
layout="split"
theme={theme}
width={96}
onClose={() => {}}
/>,
100,
5,
);

const lines = frame.split("\n");
expect(lines[1]).toContain("prism (arbiter)");
});
Comment thread
sdougbrown marked this conversation as resolved.

test("AgentCard shows author in title when set", async () => {
const theme = resolveTheme("midnight", null);
const frame = await captureFrame(
<AgentCard
locationLabel="alpha.ts +2"
rationale="Why alpha.ts changed"
summary="Annotation for alpha.ts"
author="sonnet"
theme={theme}
width={34}
onClose={() => {}}
/>,
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(
<AgentCard
locationLabel="alpha.ts +2"
rationale="Why alpha.ts changed"
summary="Annotation for alpha.ts"
theme={theme}
width={34}
onClose={() => {}}
/>,
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 = {
Expand Down
11 changes: 8 additions & 3 deletions src/ui/lib/agentPopover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

Expand All @@ -62,13 +65,15 @@ export function buildAgentPopoverContent({
rationale,
summary,
width,
author,
}: {
locationLabel: string;
noteCount: number;
noteIndex: number;
rationale?: string;
summary: string;
width: number;
author?: string;
}) {
const innerWidth = Math.max(1, width - 4);
const summaryLines = wrapText(summary, innerWidth);
Expand All @@ -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,
Expand Down