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 39d446b1..545794e7 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.test.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import { Provider, createStore } from "jotai"; -import { AgentAvatarStack } from "./agent-avatar-stack"; +import { AgentAvatarStack, agentGlyphBackground } from "./agent-avatar-stack"; import { activeAiChatIdAtom, aiChatWindowOpenAtom, @@ -26,6 +26,23 @@ function renderStack(props: Props) { return { store, ...utils }; } +describe("agentGlyphBackground", () => { + it("is deterministic for a given agent name", () => { + expect(agentGlyphBackground("Researcher")).toBe( + agentGlyphBackground("Researcher"), + ); + }); + + it("differs by name and stays a fixed dark shade (readable emoji)", () => { + 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%\)$/); + }); +}); + describe("AgentAvatarStack", () => { it("internal chat WITH role: emoji glyph in front + human launcher behind", () => { const { container } = renderStack({ diff --git a/apps/client/src/components/ui/agent-avatar-stack.tsx b/apps/client/src/components/ui/agent-avatar-stack.tsx index 59ce252c..85eb2fe2 100644 --- a/apps/client/src/components/ui/agent-avatar-stack.tsx +++ b/apps/client/src/components/ui/agent-avatar-stack.tsx @@ -23,19 +23,39 @@ export interface LauncherInfo { avatarUrl?: string | null; } -// Same violet token as the former AiAgentBadge (which used color="violet"). -const AGENT_COLOR = "violet"; const GLYPH_SIZE = 38; const LAUNCHER_SIZE = 22; -// How far the launcher avatar sticks out past the agent's bottom-right corner, so +// 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. const LAUNCHER_OVERHANG = 8; +// Small deterministic string hash (same algorithm as custom-avatar's initials +// hash) used to pick a stable per-agent glyph color. +function hashName(input: string): number { + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + hash = (hash << 5) - hash + input.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +/** + * 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). + */ +export function agentGlyphBackground(name: string): string { + const hue = hashName(name) % 360; + return `hsl(${hue}, 45%, 24%)`; +} + /** * The front avatar. Image-source priority (#300): * 1. agent.avatarUrl -> a real avatar image (external MCP agent account). - * 2. agent.emoji -> the role emoji on a violet circle. - * 3. otherwise -> the IconSparkles glyph on a violet circle (fallback). + * 2. agent.emoji -> the role emoji on a per-agent dark circle. + * 3. otherwise -> the IconSparkles glyph on a per-agent dark circle (fallback). */ function AgentGlyph({ agent }: { agent: AgentInfo }) { if (agent.avatarUrl) { @@ -48,9 +68,18 @@ 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 ( - + {agent.emoji} @@ -59,7 +88,7 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) { } return ( - + ); @@ -156,7 +185,7 @@ export function AgentAvatarStack({ : {})} > {launcher && ( - + )} - {/* Pin the agent glyph to the top-left at its own size; the launcher then - overhangs it by LAUNCHER_OVERHANG at the bottom-right and stays visible. */} + {/* The agent glyph keeps its own size (flex-centered in the container); the + launcher overhangs it by LAUNCHER_OVERHANG at the top-right and stays visible. */}