Sub-Agent Activity Visibility Design
Problem
When Claude spawns a sub-agent (Task tool), the parent CLI process goes quiet while the sub-agent runs. The UI's 180-second stall watchdog fires, calls endStream(), and the streaming indicator disappears. The user sees no activity — it appears processing has stopped.
Root cause timeline (from issue #41 diagnostics):
t=0s— Last streaming data receivedt=180s— Stall watchdog fires (reason: stream-start,ms: 180000)t=180s—endStream()called — streaming indicator stopst=184s— Sub-agent result arrives (32KB chunk) but stream already ended
The existing heartbeat (60s, claude-status type) should prevent this but diagnostic logs show zero heartbeat events during the 180s gap. Likely cause: the heartbeat sends identical "Processing..." text which may be filtered by the client's status deduplication, or the heartbeat was never started for this session.
PR #40 added real-time tool bubble display during streaming, but the stall timeout still kills the streaming state.
Solution: Three-Part Fix
Part 1: Server — Sub-Agent-Aware Heartbeat
File: claudecodeui/server/claude-cli.js
When extractToolEvents() detects a Task tool_use in the streaming content:
Track active sub-agent tool IDs in the session's
activeClaudeProcessesentry:entry.activeSubagentIds = entry.activeSubagentIds || new Set(); entry.activeSubagentIds.add(toolUseId);Modify the heartbeat to send sub-agent-specific status messages with varying text (to avoid dedup):
// In heartbeat interval handler: if (current.activeSubagentIds?.size > 0) { const elapsed = Math.round((Date.now() - current.subagentStartedAt) / 1000); const statusText = `Sub-agent processing... (${elapsed}s)`; wsHolder.send({ type: 'claude-status', sessionId: sid, data: { type: 'subagent-heartbeat', message: statusText, subagentCount: current.activeSubagentIds.size, elapsedSeconds: elapsed }, ts: Date.now() }); }When a matching
tool_resultarrives for a sub-agent tool_use ID, remove it from the active set.Also emit a one-time
claude-statuswithtype: 'subagent-started'immediately when the Task tool_use is detected, so the client gets an explicit signal.
Rationale: Varying the status text (includes elapsed seconds) guarantees the client's dedup filter passes it through. The subagent-heartbeat type lets the client distinguish sub-agent activity from regular heartbeats.
Part 2: Client — Extend Stall Timeout for Sub-Agents
File: claudecodeui/src/hooks/useProjectWebSocketV2.js
Add a ref to track sessions with active sub-agents:
const activeSubagentSessionsRef = useRef(new Set()); // sessions with running sub-agentsWhen a Task tool_use is emitted (line ~1020-1038), add the session to the active set:
if (part.name === 'Task') { activeSubagentSessionsRef.current.add(sid); }Modify
scheduleStallWatchdog()to use an extended timeout when a sub-agent is active:const SUBAGENT_STALL_TIMEOUT_MS = 600000; // 10 minutes for sub-agent runs const timeoutMs = activeSubagentSessionsRef.current.has(sid) ? SUBAGENT_STALL_TIMEOUT_MS : STREAM_STALL_TIMEOUT_MS;Clear the sub-agent tracking when:
- A tool_result matching a Task tool_use arrives
claude-completeis received for the session- The stream ends normally
When a
claude-statuswithtype: 'subagent-heartbeat'arrives, reset the stall watchdog AND ensureisStreamingstays true:// In claude-status handler: if (statusData.type === 'subagent-heartbeat') { activeSubagentSessionsRef.current.add(sid); scheduleStallWatchdog(sid, 'subagent-heartbeat'); }
Part 3: Client — Persistent Sub-Agent Status Indicator
File: claudecodeui/src/components/ChatInterface.jsx
The tool bubble already shows "Tool - Sub-Agent" with a spinner (from PR #40). Enhancements:
When a Task tool bubble is streaming (
isToolUse && toolName === 'Task' && toolResultMissing), show an elapsed time counter:{message.toolName === 'Task' && message.toolResultMissing && ( <SubAgentTimer startTime={message.timestamp} /> )}The
SubAgentTimercomponent updates every second to show "Running... 45s", "Running... 1m 23s", etc.Show a brief description extracted from the tool input (the
descriptionfield from the Task tool call):<span className="text-xs text-gray-400"> {tryParseDescription(message.toolInput)} </span>The tool bubble persists independently of the streaming state — even if
endStream()fires, the tool bubble with its "Running..." timer continues until the tool_result arrives.
Key Files
| File | Changes |
|---|---|
claudecodeui/server/claude-cli.js |
Sub-agent tracking in heartbeat, subagent-started status |
claudecodeui/src/hooks/useProjectWebSocketV2.js |
Extended stall timeout, sub-agent session tracking |
claudecodeui/src/components/ChatInterface.jsx |
Elapsed timer on Task tool bubbles |
Testing
- Trigger a Claude session that spawns a sub-agent (Task tool)
- Verify the tool bubble appears immediately with "Tool - Sub-Agent" badge
- Verify the elapsed timer counts up on the tool bubble
- Verify no stall timeout fires during the sub-agent run (check debug logs)
- Verify the streaming indicator stays active throughout
- Verify normal completion after sub-agent returns
- Test with multiple concurrent sub-agents
- Test with sub-agents that take >3 minutes (previous failure threshold)
