mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(human): show subagent companion mascots (#2099)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
ff8d60c3bb
commit
f7d1f9b42d
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user