Merge pull request 'fix(#300 ui): различимые per-agent цвета глифа + аватар пользователя на переднем плане' (#319) from feat/300-avatar-colors into develop

Reviewed-on: #319
This commit was merged in pull request #319.
This commit is contained in:
2026-07-03 22:28:44 +03:00
2 changed files with 137 additions and 38 deletions
@@ -13,6 +13,16 @@ import {
type Props = React.ComponentProps<typeof AgentAvatarStack>;
// 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");
@@ -33,18 +43,21 @@ 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+%\)$/);
});
});
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 },
@@ -60,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<HTMLElement>(
'[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<HTMLElement>(
'[data-testid="agent-glyph"]',
);
const glyphB = b.container.querySelector<HTMLElement>(
'[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 },
@@ -91,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,
@@ -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,33 @@ 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 (
<Avatar size={GLYPH_SIZE} radius="xl" variant="filled" styles={glyphStyles}>
// 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 (
<Box
data-testid="agent-glyph"
style={{
width: GLYPH_SIZE,
height: GLYPH_SIZE,
borderRadius: "50%",
background: agentGlyphBackground(agent.name),
color: "var(--mantine-color-white)",
display: "flex",
alignItems: "center",
justifyContent: "center",
lineHeight: 1,
}}
>
{agent.emoji ? (
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
{agent.emoji}
</span>
</Avatar>
);
}
return (
<Avatar size={GLYPH_SIZE} radius="xl" variant="filled" styles={glyphStyles}>
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
</Avatar>
) : (
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
)}
</Box>
);
}
@@ -110,8 +135,10 @@ export interface AgentAvatarStackProps {
}
/**
* The "agent avatar stack" (#300): the AGENT glyph in front, and — for an
* internal AI chat — the HUMAN who launched it as a smaller avatar offset behind.
* The "agent avatar stack" (#300): the AGENT glyph, and — for an internal AI
* chat — the HUMAN who launched it as a smaller avatar badge on top, overhanging
* the glyph's top-right corner in FRONT (zIndex 2 > 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
@@ -185,7 +212,9 @@ export function AgentAvatarStack({
: {})}
>
{launcher && (
<Box pos="absolute" top={0} right={0} style={{ zIndex: 0 }}>
// Launcher badge sits ABOVE the agent glyph (zIndex) at the top-right so
// it is fully visible, not half-hidden behind the agent circle.
<Box pos="absolute" top={0} right={0} style={{ zIndex: 2 }}>
<CustomAvatar
size={LAUNCHER_SIZE}
avatarUrl={launcher.avatarUrl}