Skip to content

Commit cd11527

Browse files
committed
🌈 feat(notes): tint inline notes per agent author
When an annotation carries `author`, derive a stable accent color from a new per-theme `noteAccentPalette` via FNV-1a hash and tint the entire inline note card from that accent β€” top/side/bottom borders, the title row's right-edge bar, the trailing `β•΅` guide cap below a multi-row range, and the body/title backgrounds (derived in HSL with low saturation and theme-appropriate lightness so `theme.text` / `theme.muted` body copy stays legible). The cap accent is threaded through the planned `note-guide-cap` row and resolved in PierreDiffView, with first-author-wins when two notes share a row+side. Background tint falls back to `theme.noteBackground` / `theme.noteTitleBackground` when no author is set, palette resolution returns null, or accent parsing fails; unauthored notes keep `theme.noteBorder`. Contrast-ratio tests guard the dark and light theme palettes against `theme.text`. Also spread the `3-agent-review-demo` example across all five accent slots (llama, grok, phi, gemini, sonnet) so one screenshot captures the full palette.
1 parent 2864ede commit cd11527

11 files changed

Lines changed: 488 additions & 21 deletions

File tree

β€ŽCHANGELOG.mdβ€Ž

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

99
- 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.
10+
- Tinted inline agent notes per author with a stable per-theme accent β€” borders, the title-row right-edge bar, the guide cap below multi-row ranges, and a soft body/title background β€” so notes from different agents are visually distinct on the same diff.
1011

1112
### Changed
1213

β€Žexamples/3-agent-review-demo/agent-context.jsonβ€Ž

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"newRange": [1, 3],
1111
"summary": "Adds one normalization helper for whitespace, case, and dashed shortcut terms.",
1212
"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"
13+
"author": "llama"
1414
}
1515
]
1616
},
@@ -22,13 +22,13 @@
2222
"newRange": [15, 35],
2323
"summary": "Prefix and exact keyword matches now outrank weaker substring hits before the result list is sorted.",
2424
"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"
25+
"author": "grok"
2626
},
2727
{
2828
"newRange": [20, 27],
2929
"summary": "Worth checking the score floor β€” could mask edge cases.",
3030
"rationale": "The scoring thresholds (4, 3, 2, 1) look good but validate that zero-score items are properly filtered out.",
31-
"author": "prism"
31+
"author": "gemini"
3232
}
3333
]
3434
},
@@ -40,7 +40,7 @@
4040
"newRange": [1, 8],
4141
"summary": "The preview now shows only the top three ranked commands.",
4242
"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"
43+
"author": "phi"
4444
}
4545
]
4646
},

β€Žsrc/ui/components/panes/AgentCard.tsxβ€Ž

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { buildAgentPopoverContent } from "../../lib/agentPopover";
22
import { fitText, padText } from "../../lib/text";
3+
import { resolveAuthorAccent } from "../../lib/agentColor";
34
import type { AppTheme } from "../../themes";
45

56
/** Render one framed floating agent note popover. */
@@ -33,6 +34,7 @@ export function AgentCard({
3334
width,
3435
author,
3536
});
37+
const accent = resolveAuthorAccent(author, theme.noteAccentPalette) ?? theme.accent;
3638
const titleWidth = Math.max(1, popover.innerWidth - (onClose ? 4 : 0));
3739

