From 344b9723b20620cc22c3ba79aa9273144cb4c08c Mon Sep 17 00:00:00 2001 From: claude_code Date: Fri, 3 Jul 2026 21:46:31 +0300 Subject: [PATCH 1/2] fix(#300 ui): distinct per-agent glyph colors + launcher on top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-agent glyph color never showed: the circle was a Mantine `Avatar variant="filled"` whose background was overridden by Mantine's `--avatar-bg`, so every agent fell back to the theme's violet. Also raw `hue = hash % 360` put many names in the same "purple" arc. - Render the emoji/sparkles circle as a plain Box with an explicit background — the color is now guaranteed. - Pick the color from a curated palette of categorically-distinct dark hues (red/orange/green/teal/blue/violet/magenta/slate) by name hash, so different agents read as different colors, not shades of one violet. - Bring the launcher (human) badge ABOVE the agent glyph (zIndex) so it is fully visible at the top-right instead of half-hidden behind the circle. client tsc clean, tests pass (added a color-distinctness assertion). Co-Authored-By: Claude Opus 4.8 --- .../components/ui/agent-avatar-stack.test.tsx | 11 ++- .../src/components/ui/agent-avatar-stack.tsx | 86 ++++++++++++------- 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/apps/client/src/components/ui/agent-avatar-stack.test.tsx b/apps/client/src/components/ui/agent-avatar-stack.test.tsx index 545794e7..a9d5b909 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.test.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.test.tsx @@ -33,13 +33,16 @@ describe("agentGlyphBackground", () => { ); }); - it("differs by name and stays a fixed dark shade (readable emoji)", () => { + it("gives categorically different colors to different agents", () => { + // The two agents that looked identically violet in the report must differ. + expect(agentGlyphBackground("Структурный редактор")).not.toBe( + agentGlyphBackground("Фактчекер"), + ); expect(agentGlyphBackground("Researcher")).not.toBe( agentGlyphBackground("Нарратор"), ); - // Only the hue varies; saturation/lightness are pinned low so the glyph is - // always a dark circle. - expect(agentGlyphBackground("Нарратор")).toMatch(/^hsl\(\d+, 45%, 24%\)$/); + // Every color is a dark hsl circle drawn from the palette. + expect(agentGlyphBackground("Нарратор")).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/); }); }); diff --git a/apps/client/src/components/ui/agent-avatar-stack.tsx b/apps/client/src/components/ui/agent-avatar-stack.tsx index 85eb2fe2..42a3d8eb 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.tsx @@ -1,4 +1,4 @@ -import { Avatar, Box, Group, Text, Tooltip } from "@mantine/core"; +import { Box, Group, Text, Tooltip } from "@mantine/core"; import { IconSparkles } from "@tabler/icons-react"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; @@ -25,8 +25,8 @@ export interface LauncherInfo { const GLYPH_SIZE = 38; const LAUNCHER_SIZE = 22; -// How far the launcher avatar sticks out past the agent's top-right corner, so -// the "human behind" reads as behind (lower z-index) yet stays clearly visible. +// How far the launcher avatar sticks out past the agent's top-right corner — it +// sits as a small badge over that corner (above the glyph) and stays fully visible. const LAUNCHER_OVERHANG = 8; // Small deterministic string hash (same algorithm as custom-avatar's initials @@ -40,15 +40,36 @@ function hashName(input: string): number { return Math.abs(hash); } +// A palette of categorically-DISTINCT dark circle colors for emoji/sparkles agent +// glyphs. Every entry is intentionally dark (low lightness) so a bright emoji or +// the white sparkles icon stays readable on top; the hues are spread across the +// wheel (red → orange → amber → green → teal → cyan → blue → indigo → violet → +// magenta + a neutral slate) so two different agents read as DIFFERENT colors, +// not merely different shades of the same violet. +const GLYPH_COLORS = [ + "hsl(355, 60%, 34%)", // red + "hsl(18, 62%, 32%)", // vermilion + "hsl(32, 60%, 30%)", // orange + "hsl(45, 55%, 28%)", // amber + "hsl(75, 45%, 26%)", // olive-green + "hsl(140, 48%, 26%)", // green + "hsl(165, 52%, 26%)", // teal + "hsl(188, 58%, 28%)", // cyan + "hsl(205, 58%, 32%)", // sky blue + "hsl(225, 52%, 36%)", // blue + "hsl(250, 48%, 38%)", // indigo + "hsl(280, 46%, 36%)", // violet + "hsl(312, 48%, 34%)", // magenta + "hsl(210, 12%, 36%)", // slate / neutral +]; + /** - * Deterministic DARK background for an emoji/sparkles agent glyph. The hue is - * derived from the agent-name hash so distinct agents get distinct circles; - * saturation and lightness are pinned low ("shifted into darkness") so a bright - * emoji or the white sparkles icon stays legible on top (#300). + * Deterministic dark circle color for an emoji/sparkles agent glyph, picked from + * GLYPH_COLORS by a hash of the agent name so distinct agents get categorically + * distinct colors while every color stays dark enough to keep the glyph readable. */ export function agentGlyphBackground(name: string): string { - const hue = hashName(name) % 360; - return `hsl(${hue}, 45%, 24%)`; + return GLYPH_COLORS[hashName(name) % GLYPH_COLORS.length]; } /** @@ -68,29 +89,32 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) { ); } - // Emoji/sparkles glyphs sit on a per-agent dark circle (hashed from the agent - // name) so different agents are visually distinct, while the dark background - // keeps the emoji / white sparkles icon readable. - const bg = agentGlyphBackground(agent.name); - const glyphStyles = { - root: { background: bg }, - placeholder: { background: bg, color: "var(--mantine-color-white)" }, - }; - - if (agent.emoji) { - return ( - + // Emoji/sparkles glyph on a per-agent dark circle (color hashed from the agent + // name). Rendered as a plain Box, NOT a Mantine `Avatar variant="filled"`, so + // the background is guaranteed instead of being overridden by Mantine's + // `--avatar-bg` (which was falling back to the theme's violet for every agent). + return ( + + {agent.emoji ? ( {agent.emoji} - - ); - } - - return ( - - - + ) : ( + + )} + ); } @@ -185,7 +209,9 @@ export function AgentAvatarStack({ : {})} > {launcher && ( - + // Launcher badge sits ABOVE the agent glyph (zIndex) at the top-right so + // it is fully visible, not half-hidden behind the agent circle. + Date: Fri, 3 Jul 2026 22:15:42 +0300 Subject: [PATCH 2/2] test(#319): pin per-agent glyph color reaches the DOM + fix stale z-order docs (F1/F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1: the bug was the color never reaching the DOM (Mantine Avatar's --avatar-bg overrode it); the pure agentGlyphBackground always returned distinct colors, so the existing unit tests would pass even against the broken Avatar. Add a data-testid on the glyph Box and two render tests: one asserts the emoji glyph's applied inline background equals agentGlyphBackground(name); one asserts two palette-distinct agents reach the DOM as different backgrounds. React applies styles via the CSSOM (hsl→rgb), so the assertion normalizes both sides through the same path and compares against the real function output (no frozen literal). Fails against the pre-fix Avatar (no inline background / no glyph testid). F2: the top-level AgentAvatarStack JSDoc and two test titles still described the old z-order (agent glyph in front, human behind); the PR flipped it (human launcher badge in front, zIndex 2 > glyph 1). Updated the JSDoc + both titles to match. vitest: 10 passed (+2). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ui/agent-avatar-stack.test.tsx | 71 ++++++++++++++++++- .../src/components/ui/agent-avatar-stack.tsx | 7 +- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/apps/client/src/components/ui/agent-avatar-stack.test.tsx b/apps/client/src/components/ui/agent-avatar-stack.test.tsx index a9d5b909..bbcca423 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.test.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.test.tsx @@ -13,6 +13,16 @@ import { type Props = React.ComponentProps; +// The DOM normalizes an inline `background: hsl(...)` to `rgb(...)`. Push the +// expected color through the same CSSOM path so the comparison stays exact and +// non-vacuous (an empty string — i.e. no inline background, as in the pre-fix +// Avatar approach — can never match a real color). +function normalizeColor(value: string): string { + const probe = document.createElement("div"); + probe.style.background = value; + return probe.style.background; +} + function renderStack(props: Props) { const store = createStore(); store.set(aiChatDraftAtom, "leftover draft from another chat"); @@ -47,7 +57,7 @@ describe("agentGlyphBackground", () => { }); describe("AgentAvatarStack", () => { - it("internal chat WITH role: emoji glyph in front + human launcher behind", () => { + it("internal chat WITH role: emoji glyph + human launcher badge in front", () => { const { container } = renderStack({ agent: { name: "Researcher", emoji: "🔬", avatarUrl: null }, launcher: { name: "Alice", avatarUrl: null }, @@ -63,6 +73,63 @@ describe("AgentAvatarStack", () => { expect(screen.getByText("Alice")).toBeDefined(); }); + it("emoji glyph applies its per-agent color as an inline DOM background", () => { + // Pins the actual fix: the hashed color must reach the DOM as an inline + // `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no + // inline background (Mantine's --avatar-bg overrode it), so this fails there. + const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null }; + const { container } = renderStack({ + agent, + launcher: { name: "Alice", avatarUrl: null }, + aiChatId: "chat-1", + }); + + const glyph = container.querySelector( + '[data-testid="agent-glyph"]', + ); + expect(glyph).not.toBeNull(); + // Non-vacuous: compare against the function output (normalized the same way), + // not a frozen literal. Empty against the pre-fix Avatar (no inline bg). + expect(glyph!.style.background).not.toBe(""); + expect(glyph!.style.background).toBe( + normalizeColor(agentGlyphBackground(agent.name)), + ); + }); + + it("agents with distinct hashed colors reach the DOM as distinct backgrounds", () => { + // "Researcher" and "Нарратор" hash to different palette entries, so their + // applied DOM backgrounds must differ — pins "distinct colors reach the DOM". + expect(agentGlyphBackground("Researcher")).not.toBe( + agentGlyphBackground("Нарратор"), + ); + + const a = renderStack({ + agent: { name: "Researcher", emoji: "🔬", avatarUrl: null }, + launcher: null, + aiChatId: null, + }); + const b = renderStack({ + agent: { name: "Нарратор", emoji: "📖", avatarUrl: null }, + launcher: null, + aiChatId: null, + }); + + const glyphA = a.container.querySelector( + '[data-testid="agent-glyph"]', + ); + const glyphB = b.container.querySelector( + '[data-testid="agent-glyph"]', + ); + expect(glyphA!.style.background).toBe( + normalizeColor(agentGlyphBackground("Researcher")), + ); + expect(glyphB!.style.background).toBe( + normalizeColor(agentGlyphBackground("Нарратор")), + ); + // Different colors reach the DOM (the normalized rgb values also differ). + expect(glyphA!.style.background).not.toBe(glyphB!.style.background); + }); + it("showName=false: renders only the avatars, no inline name label", () => { renderStack({ agent: { name: "Researcher", emoji: "🔬", avatarUrl: null }, @@ -94,7 +161,7 @@ describe("AgentAvatarStack", () => { expect(screen.getByText("Bob")).toBeDefined(); }); - it("external MCP: agent avatar in front, NO launcher behind", () => { + it("external MCP: agent avatar only, NO human launcher badge", () => { const { container } = renderStack({ agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" }, launcher: null, diff --git a/apps/client/src/components/ui/agent-avatar-stack.tsx b/apps/client/src/components/ui/agent-avatar-stack.tsx index 42a3d8eb..f2b7fbab 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.tsx @@ -95,6 +95,7 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) { // `--avatar-bg` (which was falling back to the theme's violet for every agent). return ( the glyph's zIndex 1) so the + * launcher stays fully visible rather than being half-hidden behind the glyph. * Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the * whole stack is a deep-link into that chat (the click the old badge owned moved * here); the click is contained (stopPropagation) so it does not also trigger an -- 2.52.0