Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-01.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-02.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-03.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-04.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-05.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-06.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-07.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-08.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-09.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-10.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-11.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-12.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-13.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-14.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/web-ui/src/flow_chat/assets/subagent-avatars/robot-15.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,7 @@ export const BtwSessionPanel: React.FC<BtwSessionPanelProps> = ({
{subagentIdentity ? (
<>
<SubagentAvatar
sessionId={childSessionId}
identity={subagentIdentity}
name={subagentIdentityName}
size={24}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SessionTreePopover } from './SessionTreePopover';
import { useSubagentIdentityStore } from '../../subagent-identity';
import {
resolveSubagentAvatarPresentation,
useSubagentIdentityStore,
} from '../../subagent-identity';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

Expand Down Expand Up @@ -140,7 +143,7 @@ describe('SessionTreePopover', () => {
}));
});

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;
Expand All @@ -159,11 +162,22 @@ describe('SessionTreePopover', () => {
await Promise.resolve();
});

const avatars = Array.from(document.querySelectorAll<HTMLElement>(
'.session-tree-popover__panel [data-bf-component="subagent-avatar"]',
const subagentNodes = Array.from(document.querySelectorAll<HTMLElement>(
'.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<HTMLElement>(
'[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);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ export const SessionTreePopover: React.FC<SessionTreePopoverProps> = ({
: identity
? (
<SubagentAvatar
sessionId={node.sessionId}
identity={identity}
name={identityName}
size={embedded ? 24 : 28}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.subagent-avatar {
--subagent-avatar-size: 28px;
--subagent-avatar-hue-shift: 0deg;

position: relative;
display: inline-grid;
Expand All @@ -21,6 +22,7 @@
width: 100%;
height: 100%;
object-fit: contain;
filter: hue-rotate(var(--subagent-avatar-hue-shift));
user-select: none;
}

Expand Down
23 changes: 23 additions & 0 deletions src/web-ui/src/flow_chat/subagent-identity/SubagentAvatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SubagentAvatar } from './SubagentAvatar';
import { resolveSubagentAvatarPresentation } from './avatarResolver';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

Expand Down Expand Up @@ -41,9 +42,31 @@ describe('SubagentAvatar', () => {

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(
<SubagentAvatar
sessionId="restored-child-session"
size={22}
status="completed"
/>,
);
});

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);
});
});
30 changes: 25 additions & 5 deletions src/web-ui/src/flow_chat/subagent-identity/SubagentAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,14 +19,25 @@ export interface SubagentAvatarProps {
}

export const SubagentAvatar: React.FC<SubagentAvatarProps> = ({
sessionId,
identity,
name,
size = 28,
status = 'idle',
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}`,
Expand All @@ -34,10 +50,14 @@ export const SubagentAvatar: React.FC<SubagentAvatarProps> = ({
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}
Expand Down
20 changes: 13 additions & 7 deletions src/web-ui/src/flow_chat/subagent-identity/allocator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -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);
});

Expand All @@ -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 = [
Expand All @@ -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',
Expand All @@ -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);
});
});
31 changes: 9 additions & 22 deletions src/web-ui/src/flow_chat/subagent-identity/allocator.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -144,24 +144,20 @@
const activeSessionIds = new Set(
orderedSubjects.filter(subject => subject.active).map(subject => subject.sessionId),
);
let next = { ...previous };

Check warning on line 147 in src/web-ui/src/flow_chat/subagent-identity/allocator.ts

View workflow job for this annotation

GitHub Actions / Frontend Build

'next' is never reassigned. Use 'const' instead

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}`,
Expand All @@ -179,15 +175,6 @@
};
}

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',
Expand Down
45 changes: 45 additions & 0 deletions src/web-ui/src/flow_chat/subagent-identity/avatarResolver.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading
Loading