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),