feat(human): show subagent companion mascots (#2099)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Zavian Wang
2026-05-19 19:43:32 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent ff8d60c3bb
commit f7d1f9b42d
8 changed files with 415 additions and 8 deletions
+48 -8
View File
@@ -11,6 +11,9 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import chatRuntimeReducer, { setToolTimelineForThread } from '../../store/chatRuntimeSlice';
import mascotReducer from '../../store/mascotSlice';
import threadReducer, { setSelectedThread } from '../../store/threadSlice';
// ── Static import (after mocks are hoisted) ──────────────────────────────
import HumanPage from './HumanPage';
@@ -20,22 +23,24 @@ vi.mock('../../pages/Conversations', () => ({
default: () => <div data-testid="conversations-stub" />,
}));
vi.mock('./Mascot', () => ({ YellowMascot: () => <div data-testid="mascot-stub" /> }));
vi.mock('./Mascot', () => ({
YellowMascot: () => <div data-testid="mascot-stub" />,
Ghosty: ({ face, bodyColor }: { face?: string; bodyColor?: string }) => (
<div data-testid="ghosty-submascot" data-face={face} data-body-color={bodyColor} />
),
}));
vi.mock('./useHumanMascot', () => ({ useHumanMascot: () => ({ face: 'idle', visemes: [] }) }));
vi.mock('../../store/hooks', () => ({ useAppSelector: () => 'yellow' }));
vi.mock('../../store/mascotSlice', () => ({ selectMascotColor: () => 'yellow' }));
const SPEAK_REPLIES_KEY = 'human.speakReplies';
function buildMinimalStore() {
return configureStore({ reducer: { _noop: (_s: null = null) => _s } });
return configureStore({
reducer: { mascot: mascotReducer, thread: threadReducer, chatRuntime: chatRuntimeReducer },
});
}
function renderHumanPage() {
const store = buildMinimalStore();
function renderHumanPage(store = buildMinimalStore()) {
return render(
<Provider store={store}>
<HumanPage />
@@ -96,4 +101,39 @@ describe('HumanPage — speak-replies localStorage persistence', () => {
expect(localStorage.getItem(SPEAK_REPLIES_KEY)).toBe('1');
expect(checkbox).toBeChecked();
});
it('renders sub-mascots for the selected thread subagent timeline', () => {
const store = buildMinimalStore();
store.dispatch(setSelectedThread('thread-subagents'));
store.dispatch(
setToolTimelineForThread({
threadId: 'thread-subagents',
entries: [
{
id: 'thread-subagents:subagent:sub-1:researcher',
name: 'subagent:researcher',
round: 1,
status: 'running',
detail: 'Research the latest docs and report back.',
subagent: {
taskId: 'sub-1',
agentId: 'researcher',
childIteration: 1,
childMaxIterations: 3,
toolCalls: [],
},
},
],
})
);
renderHumanPage(store);
expect(screen.getByTestId('sub-mascot-layer')).toBeInTheDocument();
expect(
screen.getByRole('status', { name: /researcher subagent running/i })
).toBeInTheDocument();
expect(screen.getByText('Researcher')).toBeInTheDocument();
expect(screen.getByText('Iteration 1/3')).toBeInTheDocument();
});
});
+13
View File
@@ -2,13 +2,19 @@ import { useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import Conversations from '../../pages/Conversations';
import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice';
import { useAppSelector } from '../../store/hooks';
import { selectMascotColor } from '../../store/mascotSlice';
import { YellowMascot } from './Mascot';
import { SubMascotLayer } from './SubMascotLayer';
import { useHumanMascot } from './useHumanMascot';
const SPEAK_REPLIES_KEY = 'human.speakReplies';
// Stable empty reference so useAppSelector's === equality doesn't force a re-render
// of SubMascotLayer on every store update when no subagent timeline is active.
const EMPTY_TIMELINE: ToolTimelineEntry[] = [];
const HumanPage = () => {
const { t } = useT();
const [speakReplies, setSpeakReplies] = useState<boolean>(() => {
@@ -23,6 +29,12 @@ const HumanPage = () => {
// Visemes are intentionally unused — the YellowMascot has its own talking lipsync.
const { face } = useHumanMascot({ speakReplies });
const mascotColor = useAppSelector(selectMascotColor);
const subMascotTimeline = useAppSelector(state => {
const threadId = state.thread.selectedThreadId ?? state.thread.activeThreadId;
return threadId
? (state.chatRuntime.toolTimelineByThread[threadId] ?? EMPTY_TIMELINE)
: EMPTY_TIMELINE;
});
// Sidebar reserves ~436px (420px panel + 16px gutter) on the right; the
// mascot stage takes the remaining width so the two never overlap.
@@ -39,6 +51,7 @@ const HumanPage = () => {
<div className="absolute inset-y-0 left-0 right-[436px] flex items-center justify-center">
<div className="relative w-[min(80vh,90%)] aspect-square">
<YellowMascot face={face} mascotColor={mascotColor} />
<SubMascotLayer entries={subMascotTimeline} />
</div>
</div>
@@ -0,0 +1,127 @@
import { render, screen, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice';
import { SubMascotLayer, subMascotModelsFromTimeline } from './SubMascotLayer';
function subagentEntry(overrides: Partial<ToolTimelineEntry> = {}): ToolTimelineEntry {
return {
id: 'thread-1:subagent:sub-1:researcher',
name: 'subagent:researcher',
round: 1,
status: 'running',
detail: 'Research the relevant docs.',
subagent: {
taskId: 'sub-1',
agentId: 'researcher',
childIteration: 1,
childMaxIterations: 4,
toolCalls: [],
},
...overrides,
};
}
describe('subMascotModelsFromTimeline', () => {
it('builds visible models only from subagent timeline rows', () => {
const models = subMascotModelsFromTimeline([
{ id: 'thread-1:tool:search', name: 'web_search', round: 1, status: 'running' },
subagentEntry(),
]);
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
agentId: 'researcher',
label: 'Researcher',
status: 'running',
face: 'thinking',
activity: 'Iteration 1/4',
});
});
it('uses child tool calls, completion, and failure as activity bubbles', () => {
const [running, success, error] = subMascotModelsFromTimeline([
subagentEntry({
id: 'thread-1:subagent:sub-1:code_executor',
name: 'subagent:code_executor',
subagent: {
taskId: 'sub-1',
agentId: 'code_executor',
toolCalls: [{ callId: 'call-1', toolName: 'read_file', status: 'running' }],
},
}),
subagentEntry({
id: 'thread-1:subagent:sub-2:researcher',
status: 'success',
subagent: { taskId: 'sub-2', agentId: 'researcher', outputChars: 512, toolCalls: [] },
}),
subagentEntry({
id: 'thread-1:subagent:sub-3:critic',
name: 'subagent:critic',
status: 'error',
subagent: { taskId: 'sub-3', agentId: 'critic', toolCalls: [] },
}),
]);
expect(running?.activity).toBe('Using Read File');
expect(running?.face).toBe('thinking');
expect(success?.activity).toBe('Completed 512 chars');
expect(success?.face).toBe('happy');
expect(error?.activity).toBe('Needs attention');
expect(error?.face).toBe('concerned');
});
});
describe('<SubMascotLayer />', () => {
it('renders multiple colored sub-mascots with running, success, and failed states', () => {
render(
<SubMascotLayer
entries={[
subagentEntry(),
subagentEntry({
id: 'thread-1:subagent:sub-2:planner',
name: 'subagent:planner',
status: 'success',
subagent: { taskId: 'sub-2', agentId: 'planner', outputChars: 90, toolCalls: [] },
}),
subagentEntry({
id: 'thread-1:subagent:sub-3:critic',
name: 'subagent:critic',
status: 'error',
subagent: { taskId: 'sub-3', agentId: 'critic', toolCalls: [] },
}),
]}
/>
);
const mascots = screen.getAllByTestId('sub-mascot');
expect(mascots).toHaveLength(3);
expect(screen.getByRole('status', { name: /researcher subagent running/i })).toHaveAttribute(
'data-status',
'running'
);
expect(screen.getByRole('status', { name: /planner subagent success/i })).toHaveAttribute(
'data-status',
'success'
);
expect(screen.getByRole('status', { name: /critic subagent error/i })).toHaveAttribute(
'data-status',
'error'
);
const bubbles = screen.getAllByTestId('sub-mascot-bubble');
expect(within(bubbles[0]!).getByText('Researcher')).toBeInTheDocument();
expect(within(bubbles[1]!).getByText('Completed 90 chars')).toBeInTheDocument();
expect(within(bubbles[2]!).getByText('Needs attention')).toBeInTheDocument();
});
it('renders nothing when no subagent rows are present', () => {
const { container } = render(
<SubMascotLayer
entries={[{ id: 'tool-1', name: 'web_search', round: 1, status: 'running' }]}
/>
);
expect(container).toBeEmptyDOMElement();
});
});
+187
View File
@@ -0,0 +1,187 @@
import debug from 'debug';
import { type FC, useMemo } from 'react';
import type { ToolTimelineEntry, ToolTimelineEntryStatus } from '../../store/chatRuntimeSlice';
import { Ghosty, type MascotFace } from './Mascot';
const subMascotLog = debug('human:sub-mascots');
const MAX_SUB_MASCOTS = 5;
const ACTIVITY_LIMIT = 74;
const SUB_MASCOT_COLORS = [
'#4A83DD',
'#5C9B75',
'#D9854B',
'#B8657A',
'#6E7BBD',
'#4A9A9A',
] as const;
const POSITIONS = [
{ left: '72%', top: '18%' },
{ left: '24%', top: '20%' },
{ left: '80%', top: '62%' },
{ left: '18%', top: '64%' },
{ left: '50%', top: '10%' },
] as const;
export interface SubMascotModel {
id: string;
agentId: string;
label: string;
status: ToolTimelineEntryStatus;
face: MascotFace;
activity: string;
color: string;
position: (typeof POSITIONS)[number];
}
export interface SubMascotLayerProps {
entries: ToolTimelineEntry[];
}
function hashString(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
}
return hash;
}
function truncateActivity(value: string): string {
const trimmed = value.trim().replace(/\s+/g, ' ');
if (trimmed.length <= ACTIVITY_LIMIT) return trimmed;
return `${trimmed.slice(0, ACTIVITY_LIMIT - 3).trimEnd()}...`;
}
function humanizeIdentifier(value: string): string {
const cleaned = value
.replace(/^subagent:/, '')
.replace(/[_-]+/g, ' ')
.trim();
if (!cleaned) return 'Subagent';
return cleaned.replace(/\b\w/g, ch => ch.toUpperCase());
}
function faceForStatus(status: ToolTimelineEntryStatus): MascotFace {
switch (status) {
case 'success':
return 'happy';
case 'error':
return 'concerned';
case 'running':
default:
return 'thinking';
}
}
function activityForEntry(entry: ToolTimelineEntry): string {
const subagent = entry.subagent;
if (!subagent) return 'Starting';
if (entry.status === 'success') {
return subagent.outputChars ? `Completed ${subagent.outputChars} chars` : 'Completed';
}
if (entry.status === 'error') {
return 'Needs attention';
}
const lastRunningTool = [...subagent.toolCalls].reverse().find(call => call.status === 'running');
if (lastRunningTool) {
return `Using ${humanizeIdentifier(lastRunningTool.toolName)}`;
}
if (subagent.childIteration) {
return subagent.childMaxIterations
? `Iteration ${subagent.childIteration}/${subagent.childMaxIterations}`
: `Iteration ${subagent.childIteration}`;
}
if (entry.detail?.trim()) {
return truncateActivity(entry.detail);
}
return 'Starting';
}
export function subMascotModelsFromTimeline(entries: ToolTimelineEntry[]): SubMascotModel[] {
return entries
.filter(entry => entry.subagent && entry.name.startsWith('subagent:'))
.slice(-MAX_SUB_MASCOTS)
.map((entry, index) => {
const subagent = entry.subagent!;
const agentId = subagent.agentId || entry.name.replace(/^subagent:/, '') || 'subagent';
const colorIndex = hashString(`${subagent.taskId}:${agentId}`) % SUB_MASCOT_COLORS.length;
return {
id: entry.id,
agentId,
label: humanizeIdentifier(agentId),
status: entry.status,
face: faceForStatus(entry.status),
activity: activityForEntry(entry),
color: SUB_MASCOT_COLORS[colorIndex],
position: POSITIONS[index % POSITIONS.length],
};
});
}
export const SubMascotLayer: FC<SubMascotLayerProps> = ({ entries }) => {
const models = useMemo(() => subMascotModelsFromTimeline(entries), [entries]);
if (models.length === 0) return null;
subMascotLog(
'render count=%d states=%o',
models.length,
models.map(model => `${model.agentId}:${model.status}`)
);
return (
<div
className="pointer-events-none absolute inset-0 z-10"
data-testid="sub-mascot-layer"
aria-live="polite">
{models.map(model => (
<div
key={model.id}
role="status"
aria-label={`${model.label} subagent ${model.status}`}
data-testid="sub-mascot"
data-status={model.status}
className="absolute w-[clamp(78px,18%,128px)]"
style={{
left: model.position.left,
top: model.position.top,
transform: 'translate(-50%, -50%)',
}}>
<div
className={[
'relative transition-opacity duration-500',
model.status === 'running' ? 'opacity-100' : 'opacity-85',
].join(' ')}>
<div className="drop-shadow-[0_10px_20px_rgba(15,23,42,0.22)]">
<Ghosty
size="100%"
idPrefix={`sub-mascot-${model.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`}
bodyColor={model.color}
face={model.face}
/>
</div>
<div
className="absolute left-1/2 top-[78%] w-[min(168px,42vw)] -translate-x-1/2 rounded-lg border border-white/70 bg-white/90 px-2 py-1 text-center text-[11px] leading-tight text-stone-700 shadow-soft backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/90 dark:text-neutral-100"
data-testid="sub-mascot-bubble">
<div className="truncate font-medium">{model.label}</div>
<div
className="mt-0.5 overflow-hidden text-[10px] text-stone-500 dark:text-neutral-300"
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{model.activity}
</div>
</div>
</div>
</div>
))}
</div>
);
};
+1
View File
@@ -189,6 +189,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
| 4.3.1 | Tool Trigger via Chat | WD | `skill-execution-flow.spec.ts`, `skill-multi-round.spec.ts` | ✅ | |
| 4.3.2 | Permission-Based Execution | RU+WD | `src/openhuman/tools/`, `skill-execution-flow.spec.ts` | ✅ | |
| 4.3.3 | Tool Failure Handling | WD | `skill-execution-flow.spec.ts` | ✅ | |
| 4.3.4 | Subagent Mascot Visualization | VU | `app/src/features/human/SubMascotLayer.test.tsx`, `app/src/features/human/HumanPage.test.tsx` | ✅ | Renders spawned/completed/failed subagent timeline rows as colored companion mascots with activity bubbles |
---
@@ -843,6 +843,34 @@ test('SocketProvider connects when token is available', () => {
***
## Human Mascot Surface
The Human page (`app/src/features/human/HumanPage.tsx`) renders the main
`YellowMascot` beside the conversation sidebar. The mascot face still comes
from `useHumanMascot`, which subscribes to chat lifecycle events for thinking,
speaking, acknowledgement, and error states.
Sub-agent delegation is visualized by `SubMascotLayer`. It does not introduce a
new socket protocol. Instead, it reads the selected or active thread's
`chatRuntime.toolTimelineByThread` entries that `ChatRuntimeProvider` already
builds from `subagent_spawned`, `subagent_completed`, `subagent_failed`,
`subagent_iteration_start`, `subagent_tool_call`, and `subagent_tool_result`.
Lifecycle mapping:
| Runtime timeline state | Sub-mascot state |
| ---------------------- | ---------------- |
| `running` | Small colored mascot in a thinking face with a short activity bubble |
| `success` | Same mascot resolves to a happy face and completion bubble |
| `error` | Same mascot resolves to a concerned face and failure bubble |
Activity bubble text is intentionally compact: current child tool call, child
iteration, the delegation prompt excerpt, or final status. The thread timeline
remains the authoritative detailed view; sub-mascots are only the glanceable
orchestration layer around the main mascot.
***
## Pages & Routing
The application uses HashRouter with protected and public route guards.
+10
View File
@@ -159,6 +159,16 @@ const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Beta,
privacy: None,
},
Capability {
id: "conversation.subagent_mascots",
name: "Subagent Mascots",
domain: "conversation",
category: CapabilityCategory::Conversation,
description: "Show delegated sub-agents as colored companion mascots with compact activity bubbles and running, completed, or failed states.",
how_to: "Human > ask the assistant to delegate work to sub-agents",
status: CapabilityStatus::Beta,
privacy: None,
},
Capability {
id: "conversation.label_filter",
name: "Thread Label Filters",
+1
View File
@@ -102,6 +102,7 @@ fn catalog_includes_additional_user_facing_surfaces() {
"meet_agent.live_loop",
"intelligence.mcp_server",
"intelligence.tool_registry",
"conversation.subagent_mascots",
] {
assert!(
ids.contains(expected),