diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-01.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-01.webp index 99201e4871..7f0ef9ae4c 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-01.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-01.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-02.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-02.webp index 0d4cd25cce..fde7102693 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-02.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-02.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-03.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-03.webp index 621d9f0aee..9463f70548 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-03.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-03.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-04.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-04.webp index 72623db7e2..c94f794289 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-04.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-04.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-05.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-05.webp index 7bbaae5199..055237dc0d 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-05.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-05.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-06.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-06.webp index f41146594b..c0f1167852 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-06.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-06.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-07.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-07.webp index dd26484085..aa068ca19f 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-07.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-07.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-08.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-08.webp index c0749ca35a..1bc189e0ac 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-08.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-08.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-09.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-09.webp index d3b51c7f58..417113b862 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-09.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-09.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-10.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-10.webp index 3619c1a45c..7b6389c0d2 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-10.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-10.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-11.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-11.webp index 05e260d775..165c3f3665 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-11.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-11.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-12.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-12.webp index c25fd8e27c..752edaad6d 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-12.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-12.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-13.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-13.webp index 5a0f2af9a7..8b6ee007ca 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-13.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-13.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-14.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-14.webp index 07469f440e..5d1e7a4180 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-14.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-14.webp differ diff --git a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-15.webp b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-15.webp index dfebc1af76..1da1eae062 100644 Binary files a/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-15.webp and b/src/web-ui/src/flow_chat/assets/subagent-avatars/robot-15.webp differ diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index 0f13057ed5..3d936d952f 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -1022,6 +1022,7 @@ export const BtwSessionPanel: React.FC = ({ {subagentIdentity ? ( <> { })); }); - it('assigns distinct avatar and name identities to sibling subagents', async () => { + it('maps sibling avatars from session IDs while keeping names distinct', async () => { mocks.sessions.set('child-2', createSession('child-2', 'subagent', 'root')); mocks.sessions.set('child-3', createSession('child-3', 'subagent', 'root')); const t = (key: string) => key; @@ -159,11 +162,22 @@ describe('SessionTreePopover', () => { await Promise.resolve(); }); - const avatars = Array.from(document.querySelectorAll( - '.session-tree-popover__panel [data-bf-component="subagent-avatar"]', + const subagentNodes = Array.from(document.querySelectorAll( + '.session-tree-popover__panel [role="treeitem"]:not([data-session-id="root"])', )); + const avatars = subagentNodes.map((node) => { + const sessionId = node.dataset.sessionId!; + const avatar = node.querySelector( + '[data-bf-component="subagent-avatar"]', + )!; + const presentation = resolveSubagentAvatarPresentation(sessionId); + + expect(avatar.dataset.bfAvatarId).toBe(presentation.avatarId); + expect(avatar.dataset.bfAvatarColorId).toBe(presentation.colorId); + return avatar; + }); + expect(avatars).toHaveLength(3); - expect(new Set(avatars.map(avatar => avatar.dataset.bfAvatarId)).size).toBe(3); expect(new Set(avatars.map(avatar => avatar.dataset.bfNameId)).size).toBe(3); }); diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx index 2776552c0a..61f47584d7 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx @@ -477,6 +477,7 @@ export const SessionTreePopover: React.FC = ({ : identity ? ( { const avatar = container.querySelector('[data-bf-component="subagent-avatar"]'); expect(avatar?.getAttribute('data-bf-avatar-id')).toBe('robot-08'); + expect(avatar?.getAttribute('data-bf-avatar-color-id')).toBe('cyan'); expect(avatar?.getAttribute('data-bf-name-id')).toBe('name-18'); expect(avatar?.getAttribute('data-bf-state')).toBe('running'); expect(avatar?.getAttribute('style')).toContain('28px'); expect(container.querySelector('img')?.getAttribute('src')).toContain('robot-08'); }); + + it('renders a stable avatar from the session ID before a name is assigned', () => { + act(() => { + root.render( + , + ); + }); + + const avatar = container.querySelector('[data-bf-component="subagent-avatar"]'); + const presentation = resolveSubagentAvatarPresentation('restored-child-session'); + expect(avatar?.getAttribute('data-bf-avatar-id')).toBe(presentation.avatarId); + expect(avatar?.getAttribute('data-bf-avatar-color-id')).toBe(presentation.colorId); + expect(avatar?.getAttribute('style')).toContain( + `--subagent-avatar-hue-shift: ${presentation.hueShiftDegrees}deg`, + ); + expect(avatar?.hasAttribute('data-bf-name-id')).toBe(false); + }); }); diff --git a/src/web-ui/src/flow_chat/subagent-identity/SubagentAvatar.tsx b/src/web-ui/src/flow_chat/subagent-identity/SubagentAvatar.tsx index a49a6f6411..d284150c67 100644 --- a/src/web-ui/src/flow_chat/subagent-identity/SubagentAvatar.tsx +++ b/src/web-ui/src/flow_chat/subagent-identity/SubagentAvatar.tsx @@ -2,10 +2,15 @@ import React from 'react'; import type { SessionLineageLifecycle } from '../utils/sessionLineage'; import { getSubagentAvatarDefinition } from './catalog'; import type { SubagentIdentityAssignment } from './allocator'; +import { + resolveSubagentAvatarColor, + resolveSubagentAvatarPresentation, +} from './avatarResolver'; import './SubagentAvatar.scss'; export interface SubagentAvatarProps { - identity: SubagentIdentityAssignment; + sessionId?: string; + identity?: SubagentIdentityAssignment; name?: string; size?: number; status?: SessionLineageLifecycle; @@ -14,6 +19,7 @@ export interface SubagentAvatarProps { } export const SubagentAvatar: React.FC = ({ + sessionId, identity, name, size = 28, @@ -21,7 +27,17 @@ export const SubagentAvatar: React.FC = ({ decorative = true, className = '', }) => { - const avatar = getSubagentAvatarDefinition(identity.avatarId); + if (!sessionId && !identity) { + return null; + } + + const presentation = sessionId + ? resolveSubagentAvatarPresentation(sessionId) + : { + avatarId: identity!.avatarId, + ...resolveSubagentAvatarColor(''), + }; + const avatar = getSubagentAvatarDefinition(presentation.avatarId); const classes = [ 'subagent-avatar', `subagent-avatar--${status}`, @@ -34,10 +50,14 @@ export const SubagentAvatar: React.FC = ({ className={classes} data-bf-component="subagent-avatar" data-bf-part="root" - data-bf-avatar-id={identity.avatarId} - data-bf-name-id={identity.nameId} + data-bf-avatar-id={presentation.avatarId} + data-bf-avatar-color-id={presentation.colorId} + data-bf-name-id={identity?.nameId} data-bf-state={status} - style={{ '--subagent-avatar-size': `${size}px` } as React.CSSProperties} + style={{ + '--subagent-avatar-size': `${size}px`, + '--subagent-avatar-hue-shift': `${presentation.hueShiftDegrees}deg`, + } as React.CSSProperties} role={decorative ? undefined : 'img'} aria-hidden={decorative ? 'true' : undefined} aria-label={decorative ? undefined : accessibleName} diff --git a/src/web-ui/src/flow_chat/subagent-identity/allocator.test.ts b/src/web-ui/src/flow_chat/subagent-identity/allocator.test.ts index 51f50f0d27..c76148d400 100644 --- a/src/web-ui/src/flow_chat/subagent-identity/allocator.test.ts +++ b/src/web-ui/src/flow_chat/subagent-identity/allocator.test.ts @@ -4,6 +4,7 @@ import { type SubagentIdentityAssignments, type SubagentIdentitySubject, } from './allocator'; +import { resolveSubagentAvatarId } from './avatarResolver'; function subjects(count: number, active = true): SubagentIdentitySubject[] { return Array.from({ length: count }, (_, index) => ({ @@ -14,11 +15,14 @@ function subjects(count: number, active = true): SubagentIdentitySubject[] { } describe('subagent identity allocator', () => { - it('keeps the first fifteen avatars and first thirty names unique', () => { + it('maps avatars from session IDs while keeping the first thirty names unique', () => { const assignments = reconcileSubagentIdentityAssignments('root', subjects(30, false)); const ordered = subjects(30, false).map(subject => assignments[subject.sessionId]); - expect(new Set(ordered.slice(0, 15).map(identity => identity.avatarId))).toHaveLength(15); + for (const subject of subjects(30, false)) { + expect(assignments[subject.sessionId].avatarId) + .toBe(resolveSubagentAvatarId(subject.sessionId)); + } expect(new Set(ordered.map(identity => identity.nameId))).toHaveLength(30); }); @@ -30,7 +34,7 @@ describe('subagent identity allocator', () => { expect(reversed).toEqual(forward); }); - it('preserves existing identities and reuses released capacity for later active agents', () => { + it('preserves existing names and reuses released name capacity for later active agents', () => { const historical = subjects(15, false); const initial = reconcileSubagentIdentityAssignments('root', historical); const nextSubjects = [ @@ -41,11 +45,12 @@ describe('subagent identity allocator', () => { const next = reconcileSubagentIdentityAssignments('root', nextSubjects, initial); expect(next['child-01']).toEqual(initial['child-01']); - expect(next['child-16'].avatarId).not.toBe(next['child-17'].avatarId); + expect(next['child-16'].avatarId).toBe(resolveSubagentAvatarId('child-16')); + expect(next['child-17'].avatarId).toBe(resolveSubagentAvatarId('child-17')); expect(next['child-16'].nameId).not.toBe(next['child-17'].nameId); }); - it('repairs collisions between live agents while keeping the older identity', () => { + it('repairs live name collisions while replacing stored avatars with the session mapping', () => { const previous: SubagentIdentityAssignments = { older: { rootSessionId: 'root', @@ -65,8 +70,9 @@ describe('subagent identity allocator', () => { { sessionId: 'older', createdAt: 1, active: true }, ], previous); - expect(next.older).toEqual(previous.older); - expect(next.newer.avatarId).not.toBe(next.older.avatarId); + expect(next.older.nameId).toBe(previous.older.nameId); + expect(next.older.avatarId).toBe(resolveSubagentAvatarId('older')); + expect(next.newer.avatarId).toBe(resolveSubagentAvatarId('newer')); expect(next.newer.nameId).not.toBe(next.older.nameId); }); }); diff --git a/src/web-ui/src/flow_chat/subagent-identity/allocator.ts b/src/web-ui/src/flow_chat/subagent-identity/allocator.ts index 87297eb92a..a2e77716b8 100644 --- a/src/web-ui/src/flow_chat/subagent-identity/allocator.ts +++ b/src/web-ui/src/flow_chat/subagent-identity/allocator.ts @@ -1,10 +1,10 @@ import { - SUBAGENT_AVATAR_IDS, SUBAGENT_IDENTITY_CATALOG_VERSION, SUBAGENT_NAME_IDS, type SubagentAvatarId, type SubagentNameId, } from './catalog'; +import { resolveSubagentAvatarId } from './avatarResolver'; export interface SubagentIdentitySubject { sessionId: string; @@ -148,20 +148,16 @@ export function reconcileSubagentIdentityAssignments( for (const subject of orderedSubjects) { const existing = next[subject.sessionId]; - const existingIsValid = existing?.rootSessionId === rootSessionId - && SUBAGENT_AVATAR_IDS.includes(existing.avatarId) + const avatarId = resolveSubagentAvatarId(subject.sessionId); + const existingNameIsValid = existing?.rootSessionId === rootSessionId && SUBAGENT_NAME_IDS.includes(existing.nameId); - if (existingIsValid) continue; + if (existingNameIsValid) { + if (existing.avatarId !== avatarId) { + next[subject.sessionId] = { ...existing, avatarId }; + } + continue; + } - const avatarId = chooseCatalogId({ - pool: SUBAGENT_AVATAR_IDS, - seed: `${SUBAGENT_IDENTITY_CATALOG_VERSION}:${rootSessionId}:avatar:${subject.sessionId}`, - rootSessionId, - active: subject.active, - assignments: next, - activeSessionIds, - read: assignment => assignment.avatarId, - }); const nameId = chooseCatalogId({ pool: SUBAGENT_NAME_IDS, seed: `${SUBAGENT_IDENTITY_CATALOG_VERSION}:${rootSessionId}:name:${subject.sessionId}`, @@ -179,15 +175,6 @@ export function reconcileSubagentIdentityAssignments( }; } - next = repairActiveCollisions({ - pool: SUBAGENT_AVATAR_IDS, - salt: 'avatar', - rootSessionId, - subjects: orderedSubjects, - assignments: next, - read: assignment => assignment.avatarId, - write: (assignment, avatarId) => ({ ...assignment, avatarId }), - }); return repairActiveCollisions({ pool: SUBAGENT_NAME_IDS, salt: 'name', diff --git a/src/web-ui/src/flow_chat/subagent-identity/avatarResolver.test.ts b/src/web-ui/src/flow_chat/subagent-identity/avatarResolver.test.ts new file mode 100644 index 0000000000..605a298488 --- /dev/null +++ b/src/web-ui/src/flow_chat/subagent-identity/avatarResolver.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + SUBAGENT_AVATAR_COLOR_CATALOG, + SUBAGENT_AVATAR_IDS, +} from './catalog'; +import { + resolveSubagentAvatarColor, + resolveSubagentAvatarId, + resolveSubagentAvatarPresentation, +} from './avatarResolver'; + +describe('subagent avatar resolver', () => { + it('maps a session ID to the same catalog avatar without stored state', () => { + const first = resolveSubagentAvatarId('child-session'); + + expect(resolveSubagentAvatarId('child-session')).toBe(first); + expect(SUBAGENT_AVATAR_IDS).toContain(first); + }); + + it('uses the default avatar when the session ID is empty', () => { + expect(resolveSubagentAvatarId(' ')).toBe(SUBAGENT_AVATAR_IDS[0]); + }); + + it('maps color through an independently salted stable catalog', () => { + const first = resolveSubagentAvatarColor('child-session'); + + expect(first).toEqual({ colorId: 'magenta', hueShiftDegrees: 120 }); + expect(resolveSubagentAvatarColor('child-session')).toEqual(first); + expect(SUBAGENT_AVATAR_COLOR_CATALOG).toContainEqual({ + id: first.colorId, + hueShiftDegrees: first.hueShiftDegrees, + }); + expect(resolveSubagentAvatarPresentation('child-session')).toEqual({ + avatarId: resolveSubagentAvatarId('child-session'), + ...first, + }); + }); + + it('uses the unrotated cyan color when the session ID is empty', () => { + expect(resolveSubagentAvatarColor(' ')).toEqual({ + colorId: 'cyan', + hueShiftDegrees: 0, + }); + }); +}); diff --git a/src/web-ui/src/flow_chat/subagent-identity/avatarResolver.ts b/src/web-ui/src/flow_chat/subagent-identity/avatarResolver.ts new file mode 100644 index 0000000000..1fea3ea128 --- /dev/null +++ b/src/web-ui/src/flow_chat/subagent-identity/avatarResolver.ts @@ -0,0 +1,70 @@ +import { + SUBAGENT_AVATAR_COLOR_CATALOG, + SUBAGENT_AVATAR_COLOR_CATALOG_VERSION, + SUBAGENT_AVATAR_IDS, + SUBAGENT_IDENTITY_CATALOG_VERSION, + type SubagentAvatarColorId, + type SubagentAvatarId, +} from './catalog'; + +export interface SubagentAvatarColor { + colorId: SubagentAvatarColorId; + hueShiftDegrees: number; +} + +export interface SubagentAvatarPresentation extends SubagentAvatarColor { + avatarId: SubagentAvatarId; +} + +function hashString(value: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +/** + * Resolve the Web UI avatar directly from the stable subagent session ID. + * + * The mapping deliberately does not depend on lineage hydration, active state, + * allocation order, or persisted frontend state. Avatar collisions are allowed. + */ +export function resolveSubagentAvatarId(sessionId: string): SubagentAvatarId { + const normalizedSessionId = sessionId.trim(); + if (!normalizedSessionId) { + return SUBAGENT_AVATAR_IDS[0]; + } + + const hash = hashString( + `${SUBAGENT_IDENTITY_CATALOG_VERSION}:avatar:${normalizedSessionId}`, + ); + return SUBAGENT_AVATAR_IDS[hash % SUBAGENT_AVATAR_IDS.length]; +} + +/** Resolve a stable color independently from the avatar shape hash. */ +export function resolveSubagentAvatarColor(sessionId: string): SubagentAvatarColor { + const normalizedSessionId = sessionId.trim(); + const color = normalizedSessionId + ? SUBAGENT_AVATAR_COLOR_CATALOG[ + hashString( + `${SUBAGENT_AVATAR_COLOR_CATALOG_VERSION}:color:${normalizedSessionId}`, + ) % SUBAGENT_AVATAR_COLOR_CATALOG.length + ] + : SUBAGENT_AVATAR_COLOR_CATALOG[0]; + + return { + colorId: color.id, + hueShiftDegrees: color.hueShiftDegrees, + }; +} + +export function resolveSubagentAvatarPresentation( + sessionId: string, +): SubagentAvatarPresentation { + return { + avatarId: resolveSubagentAvatarId(sessionId), + ...resolveSubagentAvatarColor(sessionId), + }; +} diff --git a/src/web-ui/src/flow_chat/subagent-identity/catalog.ts b/src/web-ui/src/flow_chat/subagent-identity/catalog.ts index 92ad27cd99..cb011eea4c 100644 --- a/src/web-ui/src/flow_chat/subagent-identity/catalog.ts +++ b/src/web-ui/src/flow_chat/subagent-identity/catalog.ts @@ -15,6 +15,7 @@ import robot14 from '../assets/subagent-avatars/robot-14.webp'; import robot15 from '../assets/subagent-avatars/robot-15.webp'; export const SUBAGENT_IDENTITY_CATALOG_VERSION = 'subagent-identity-v1'; +export const SUBAGENT_AVATAR_COLOR_CATALOG_VERSION = 'subagent-avatar-color-v1'; export const SUBAGENT_AVATAR_CATALOG = [ { id: 'robot-01', src: robot01 }, @@ -34,6 +35,23 @@ export const SUBAGENT_AVATAR_CATALOG = [ { id: 'robot-15', src: robot15 }, ] as const; +// The order is part of the session-to-color mapping contract. Bump +// SUBAGENT_AVATAR_COLOR_CATALOG_VERSION before changing this catalog. +export const SUBAGENT_AVATAR_COLOR_CATALOG = [ + { id: 'cyan', hueShiftDegrees: 0 }, + { id: 'azure', hueShiftDegrees: 30 }, + { id: 'indigo', hueShiftDegrees: 60 }, + { id: 'violet', hueShiftDegrees: 90 }, + { id: 'magenta', hueShiftDegrees: 120 }, + { id: 'rose', hueShiftDegrees: 150 }, + { id: 'red', hueShiftDegrees: 180 }, + { id: 'orange', hueShiftDegrees: 210 }, + { id: 'amber', hueShiftDegrees: 240 }, + { id: 'lime', hueShiftDegrees: 270 }, + { id: 'green', hueShiftDegrees: 300 }, + { id: 'teal', hueShiftDegrees: 330 }, +] as const; + export const SUBAGENT_NAME_CATALOG = [ { id: 'name-01', labelKey: 'subagentIdentity.names.name01', fallback: 'Starbit' }, { id: 'name-02', labelKey: 'subagentIdentity.names.name02', fallback: 'Moonbud' }, @@ -68,9 +86,11 @@ export const SUBAGENT_NAME_CATALOG = [ ] as const; export type SubagentAvatarId = typeof SUBAGENT_AVATAR_CATALOG[number]['id']; +export type SubagentAvatarColorId = typeof SUBAGENT_AVATAR_COLOR_CATALOG[number]['id']; export type SubagentNameId = typeof SUBAGENT_NAME_CATALOG[number]['id']; export const SUBAGENT_AVATAR_IDS = SUBAGENT_AVATAR_CATALOG.map(item => item.id); +export const SUBAGENT_AVATAR_COLOR_IDS = SUBAGENT_AVATAR_COLOR_CATALOG.map(item => item.id); export const SUBAGENT_NAME_IDS = SUBAGENT_NAME_CATALOG.map(item => item.id); const avatarById = new Map( diff --git a/src/web-ui/src/flow_chat/subagent-identity/index.ts b/src/web-ui/src/flow_chat/subagent-identity/index.ts index c49cdea03c..92a14fc8ee 100644 --- a/src/web-ui/src/flow_chat/subagent-identity/index.ts +++ b/src/web-ui/src/flow_chat/subagent-identity/index.ts @@ -1,9 +1,18 @@ export { SubagentAvatar } from './SubagentAvatar'; +export { + resolveSubagentAvatarColor, + resolveSubagentAvatarId, + resolveSubagentAvatarPresentation, + type SubagentAvatarColor, + type SubagentAvatarPresentation, +} from './avatarResolver'; export { getSubagentAvatarDefinition, getSubagentNameDefinition, SUBAGENT_AVATAR_CATALOG, + SUBAGENT_AVATAR_COLOR_CATALOG, SUBAGENT_NAME_CATALOG, + type SubagentAvatarColorId, type SubagentAvatarId, type SubagentNameId, } from './catalog'; diff --git a/src/web-ui/src/flow_chat/tool-cards/AgentControlToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/AgentControlToolCard.test.tsx index 1fe599cf7d..327e678cd4 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AgentControlToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AgentControlToolCard.test.tsx @@ -3,12 +3,16 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat'; -import { useSubagentIdentityStore } from '../subagent-identity'; +import { + resolveSubagentAvatarPresentation, + useSubagentIdentityStore, +} from '../subagent-identity'; import { AgentControlToolCard } from './AgentControlToolCard'; const mocks = vi.hoisted(() => ({ openBtwSessionInAuxPane: vi.fn(), listeners: new Set<() => void>(), + includeChildSession: true, })); vi.mock('react-i18next', () => ({ @@ -37,8 +41,8 @@ vi.mock('../store/FlowChatStore', () => ({ mocks.listeners.add(listener); return () => mocks.listeners.delete(listener); }, - getState: () => ({ - sessions: new Map([ + getState: () => { + const sessions = new Map([ ['parent-session', { sessionId: 'parent-session', workspacePath: 'D:\\workspace\\repo', @@ -47,7 +51,9 @@ vi.mock('../store/FlowChatStore', () => ({ config: { agentType: 'Ultra' }, dialogTurns: [], }], - ['child-session', { + ]); + if (mocks.includeChildSession) { + sessions.set('child-session', { sessionId: 'child-session', sessionKind: 'subagent', parentSessionId: 'parent-session', @@ -63,9 +69,10 @@ vi.mock('../store/FlowChatStore', () => ({ status: 'processing', modelRounds: [], }], - }], - ]), - }), + }); + } + return { sessions }; + }, }, })); @@ -140,6 +147,7 @@ describeWithJsdom('AgentControlToolCard', () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); useSubagentIdentityStore.getState().clear(); + mocks.includeChildSession = true; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -207,6 +215,30 @@ describeWithJsdom('AgentControlToolCard', () => { }, ); + it('renders the session-mapped avatar before the restored child session is loaded', async () => { + mocks.includeChildSession = false; + + await act(async () => { + root.render( + , + ); + }); + + const avatar = container.querySelector('[data-bf-component="subagent-avatar"]'); + const presentation = resolveSubagentAvatarPresentation('child-session'); + expect(avatar?.getAttribute('data-bf-avatar-id')).toBe(presentation.avatarId); + expect(avatar?.getAttribute('data-bf-avatar-color-id')).toBe(presentation.colorId); + expect(avatar?.getAttribute('style')).toContain( + `--subagent-avatar-hue-shift: ${presentation.hueShiftDegrees}deg`, + ); + expect(container.querySelector('.agent-control-tool-card__fallback-avatar')).toBeNull(); + expect(container.textContent).toContain('agent-1'); + }); + it('stays collapsed and disables expansion while parameters are streaming', async () => { await act(async () => { root.render( diff --git a/src/web-ui/src/flow_chat/tool-cards/AgentControlToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/AgentControlToolCard.tsx index d1d65cf50e..28e88676fc 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AgentControlToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AgentControlToolCard.tsx @@ -205,8 +205,9 @@ export const AgentControlToolCard: React.FC = ({ data-bf-component="agent-control-tool-card" data-bf-part="avatar" > - {identity ? ( + {linkedSubagentSessionId ? ( = ({ ); const renderToolIcon = () => { - return subagentIdentity ? ( + return linkedSubagentSessionId ? (