3840
return (
@@ -41,7 +43,7 @@ export function AgentCard({
4143
width,
4244
height: popover.height,
4345
border: true,
44-
borderColor: theme.accent,
46+
borderColor: accent,
4547
backgroundColor: theme.panel,
4648
paddingLeft: 1,
4749
paddingRight: 1,

β€Žsrc/ui/components/panes/AgentInlineNote.tsxβ€Ž

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import type { AgentAnnotation, LayoutMode } from "../../../core/types";
22
import { wrapText } from "../../lib/agentPopover";
33
import { annotationRangeLabel } from "../../lib/agentAnnotations";
44
import { fitText, padText } from "../../lib/text";
5+
import {
6+
deriveAuthorBackground,
7+
deriveAuthorTitleBackground,
8+
resolveAuthorAccent,
9+
} from "../../lib/agentColor";
510
import type { AppTheme } from "../../themes";
611

712
function inlineNoteTitle(noteIndex: number, noteCount: number, author?: string) {
@@ -87,6 +92,14 @@ export function AgentInlineNote({
8792
}) {
8893
const closeText = onClose ? "[x]" : "";
8994
const titleText = `${inlineNoteTitle(noteIndex, noteCount, annotation.author)} Β· ${annotationRangeLabel(annotation)}`;
95+
const resolvedAccent = resolveAuthorAccent(annotation.author, theme.noteAccentPalette);
96+
const accent = resolvedAccent ?? theme.noteBorder;
97+
const noteBackground = resolvedAccent
98+
? (deriveAuthorBackground(resolvedAccent, theme.appearance) ?? theme.noteBackground)
99+
: theme.noteBackground;
100+
const noteTitleBackground = resolvedAccent
101+
? (deriveAuthorTitleBackground(resolvedAccent, theme.appearance) ?? theme.noteTitleBackground)
102+
: theme.noteTitleBackground;
90103
const splitWidths = splitColumnWidths(width);
91104
const canDockRight = layout === "split" && anchorSide === "new" && width >= 84;
92105
const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84;
@@ -128,7 +141,7 @@ export function AgentInlineNote({
128141
<text>{" ".repeat(boxLeft)}</text>
129142
</box>
130143
<box style={{ width: boxWidth, height: 1, backgroundColor: theme.panel }}>
131-
<text fg={theme.noteBorder} bg={theme.noteBackground}>
144+
<text fg={accent} bg={noteBackground}>
132145
{topBorder}
133146
</text>
134147
</box>
@@ -139,12 +152,12 @@ export function AgentInlineNote({
139152
<text>{" ".repeat(boxLeft)}</text>
140153
</box>
141154
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
142-
<text fg={theme.noteBorder} bg={theme.noteBackground}>
155+
<text fg={accent} bg={noteBackground}>
143156
β”‚
144157
</text>
145158
</box>
146159
<box style={{ width: titleWidth, height: 1, backgroundColor: theme.panel }}>
147-
<text fg={theme.noteTitleText} bg={theme.noteTitleBackground}>
160+
<text fg={theme.noteTitleText} bg={noteTitleBackground}>
148161
{padText(fitText(titleText, titleWidth), titleWidth)}
149162
</text>
150163
</box>
@@ -153,11 +166,11 @@ export function AgentInlineNote({
153166
onMouseUp={onClose}
154167
style={{ width: closeText.length + 1, height: 1, backgroundColor: theme.panel }}
155168
>
156-
<text fg={theme.noteTitleText} bg={theme.noteTitleBackground}>{` ${closeText}`}</text>
169+
<text fg={theme.noteTitleText} bg={noteTitleBackground}>{` ${closeText}`}</text>
157170
</box>
158171
) : null}
159172
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
160-
<text fg={theme.noteBorder} bg={theme.noteBackground}>
173+
<text fg={accent} bg={noteBackground}>
161174
β”‚
162175
</text>
163176
</box>
@@ -172,17 +185,17 @@ export function AgentInlineNote({
172185
<text>{" ".repeat(boxLeft)}</text>
173186
</box>
174187
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
175-
<text fg={theme.noteBorder} bg={theme.noteBackground}>
188+
<text fg={accent} bg={noteBackground}>
176189
β”‚
177190
</text>
178191
</box>
179192
<box style={{ width: bodyWidth, height: 1, backgroundColor: theme.panel }}>
180-
<text fg={line.kind === "summary" ? theme.text : theme.muted} bg={theme.noteBackground}>
193+
<text fg={line.kind === "summary" ? theme.text : theme.muted} bg={noteBackground}>
181194
{padText(line.text, bodyWidth)}
182195
</text>
183196
</box>
184197
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
185-
<text fg={theme.noteBorder} bg={theme.noteBackground}>
198+
<text fg={accent} bg={noteBackground}>
186199
β”‚
187200
</text>
188201
</box>
@@ -194,7 +207,7 @@ export function AgentInlineNote({
194207
<text>{" ".repeat(boxLeft)}</text>
195208
</box>
196209
<box style={{ width: boxWidth, height: 1, backgroundColor: theme.panel }}>
197-
<text fg={theme.noteBorder} bg={theme.noteBackground}>
210+
<text fg={accent} bg={noteBackground}>
198211
{bottomBorder}
199212
</text>
200213
</box>
@@ -208,17 +221,20 @@ export function AgentInlineNoteGuideCap({
208221
side,
209222
theme,
210223
width,
224+
accent,
211225
}: {
212226
side: "old" | "new";
213227
theme: AppTheme;
214228
width: number;
229+
accent?: string;
215230
}) {
231+
const borderColor = accent ?? theme.noteBorder;
216232
return (
217233
<box style={{ width: "100%", height: 1, flexDirection: "row", backgroundColor: theme.panel }}>
218234
{side === "old" ? (
219235
<>
220236
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
221-
<text fg={theme.noteBorder}>β•΅</text>
237+
<text fg={borderColor}>β•΅</text>
222238
</box>
223239
<box style={{ width: Math.max(0, width - 1), height: 1, backgroundColor: theme.panel }}>
224240
<text>{" ".repeat(Math.max(0, width - 1))}</text>
@@ -230,7 +246,7 @@ export function AgentInlineNoteGuideCap({
230246
<text>{" ".repeat(Math.max(0, width - 1))}</text>
231247
</box>
232248
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
233-
<text fg={theme.noteBorder}>β•΅</text>
249+
<text fg={borderColor}>β•΅</text>
234250
</box>
235251
</>
236252
)}

β€Žsrc/ui/components/ui-components.test.tsxβ€Ž

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,6 +1426,93 @@ describe("UI components", () => {
14261426
expect(lines[1]).toContain("AI note");
14271427
});
14281428

1429+
test("AgentInlineNote uses theme.noteBorder when author is absent", async () => {
1430+
const theme = resolveTheme("midnight", null);
1431+
const frame = await captureFrame(
1432+
<AgentInlineNote
1433+
annotation={{
1434+
newRange: [2, 4],
1435+
summary: "Summary line",
1436+
rationale: "Rationale line.",
1437+
}}
1438+
anchorSide="new"
1439+
layout="split"
1440+
theme={theme}
1441+
width={96}
1442+
onClose={() => {}}
1443+
/>,
1444+
100,
1445+
5,
1446+
);
1447+
1448+
// Verify it renders without error; the border color should be theme.noteBorder
1449+
expect(frame).toContain("AI note Β· β–Ά new 2-4");
1450+
expect(frame).toContain("Summary line");
1451+
});
1452+
1453+
test("AgentInlineNote derives border color from palette when author is set", async () => {
1454+
const theme = resolveTheme("midnight", null);
1455+
const frame = await captureFrame(
1456+
<AgentInlineNote
1457+
annotation={{
1458+
newRange: [2, 4],
1459+
summary: "Summary line",
1460+
author: "sonnet",
1461+
}}
1462+
anchorSide="new"
1463+
layout="split"
1464+
theme={theme}
1465+
width={96}
1466+
onClose={() => {}}
1467+
/>,
1468+
100,
1469+
5,
1470+
);
1471+
1472+
// Verify it renders without error with author set
1473+
expect(frame).toContain("sonnet Β· β–Ά new 2-4");
1474+
expect(frame).toContain("Summary line");
1475+
});
1476+
1477+
test("AgentInlineNote renders different border colors for different authors", async () => {
1478+
const theme = resolveTheme("midnight", null);
1479+
const frame1 = await captureFrame(
1480+
<AgentInlineNote
1481+
annotation={{
1482+
newRange: [2, 4],
1483+
summary: "Summary line",
1484+
author: "sonnet",
1485+
}}
1486+
anchorSide="new"
1487+
layout="split"
1488+
theme={theme}
1489+
width={96}
1490+
/>,
1491+
100,
1492+
5,
1493+
);
1494+
1495+
const frame2 = await captureFrame(
1496+
<AgentInlineNote
1497+
annotation={{
1498+
newRange: [2, 4],
1499+
summary: "Summary line",
1500+
author: "prism",
1501+
}}
1502+
anchorSide="new"
1503+
layout="split"
1504+
theme={theme}
1505+
width={96}
1506+
/>,
1507+
100,
1508+
5,
1509+
);
1510+
1511+
// Both frames should render successfully; the internal accent colors differ
1512+
expect(frame1).toContain("sonnet Β· β–Ά new 2-4");
1513+
expect(frame2).toContain("prism Β· β–Ά new 2-4");
1514+
});
1515+
14291516
test("DiffPane renders all visible hunk notes across the review stream", async () => {
14301517
const bootstrap = createBootstrap();
14311518
bootstrap.changeset.files[1]!.agent = {

β€Žsrc/ui/diff/PierreDiffView.tsxβ€Ž

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useMemo } from "react";
22
import type { DiffFile, LayoutMode } from "../../core/types";
33
import { AgentInlineNote, AgentInlineNoteGuideCap } from "../components/panes/AgentInlineNote";
44
import type { VisibleAgentNote } from "../lib/agentAnnotations";
5+
import { resolveAuthorAccent } from "../lib/agentColor";
56
import type { DiffSectionGeometry } from "../lib/diffSectionGeometry";
67
import { reviewRowId } from "../lib/ids";
78
import type { AppTheme } from "../themes";
@@ -170,9 +171,16 @@ export function PierreDiffView({
170171
}
171172

172173
if (plannedRow.kind === "note-guide-cap") {
174+
const capAccent =
175+
resolveAuthorAccent(plannedRow.author, theme.noteAccentPalette) ?? undefined;
173176
return (
174177
<box key={plannedRow.key} id={rowId} style={{ width: "100%", flexDirection: "column" }}>
175-
<AgentInlineNoteGuideCap side={plannedRow.side} theme={theme} width={width} />
178+
<AgentInlineNoteGuideCap
179+
side={plannedRow.side}
180+
theme={theme}
181+
width={width}
182+
accent={capAccent}
183+
/>
176184
</box>
177185
);
178186
}

β€Žsrc/ui/diff/reviewRenderPlan.test.tsβ€Ž

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,40 @@ describe("review render plan", () => {
118118
expect(cap?.kind).toBe("note-guide-cap");
119119
if (cap?.kind === "note-guide-cap") {
120120
expect(cap.side).toBe("new");
121+
expect(cap.author).toBeUndefined();
122+
}
123+
});
124+
125+
test("propagates annotation author onto the matching guide cap row", () => {
126+
const theme = resolveTheme("midnight", null);
127+
const file = createDiffFile(
128+
"alpha",
129+
"alpha.ts",
130+
"export const alpha = 1;\n",
131+
"export const alpha = 2;\nexport const beta = 3;\nexport const gamma = 4;\n",
132+
);
133+
const rows = buildSplitRows(file, null, theme);
134+
const plannedRows = buildReviewRenderPlan({
135+
fileId: file.id,
136+
rows,
137+
selectedHunkIndex: 0,
138+
showHunkHeaders: true,
139+
visibleAgentNotes: [
140+
{
141+
id: "annotation:alpha:0:0",
142+
annotation: {
143+
newRange: [2, 3],
144+
summary: "Authored note",
145+
author: "sonnet",
146+
},
147+
},
148+
],
149+
});
150+
151+
const cap = plannedRows.find((row) => row.kind === "note-guide-cap");
152+
expect(cap?.kind).toBe("note-guide-cap");
153+
if (cap?.kind === "note-guide-cap") {
154+
expect(cap.author).toBe("sonnet");
121155
}
122156
});
123157

0 commit comments

Comments
Β (0)