Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions web/src/stores/handlers/auxiliaryHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { WSMessage } from '../../api/websocket';
import { scheduleAutoClose } from '../helpers/blockHelpers';
import type { Get, Set } from './types';

// ------------------------------------------------------------------ //
Expand Down Expand Up @@ -79,15 +80,32 @@ export function handleBackgroundTasksUpdate(
get: Get,
set: Set,
): void {
if (msg.session_id === get().activeSession) {
set(s => {
// Merge: keep startedAt from existing entries, add new ones
const existing = new Map(s.backgroundTasks.map(t => [t.task_id, t]));
const updated = msg.tasks.map(t => ({
...t,
startedAt: existing.get(t.task_id)?.startedAt || Date.now(),
}));
return { backgroundTasks: updated };
});
if (msg.session_id !== get().activeSession) return;
set(s => {
// Merge: keep startedAt from existing entries, add new ones
const existing = new Map(s.backgroundTasks.map(t => [t.task_id, t]));
const updated = msg.tasks.map(t => ({
...t,
startedAt: existing.get(t.task_id)?.startedAt || Date.now(),
}));
return { backgroundTasks: updated };
});

// Background sub-agent panels are held open (running) while their detached
// work streams — there's no per-tool_use_id "done" event for them. Once no
// background task is still running, the sub-agents can no longer emit, so
// settle any still-running background panels (and let them auto-close).
if (!msg.tasks.some(t => t.status === 'running')) {
const state = get();
for (const panel of state.panels) {
if (panel.background && panel.status === 'running') {
state.updatePanelTab(panel.id, {
status: 'complete',
streaming: false,
completedAt: Date.now(),
});
if (panel.type !== 'plan') scheduleAutoClose(panel.id, get);
}
}
}
}
6 changes: 6 additions & 0 deletions web/src/stores/handlers/panelHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export function handleSubagentComplete(
// Server-side sub-agent lifecycle event — mark complete
const tab = state.panels.find(p => p.id === msg.tool_use_id);
if (tab) {
// A background sub-agent's "complete" fires immediately when the Agent tool
// returns its task id — but the sub-agent keeps streaming afterward. Keep
// the panel running; it settles when the background task actually ends
// (handleBackgroundTasksUpdate). Completing here would send its later
// tools/thoughts into the main chat instead of this panel.
if (tab.background) return;
get().updatePanelTab(msg.tool_use_id, {
status: msg.is_error ? 'error' : 'complete',
isError: msg.is_error || false,
Expand Down
183 changes: 116 additions & 67 deletions web/src/stores/handlers/streamingHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,26 @@ export function handleThinking(
): void {
const state = get();
const parentId = msg.parent_tool_use_id;
if (parentId && state.panels.some(p => p.id === parentId && p.status === 'running')) {
set(s => ({
panels: appendBlockToPanel(s.panels, parentId, { type: 'thinking', content: msg.content }),
}));
} else {
const blocks = [...state.streamingBlocks];
const last = blocks[blocks.length - 1];
if (last?.type === 'thinking') {
blocks[blocks.length - 1] = { ...last, content: last.content + msg.content };
} else {
blocks.push({ type: 'thinking', content: msg.content });
if (parentId) {
// Sub-agent output — belongs to its side-panel, never the main chat (mirrors
// the replay invariant in applyStreamEvent). Route to the panel by id
// regardless of its status: a background sub-agent's panel may already be
// marked complete by the time its thoughts stream in.
if (state.panels.some(p => p.id === parentId)) {
set(s => ({
panels: appendBlockToPanel(s.panels, parentId, { type: 'thinking', content: msg.content }),
}));
}
set({ streamingBlocks: blocks, agentStatus: { state: 'thinking' } });
return;
}
const blocks = [...state.streamingBlocks];
const last = blocks[blocks.length - 1];
if (last?.type === 'thinking') {
blocks[blocks.length - 1] = { ...last, content: last.content + msg.content };
} else {
blocks.push({ type: 'thinking', content: msg.content });
}
set({ streamingBlocks: blocks, agentStatus: { state: 'thinking' } });
}

export function handleToken(
Expand All @@ -61,20 +67,24 @@ export function handleToken(
): void {
const state = get();
const parentId = msg.parent_tool_use_id;
if (parentId && state.panels.some(p => p.id === parentId && p.status === 'running')) {
set(s => ({
panels: appendBlockToPanel(s.panels, parentId, { type: 'text', content: msg.content }),
}));
} else {
const blocks = [...state.streamingBlocks];
const last = blocks[blocks.length - 1];
if (last?.type === 'text') {
blocks[blocks.length - 1] = { ...last, content: last.content + msg.content };
} else {
blocks.push({ type: 'text', content: msg.content });
if (parentId) {
// Sub-agent output — route to its side-panel by id (any status), never the
// main chat. See handleThinking for the rationale.
if (state.panels.some(p => p.id === parentId)) {
set(s => ({
panels: appendBlockToPanel(s.panels, parentId, { type: 'text', content: msg.content }),
}));
}
set({ streamingBlocks: blocks, agentStatus: { state: 'writing' } });
return;
}
const blocks = [...state.streamingBlocks];
const last = blocks[blocks.length - 1];
if (last?.type === 'text') {
blocks[blocks.length - 1] = { ...last, content: last.content + msg.content };
} else {
blocks.push({ type: 'text', content: msg.content });
}
set({ streamingBlocks: blocks, agentStatus: { state: 'writing' } });
}

export function handleWakeup(
Expand Down Expand Up @@ -130,6 +140,10 @@ export function handleToolUse(
// Open panel tab
const subagentType = String(msg.input?.subagent_type || msg.input?.model || 'agent');
const isPlan = subagentType === 'Plan';
// Background sub-agents (run_in_background) detach: the Agent tool returns a
// task id immediately, then the sub-agent streams its work afterward. Flag
// the panel so the immediate result/complete don't close it prematurely.
const isBackground = msg.input?.run_in_background === true;
get().openPanelTab({
id: toolUseId,
type: isPlan ? 'plan' : 'subagent',
Expand All @@ -143,6 +157,7 @@ export function handleToolUse(
status: 'running',
startedAt: Date.now(),
blocks: [],
background: isBackground,
});
return;
}
Expand Down Expand Up @@ -178,44 +193,50 @@ export function handleToolUse(
return;
}

// Is this a child tool call inside a running sub-agent?
// Is this a child tool call inside a sub-agent? Route to the panel by id —
// regardless of status, and never into the main chat (mirrors replay). A
// background sub-agent's panel is already 'complete' by the time its nested
// tools stream in; the old `status === 'running'` gate sent them to the chat.
const parentId = msg.parent_tool_use_id;
if (parentId && state.panels.some(p => p.id === parentId && p.status === 'running')) {
set(s => ({
panels: appendBlockToPanel(s.panels, parentId, {
type: 'tool_call',
toolUseId: msg.tool_use_id || '',
tool: msg.tool,
input: msg.input,
status: 'running',
}),
}));
} else {
// Normal: add to main chat
const blocks = [...state.streamingBlocks];
blocks.push({
type: 'tool_call',
toolUseId: msg.tool_use_id || '',
tool: msg.tool,
input: msg.input,
status: 'running',
});
const extraUpdate: Record<string, unknown> = {};
if (msg.tool === 'TodoWrite' && Array.isArray(msg.input?.todos)) {
extraUpdate.currentTodos = msg.input.todos as TodoItem[];
if (parentId) {
if (state.panels.some(p => p.id === parentId)) {
set(s => ({
panels: appendBlockToPanel(s.panels, parentId, {
type: 'tool_call',
toolUseId: msg.tool_use_id || '',
tool: msg.tool,
input: msg.input,
status: 'running',
}),
}));
}
// Optimistically reflect Claude Code task tool calls in the panel before
// the result arrives. TaskCreate adds a placeholder row (real ID lands on
// tool_result); TaskUpdate mutates by taskId so the row reacts instantly.
if (msg.tool === 'TaskCreate') {
const input = (msg.input ?? {}) as Record<string, unknown>;
extraUpdate.currentCCTasks = applyCCTaskCreateInput(state.currentCCTasks, input, msg.tool_use_id || '');
} else if (msg.tool === 'TaskUpdate') {
const input = (msg.input ?? {}) as Record<string, unknown>;
extraUpdate.currentCCTasks = applyCCTaskUpdateInput(state.currentCCTasks, input);
}
set({ streamingBlocks: blocks, agentStatus: { state: 'tool', toolName: msg.tool }, ...extraUpdate });
return;
}

// Normal: add to main chat
const blocks = [...state.streamingBlocks];
blocks.push({
type: 'tool_call',
toolUseId: msg.tool_use_id || '',
tool: msg.tool,
input: msg.input,
status: 'running',
});
const extraUpdate: Record<string, unknown> = {};
if (msg.tool === 'TodoWrite' && Array.isArray(msg.input?.todos)) {
extraUpdate.currentTodos = msg.input.todos as TodoItem[];
}
// Optimistically reflect Claude Code task tool calls in the panel before
// the result arrives. TaskCreate adds a placeholder row (real ID lands on
// tool_result); TaskUpdate mutates by taskId so the row reacts instantly.
if (msg.tool === 'TaskCreate') {
const input = (msg.input ?? {}) as Record<string, unknown>;
extraUpdate.currentCCTasks = applyCCTaskCreateInput(state.currentCCTasks, input, msg.tool_use_id || '');
} else if (msg.tool === 'TaskUpdate') {
const input = (msg.input ?? {}) as Record<string, unknown>;
extraUpdate.currentCCTasks = applyCCTaskUpdateInput(state.currentCCTasks, input);
}
set({ streamingBlocks: blocks, agentStatus: { state: 'tool', toolName: msg.tool }, ...extraUpdate });
}

export function handleToolResult(
Expand All @@ -239,6 +260,22 @@ export function handleToolResult(
return;
}

// A background sub-agent behaves like a workflow: the Agent tool returns its
// task id immediately while the sub-agent keeps streaming. Record the result
// on the inline chat card but DO NOT complete or auto-close the panel — its
// tools/thoughts are still arriving. The panel settles via
// handleBackgroundTasksUpdate when the background task is no longer running.
const backgroundTab = state.panels.find(p => p.id === msg.tool_use_id && p.background);
if (backgroundTab) {
const blocks = state.streamingBlocks.map(b =>
b.type === 'tool_call' && b.toolUseId === msg.tool_use_id
? { ...b, result: msg.result, isError: msg.is_error, status: 'complete' as const }
: b
);
set({ streamingBlocks: blocks, agentStatus: { state: 'thinking' } });
return;
}

// Is this a sub-agent (Task) completing?
// Check if this tool_use_id matches a panel tab (= it's a Task result)
const completingTab = state.panels.find(p => p.id === msg.tool_use_id && p.status === 'running');
Expand Down Expand Up @@ -267,13 +304,20 @@ export function handleToolResult(
return;
}

// Is this a child tool result inside a sub-agent?
// Is this a child tool result inside a sub-agent? Route to the panel by id
// (any status), never into the main chat — mirrors replay and matches the
// tool_use handler above so a background sub-agent's results land in its panel.
const parentId = msg.parent_tool_use_id;
if (parentId && state.panels.some(p => p.id === parentId && p.status === 'running')) {
set(s => ({
panels: updateToolResultInPanel(s.panels, parentId, msg.tool_use_id || '', msg.result, msg.is_error),
}));
} else {
if (parentId) {
if (state.panels.some(p => p.id === parentId)) {
set(s => ({
panels: updateToolResultInPanel(s.panels, parentId, msg.tool_use_id || '', msg.result, msg.is_error),
}));
}
return;
}

{
// Normal: update main chat
const blocks = state.streamingBlocks.map(b => {
if (b.type === 'tool_call' && b.toolUseId === msg.tool_use_id) {
Expand Down Expand Up @@ -325,11 +369,15 @@ export function handleToolResult(
// ------------------------------------------------------------------ //

/** Mark any still-running panel tabs as complete & schedule auto-close. */
function finalizeRunningPanels(get: Get): void {
function finalizeRunningPanels(get: Get, includeBackground = false): void {
for (const panel of get().panels) {
// Workflows run in the background past the launching turn — they settle
// on their own terminal workflow_progress, not when this turn ends.
if (panel.type === 'workflow') continue;
// Background sub-agents likewise keep streaming after the launching turn
// ends — leave their panels running until the background task settles
// (handleBackgroundTasksUpdate). An explicit /stop settles them anyway.
if (panel.background && !includeBackground) continue;
if (panel.status === 'running') {
get().updatePanelTab(panel.id, {
status: 'complete',
Expand Down Expand Up @@ -411,7 +459,8 @@ export function handleStopped(
isStreaming: false,
agentStatus: { state: 'idle' },
}));
finalizeRunningPanels(get);
// Explicit stop ends everything, including any detached background sub-agents.
finalizeRunningPanels(get, true);
get().loadSessions();
}

Expand Down
2 changes: 2 additions & 0 deletions web/src/stores/helpers/bufferReplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function rebuildPanelTabsFromBuffer(
if (event.type === 'tool_use' && (event.tool === 'Agent' || event.tool === 'Task')) {
const subagentType = String(event.input?.subagent_type || event.input?.model || 'agent');
const toolUseId = event.tool_use_id || '';
const isBackground = event.input?.run_in_background === true;
const block = blocks.find(
b => b.type === 'tool_call' && b.toolUseId === toolUseId,
);
Expand All @@ -154,6 +155,7 @@ export function rebuildPanelTabsFromBuffer(
completedAt: isComplete ? Date.now() : undefined,
isError: block?.type === 'tool_call' ? block.isError : false,
blocks: [],
background: isBackground,
};
panels.push(tab);
panelMap.set(toolUseId, tab);
Expand Down
5 changes: 5 additions & 0 deletions web/src/types/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ export interface PanelTab {
blocks: MessageBlock[]; // live sub-agent activity (same types as main chat)
/** For type==='workflow': the live phase/agent progress tree. */
workflow?: WorkflowSnapshot;
/** True for a sub-agent spawned with run_in_background. The Agent tool returns
* immediately (a task id) while the sub-agent keeps streaming, so the panel
* must stay open + running until the background task settles — otherwise its
* later tools/thoughts spill into the main chat instead of this panel. */
background?: boolean;
}

// --- Session modified files & diff types ---
Expand Down
Loading