Compare commits

...

1 Commits

Author SHA1 Message Date
claude_code 6f7d439811 feat(#300 ui): move launcher to top-right, per-agent dark glyph color
- Launcher (human) avatar moves from the bottom-right to the TOP-RIGHT
  corner of the agent glyph.
- The emoji/sparkles glyph circle is no longer a fixed violet: its
  background is derived from a hash of the agent name (hue) and pinned to a
  fixed dark shade (hsl(h, 45%, 24%)) so distinct agents get distinct colors
  while the emoji / white sparkles icon stays readable. Agents with an
  uploaded avatar image are unaffected.

Add a unit test for agentGlyphBackground (deterministic, name-varying, dark).
client tsc clean, 11 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:28:06 +03:00
2 changed files with 55 additions and 9 deletions
@@ -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({
@@ -23,14 +23,34 @@ 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).
@@ -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 (
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
<Avatar size={GLYPH_SIZE} radius="xl" variant="filled" styles={glyphStyles}>
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
{agent.emoji}
</span>
@@ -59,7 +88,7 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
}
return (
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
<Avatar size={GLYPH_SIZE} radius="xl" variant="filled" styles={glyphStyles}>
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
</Avatar>
);
@@ -156,7 +185,7 @@ export function AgentAvatarStack({
: {})}
>
{launcher && (
<Box pos="absolute" bottom={0} right={0} style={{ zIndex: 0 }}>
<Box pos="absolute" top={0} right={0} style={{ zIndex: 0 }}>
<CustomAvatar
size={LAUNCHER_SIZE}
avatarUrl={launcher.avatarUrl}
@@ -165,8 +194,8 @@ export function AgentAvatarStack({
/>
</Box>
)}
{/* 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. */}
<Box
style={{
position: "relative",