diff --git a/app/src/features/human/HumanPage.test.tsx b/app/src/features/human/HumanPage.test.tsx
index 012300f6c..50ecd2014 100644
--- a/app/src/features/human/HumanPage.test.tsx
+++ b/app/src/features/human/HumanPage.test.tsx
@@ -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: () =>
,
}));
-vi.mock('./Mascot', () => ({ YellowMascot: () => }));
+vi.mock('./Mascot', () => ({
+ YellowMascot: () => ,
+ Ghosty: ({ face, bodyColor }: { face?: string; bodyColor?: string }) => (
+
+ ),
+}));
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(
@@ -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();
+ });
});
diff --git a/app/src/features/human/HumanPage.tsx b/app/src/features/human/HumanPage.tsx
index 46a0d26f5..8def76542 100644
--- a/app/src/features/human/HumanPage.tsx
+++ b/app/src/features/human/HumanPage.tsx
@@ -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(() => {
@@ -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 = () => {
diff --git a/app/src/features/human/SubMascotLayer.test.tsx b/app/src/features/human/SubMascotLayer.test.tsx
new file mode 100644
index 000000000..1082fbfc7
--- /dev/null
+++ b/app/src/features/human/SubMascotLayer.test.tsx
@@ -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 {
+ 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('', () => {
+ it('renders multiple colored sub-mascots with running, success, and failed states', () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/app/src/features/human/SubMascotLayer.tsx b/app/src/features/human/SubMascotLayer.tsx
new file mode 100644
index 000000000..2e188de77
--- /dev/null
+++ b/app/src/features/human/SubMascotLayer.tsx
@@ -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 = ({ 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 (
+
+ {models.map(model => (
+
+
+
+
+
+
+
{model.label}
+
+ {model.activity}
+
+
+
+
+ ))}
+
+ );
+};
diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md
index 8d3b1db9e..e861a41bf 100644
--- a/docs/TEST-COVERAGE-MATRIX.md
+++ b/docs/TEST-COVERAGE-MATRIX.md
@@ -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 |
---
diff --git a/gitbooks/developing/architecture/frontend.md b/gitbooks/developing/architecture/frontend.md
index 606ce3351..a90604f18 100644
--- a/gitbooks/developing/architecture/frontend.md
+++ b/gitbooks/developing/architecture/frontend.md
@@ -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.
diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs
index f7c1696d1..1052e1faa 100644
--- a/src/openhuman/about_app/catalog.rs
+++ b/src/openhuman/about_app/catalog.rs
@@ -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",
diff --git a/src/openhuman/about_app/catalog_tests.rs b/src/openhuman/about_app/catalog_tests.rs
index 33eb45392..26532fb82 100644
--- a/src/openhuman/about_app/catalog_tests.rs
+++ b/src/openhuman/about_app/catalog_tests.rs
@@ -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),