Subagent Handling Implementation - Technical Report
Table of Contents
- Architecture Overview
- Floating Card State Management
- Live Streaming Event Processing
- History Loading Event Processing
- Variable Lifetime Management
- Event Flow Diagrams
- Code References
Architecture Overview
The subagent handling system is built on a layered architecture with clear separation of concerns:
┌─────────────────────────────────────────────────────────────┐
│ ChatView Component │
│ - Orchestrates UI rendering │
│ - Connects hooks and components │
│ - Handles user interactions │
└──────────────┬──────────────────────────────────────────────┘
│
├─────────────────┬─────────────────────────────┐
│ │ │
┌──────────────▼──────────┐ ┌──▼──────────────────────────┐ │
│ useFloatingCards Hook │ │ useChatMessages Hook │ │
│ - Card state management │ │ - Event routing │ │
│ - Position/z-index │ │ - Message state │ │
│ - Minimize/maximize │ │ - History loading │ │
└──────────────┬───────────┘ └──┬─────────────────────────┘ │
│ │ │
│ ├──────────────┬─────────────┘
│ │ │
┌──────────────▼──────────┐ ┌───▼──────────┐ ┌─▼──────────────┐
│ FloatingCard Component │ │ streamEvent │ │historyEvent │
│ - Draggable UI │ │ Handlers │ │ Handlers │
│ - Minimize/maximize UI │ │ - Live SSE │ │ - Replay SSE │
└─────────────────────────┘ └─────────────┘ └───────────────┘
Key Design Principles
- Separation of Concerns: Floating card state is managed separately from message state
- Event-Driven: All updates flow through event handlers
- Lazy Loading: History is processed but cards are only created on demand
- State Preservation: Card positions and messages persist across updates
Floating Card State Management
State Structure
The floating card state is managed in useFloatingCards.js:
// State structure for each card
{
[cardId]: {
title: string, // Display title
isMinimized: boolean, // Minimized state
position: { x: number, y: number }, // Screen position
zIndex: number, // Z-index for layering
minimizeOrder: number | null, // Order when minimized
hasUnreadUpdate: boolean, // Visual indicator for updates
// Card-specific data:
todoData?: { ... }, // For todo list cards
subagentData?: { // For subagent cards
taskId: string,
description: string,
type: string,
status: 'active' | 'completed',
toolCalls: number,
currentTool: string,
messages: Array<Message>,
isHistory: boolean,
}
}
}
Key Functions
updateSubagentCard(taskId, subagentDataUpdate)
Location: useFloatingCards.js:265-343
Purpose: Creates or updates a subagent floating card
Key Behaviors:
- Card ID: Uses
subagent-${taskId}as the card ID - Position Preservation: Preserves existing position object reference to prevent position reset
- Message Merging: Merges new messages with existing messages (doesn't replace)
- Update Indicator: Sets
hasUnreadUpdateonly if card is minimized - Default Position: Centers horizontally at
windowWidth / 1.25 - 260, stacks vertically
Code Flow:
updateSubagentCard(taskId, subagentDataUpdate) {
const cardId = `subagent-${taskId}`;
if (prev[cardId]) {
// Update existing card
// Preserve position reference
// Merge subagentData
// Set hasUnreadUpdate if minimized
} else {
// Create new card
// Set default position
// Initialize subagentData
}
}
Position Management
Critical Detail: The position object reference is preserved to prevent FloatingCard from resetting position on updates:
// Line 306: Preserve exact same object reference
position: existingCard.position, // Don't create new object!
This works because FloatingCard uses useEffect to watch initialPosition prop. If the reference changes, it resets position. By preserving the reference, we prevent unwanted resets.
Live Streaming Event Processing
Event Flow
SSE Stream
│
├─► isSubagentEvent(event)?
│ │
│ ├─► YES ──► Route to subagent handlers
│ │ │
│ │ ├─► subagent_status ──► handleSubagentStatus
│ │ ├─► message_chunk ──► handleSubagentMessageChunk
│ │ ├─► tool_calls ──► handleSubagentToolCalls
│ │ └─► tool_call_result ──► handleSubagentToolCallResult
│ │
│ └─► NO ────► Route to main agent handlers
│
└─► Update floating card via updateSubagentCard()
Event Detection
Location: streamEventHandlers.js:557-559
export function isSubagentEvent(event) {
return event.agent && typeof event.agent === 'string' && event.agent.startsWith('tools:');
}
Key Distinction:
- Main Agent:
event.agent === "model:..." - Subagent:
event.agent === "tools:..."
Event Routing
Location: useChatMessages.js:807-923
The main event router in handleSendMessage callback:
// Check if subagent event
const isSubagent = isSubagentEvent(event);
// Handle subagent_status events
if (eventType === 'subagent_status') {
handleSubagentStatus({ subagentStatus, updateSubagentCard });
return; // Don't process in main chat
}
// Handle other subagent events
if (isSubagent) {
const taskId = getTaskIdFromEvent(event);
if (taskId && updateSubagentCard) {
// Route to appropriate handler
if (eventType === 'message_chunk') {
handleSubagentMessageChunk({ ... });
} else if (eventType === 'tool_calls') {
handleSubagentToolCalls({ ... });
} else if (eventType === 'tool_call_result') {
handleSubagentToolCallResult({ ... });
}
}
return; // Don't process in main chat
}
Subagent State Management (Live Streaming)
Location: useChatMessages.js:750-758
Each subagent task maintains its own state refs:
const subagentStateRefs = {}; // Populated as subagents are detected
// Structure for each task:
subagentStateRefs[taskId] = {
contentOrderCounterRef: { current: 0 },
currentReasoningIdRef: { current: null },
currentToolCallIdRef: { current: null },
messages: [], // Array of message objects
};
Lifetime: Created on first subagent event, persists for the duration of the streaming session.
Handler Details
1. handleSubagentStatus
Location: streamEventHandlers.js:485-550
Purpose: Creates/updates cards when subagent status changes
Key Behaviors:
- Validates task IDs (skips tasks without IDs)
- Updates both
active_tasksandcompleted_tasks - Preserves messages from previous updates (doesn't overwrite)
- Sets status:
'active'or'completed'
Code:
active_tasks.forEach((task) => {
if (!task || !task.id) return; // Skip invalid tasks
updateSubagentCard(taskId, {
taskId,
description: task.description || '',
type: task.type || 'general-purpose',
toolCalls: task.tool_calls || 0,
currentTool: task.current_tool || '',
status: 'active',
// Don't set messages - preserve existing
});
});
2. handleSubagentMessageChunk
Location: streamEventHandlers.js:574-782
Purpose: Processes message chunks (reasoning, text) for subagents
State Management:
- Gets or creates
subagentStateRefs[taskId] - Maintains message array in refs (not React state)
- Updates floating card after each chunk
Message Structure:
{
id: assistantMessageId,
role: 'assistant',
contentSegments: [
{ type: 'reasoning', reasoningId, order },
{ type: 'text', content, order },
],
reasoningProcesses: {
[reasoningId]: {
content: string,
isReasoning: boolean,
reasoningComplete: boolean,
order: number,
}
},
toolCallProcesses: { ... },
content: string, // Accumulated text
contentType: 'text',
}
Edge Cases Handled:
- Reasoning content arrives before "start" signal → Creates reasoning process
- Message doesn't exist → Creates new message
3. handleSubagentToolCalls
Location: streamEventHandlers.js:794-895
Purpose: Processes tool call events for subagents
Key Behaviors:
- Creates tool call segments and processes
- Updates
currentToolin floating card (shows what's running) - Sets
isInProgress: truewhen tool call starts
Status Update:
// Line 890-893: Update currentTool to show running tool
updateSubagentCard(taskId, {
messages: taskRefs.messages,
currentTool: currentToolName, // Shows "read_file", "write_file", etc.
});
4. handleSubagentToolCallResult
Location: streamEventHandlers.js:908-1094
Purpose: Processes tool call results for subagents
Key Behaviors:
- Finds message containing the tool call (by
toolCallId) - Updates tool call with result
- Clears
currentToolif no tools are in progress
Status Update Logic:
// Lines 1074-1092: Check for in-progress tools
let hasInProgressTool = false;
for (const msg of updatedMessages) {
for (const [tcId, tcProcess] of Object.entries(msg.toolCallProcesses || {})) {
if (tcProcess.isInProgress && !tcProcess.isComplete) {
hasInProgressTool = true;
currentToolName = tcProcess.toolName;
break;
}
}
}
// Clear currentTool if no tools running
updateSubagentCard(taskId, {
messages: updatedMessages,
currentTool: hasInProgressTool ? currentToolName : '',
});
Agent-to-Task Mapping
Location: useChatMessages.js:80-82, 762-783
Purpose: Maps event.agent (e.g., "tools:...") to taskId (e.g., "Task-1")
Storage:
const agentToTaskMapRef = useRef(new Map()); // Map<agentId, taskId>
const activeSubagentTasksRef = useRef(new Map()); // Map<taskId, taskInfo>
Mapping Strategy:
- Check
agentToTaskMapReffor existing mapping - If only one active task, use it (single-task fallback)
- Cache mapping for future events
Code:
const getTaskIdFromEvent = (event) => {
if (!event.agent) return null;
const agentId = event.agent;
if (agentToTaskMapRef.current.has(agentId)) {
return agentToTaskMapRef.current.get(agentId);
}
// Single-task fallback
const activeTasks = Array.from(activeSubagentTasksRef.current.keys());
if (activeTasks.length === 1) {
const taskId = activeTasks[0];
agentToTaskMapRef.current.set(agentId, taskId);
return taskId;
}
return activeTasks[0] || null;
};
History Loading Event Processing
Overview
History loading processes events in two phases:
- Collection Phase: Store subagent events separately from main agent events
- Processing Phase: Build message structures from stored events (lazy loading)
Event Flow
History Replay (replayThreadHistory)
│
├─► isSubagentHistoryEvent(event)?
│ │
│ ├─► YES ──► Store in subagentHistoryByTaskId[taskId].events
│ │ (Don't process in main chat)
│ │
│ └─► NO ────► Process in main chat (normal history flow)
│
└─► After replay completes:
│
└─► For each taskId in subagentHistoryByTaskId:
│
├─► Create temp refs structure
├─► Process stored events using subagent handlers
├─► Build final messages array
└─► Store in subagentHistoryRef.current[taskId]
(Available for lazy loading)
Event Detection
Location: historyEventHandlers.js:11-13
export function isSubagentHistoryEvent(event) {
return event.agent && typeof event.agent === 'string' && event.agent.startsWith('tools:');
}
Collection Phase
Location: useChatMessages.js:155-260
Data Structures:
// Per-history-load storage
const subagentHistoryByTaskId = new Map(); // Map<taskId, { messages, events, description, type }>
const agentToTaskMap = new Map(); // Map<agentId, taskId> (for this history load)
Event Processing:
// Handle subagent_status events - build mapping
if (eventType === 'subagent_status') {
[...activeTasks, ...completedTasks].forEach((task) => {
if (task && task.id && task.agent) {
agentToTaskMap.set(task.agent, task.id);
// Initialize storage
subagentHistoryByTaskId.set(task.id, {
messages: [],
events: [],
description: task.description || '',
type: task.type || 'general-purpose',
});
}
});
return; // Don't process in main chat
}
// Handle other subagent events - store them
if (isSubagent) {
let taskId = agentToTaskMap.get(event.agent);
// Single-task fallback (if only one task known)
if (!taskId && subagentHistoryByTaskId.size === 1) {
const [onlyTaskId] = Array.from(subagentHistoryByTaskId.keys());
taskId = onlyTaskId;
}
if (taskId) {
subagentHistoryByTaskId.get(taskId).events.push(event);
}
return; // Don't process in main chat
}
Single-Task Fallback: If only one subagent task is known, all subagent events are attributed to it. This handles cases where subagent_status events are missing from history.
Processing Phase
Location: useChatMessages.js:517-623
Purpose: Build message structures from stored events
Key Design: Uses no-op updater to prevent floating cards from being created during history load:
// History-specific no-op updater
const historyUpdateSubagentCard = () => {};
Processing Loop:
for (const [taskId, subagentHistory] of subagentHistoryByTaskId.entries()) {
// Create temporary refs (similar to live streaming)
const tempSubagentStateRefs = {
[taskId]: {
contentOrderCounterRef: { current: 0 },
currentReasoningIdRef: { current: null },
currentToolCallIdRef: { current: null },
messages: [],
},
};
// Process each stored event
for (const event of subagentHistory.events) {
if (eventType === 'message_chunk') {
handleSubagentMessageChunk({
...,
refs: { subagentStateRefs: tempSubagentStateRefs },
updateSubagentCard: historyUpdateSubagentCard, // No-op!
});
} else if (eventType === 'tool_calls') {
handleSubagentToolCalls({ ... });
} else if (eventType === 'tool_call_result') {
handleSubagentToolCallResult({ ... });
}
}
// Store final result
subagentHistoryRef.current[taskId] = {
taskId,
description: taskMetadata?.description || '',
type: taskMetadata?.type || 'general-purpose',
messages: finalMessages,
status: 'completed',
toolCalls: 0,
currentTool: '',
};
}
Result: Messages are built in memory but no floating cards are created. Cards are created lazily when user clicks "Open subagent details".
Lazy Loading
Location: ChatView.jsx:211-246
When user clicks "Open subagent details" button:
onOpenSubagentTask={(subagentInfo) => {
const { subagentId, description, type, status } = subagentInfo;
// Try to load history
const history = getSubagentHistory(subagentId);
// Use history if available, otherwise use current info
const finalDescription = history?.description || description || '';
const finalMessages = history?.messages || [];
const isHistoryCard = !!history;
// Open floating card with history data
updateSubagentCard(subagentId, {
taskId: subagentId,
description: finalDescription,
type: finalType,
status: finalStatus,
messages: finalMessages,
isHistory: isHistoryCard, // Flag for UI rendering
});
}}
UI Difference: When isHistory: true, SubagentCardContent hides:
- Task ID and type header
- Status indicator
Variable Lifetime Management
React State vs Refs
State (Re-renders on change):
floatingCards- Card state (position, minimize, etc.)messages- Main chat messages
Refs (Persist across renders, no re-render):
subagentStateRefs- Per-task message state (live streaming)agentToTaskMapRef- Agent-to-task mapping (persists across sessions)activeSubagentTasksRef- Active tasks trackingsubagentHistoryRef- Processed history (lazy loading)
Lifetime Diagram
Component Mount
│
├─► useFloatingCards()
│ └─► useState(floatingCards) ──► Persists until unmount
│
├─► useChatMessages()
│ ├─► useState(messages) ──► Persists until unmount
│ ├─► useRef(agentToTaskMapRef) ──► Persists across renders
│ ├─► useRef(activeSubagentTasksRef) ──► Persists across renders
│ └─► useRef(subagentHistoryRef) ──► Persists across renders
│
└─► Live Streaming Session
│
├─► subagentStateRefs[taskId] ──► Created on first event
│ └─► Persists for session duration
│
└─► History Loading
│
├─► subagentHistoryByTaskId ──► Local to loadConversationHistory()
│ └─► Discarded after processing
│
└─► subagentHistoryRef.current[taskId] ──► Stored after processing
└─► Persists until component unmount
Critical Ref Usage
1. subagentStateRefs (Live Streaming)
Location: useChatMessages.js:750
Why Ref?: Messages are built incrementally across multiple event handlers. Using refs avoids unnecessary re-renders and allows handlers to mutate state directly.
Structure:
subagentStateRefs[taskId] = {
contentOrderCounterRef: { current: 0 },
currentReasoningIdRef: { current: null },
currentToolCallIdRef: { current: null },
messages: [], // Array of message objects
};
Updates: Handlers mutate messages array directly, then call updateSubagentCard to trigger React state update.
2. subagentHistoryRef (History)
Location: useChatMessages.js:86, 611-619
Why Ref?: History is processed once and stored for lazy loading. No need to trigger re-renders until user opens card.
Structure:
subagentHistoryRef.current = {
[taskId]: {
taskId,
description,
type,
messages: [...], // Fully built message array
status: 'completed',
toolCalls: 0,
currentTool: '',
}
};
Access: Exposed via getSubagentHistory(taskId) function.
3. agentToTaskMapRef (Mapping)
Location: useChatMessages.js:82
Why Ref?: Mapping persists across streaming sessions and history loads. Needs to survive re-renders.
Updates:
- Live streaming: Updated in
handleSubagentStatusandgetTaskIdFromEvent - History loading: Seeded from ref, updated during replay
Event Flow Diagrams
Live Streaming Flow
User sends message
│
└─► handleSendMessage()
│
├─► Create assistant message placeholder
├─► sendChatMessageStream()
│ │
│ └─► SSE Event Stream
│ │
│ ├─► subagent_status
│ │ └─► handleSubagentStatus()
│ │ └─► updateSubagentCard()
│ │ └─► Floating card created/updated
│ │
│ ├─► message_chunk (subagent)
│ │ └─► isSubagentEvent() → YES
│ │ └─► handleSubagentMessageChunk()
│ │ ├─► Update subagentStateRefs[taskId].messages
│ │ └─► updateSubagentCard()
│ │
│ ├─► tool_calls (subagent)
│ │ └─► isSubagentEvent() → YES
│ │ └─► handleSubagentToolCalls()
│ │ ├─► Update subagentStateRefs[taskId].messages
│ │ ├─► Set currentTool
│ │ └─► updateSubagentCard()
│ │
│ └─► tool_call_result (subagent)
│ └─► isSubagentEvent() → YES
│ └─► handleSubagentToolCallResult()
│ ├─► Update subagentStateRefs[taskId].messages
│ ├─► Clear currentTool if no tools running
│ └─► updateSubagentCard()
History Loading Flow
Component mounts / threadId changes
│
└─► loadConversationHistory()
│
├─► Initialize storage
│ ├─► subagentHistoryByTaskId = new Map()
│ └─► agentToTaskMap = new Map()
│
├─► replayThreadHistory()
│ │
│ └─► For each event:
│ │
│ ├─► subagent_status
│ │ └─► Build agentToTaskMap
│ │ └─► Initialize subagentHistoryByTaskId[taskId]
│ │
│ └─► Other subagent events
│ └─► isSubagentHistoryEvent() → YES
│ └─► Store in subagentHistoryByTaskId[taskId].events
│
└─► After replay completes:
│
└─► For each taskId:
│
├─► Create tempSubagentStateRefs[taskId]
├─► Process stored events:
│ ├─► handleSubagentMessageChunk(..., historyUpdateSubagentCard)
│ ├─► handleSubagentToolCalls(..., historyUpdateSubagentCard)
│ └─► handleSubagentToolCallResult(..., historyUpdateSubagentCard)
│
└─► Store in subagentHistoryRef.current[taskId]
└─► Available for lazy loading
Lazy Loading Flow
User clicks "Open subagent details"
│
└─► onOpenSubagentTask(subagentInfo)
│
├─► getSubagentHistory(subagentId)
│ └─► Returns subagentHistoryRef.current[taskId] or null
│
├─► Merge history with current info
│ ├─► description = history?.description || current
│ ├─► messages = history?.messages || []
│ └─► isHistory = !!history
│
└─► updateSubagentCard(taskId, { ... })
└─► Floating card created/updated
└─► SubagentCardContent renders with isHistory flag
Code References
Core Files
useFloatingCards.js(363 lines)- Floating card state management
updateSubagentCard(): Lines 265-343- Position preservation: Line 306
- Default positioning: Lines 271-283
useChatMessages.js(1080 lines)- Main event routing: Lines 791-1037
- History loading: Lines 133-644
- Subagent state refs: Lines 750-758
- Agent-to-task mapping: Lines 80-82, 762-783
- History storage: Lines 86, 611-619
- Lazy loading access: Lines 1077-1078
streamEventHandlers.js(1095 lines)isSubagentEvent(): Lines 557-559handleSubagentStatus(): Lines 485-550handleSubagentMessageChunk(): Lines 574-782handleSubagentToolCalls(): Lines 794-895handleSubagentToolCallResult(): Lines 908-1094
historyEventHandlers.js(588 lines)isSubagentHistoryEvent(): Lines 11-13- History event filtering: Used in
useChatMessages.js:169-260
ChatView.jsx(305 lines)- Component orchestration: Lines 28-304
- Lazy loading handler: Lines 211-246
- Floating card rendering: Lines 258-299
SubagentCardContent.jsx(159 lines)- UI rendering: Lines 22-156
- History mode: Lines 57-67, 77-82
Key Functions Summary
| Function | Location | Purpose |
|---|---|---|
updateSubagentCard |
useFloatingCards.js:265 |
Create/update subagent floating card |
isSubagentEvent |
streamEventHandlers.js:557 |
Detect subagent events in live stream |
isSubagentHistoryEvent |
historyEventHandlers.js:11 |
Detect subagent events in history |
handleSubagentStatus |
streamEventHandlers.js:485 |
Process subagent status updates |
handleSubagentMessageChunk |
streamEventHandlers.js:574 |
Process subagent message chunks |
handleSubagentToolCalls |
streamEventHandlers.js:794 |
Process subagent tool calls |
handleSubagentToolCallResult |
streamEventHandlers.js:908 |
Process subagent tool results |
loadConversationHistory |
useChatMessages.js:133 |
Load and process history |
getSubagentHistory |
useChatMessages.js:1077 |
Retrieve stored history |
getTaskIdFromEvent |
useChatMessages.js:762 |
Map agent ID to task ID |
Summary
The subagent handling system uses a three-layer architecture:
- State Layer (
useFloatingCards): Manages card UI state - Event Layer (
useChatMessages): Routes events to handlers - Handler Layer (
streamEventHandlers,historyEventHandlers): Processes specific event types
Key Design Decisions:
- Refs for subagent state: Avoids re-renders during incremental message building
- Lazy history loading: Processes history but only creates cards on demand
- Position preservation: Preserves object references to prevent position resets
- Single-task fallback: Handles missing mappings in history
- No-op updater: Prevents card creation during history processing
Variable Lifetimes:
- React State: UI state (cards, messages) - triggers re-renders
- Refs: Internal state (subagent messages, mappings) - persists without re-renders
- Local Variables: Temporary storage (history processing) - discarded after use
This architecture ensures:
- ✅ Subagent events don't duplicate in main chat
- ✅ Floating cards persist and maintain position
- ✅ History is loaded efficiently (lazy loading)
- ✅ Status updates reflect real-time state
- ✅ Code is maintainable and well-separated