From 3511f30978a20c7fde8a2f043cbd43bebc131c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 15:12:49 +0800 Subject: [PATCH] fix(desktop): prevent completed turns from hiding active background work The CLI can emit a completed assistant turn while local background bash or task work continues. The desktop transcript and header now keep that background activity visible without converting the composer into a global stop state. Constraint: Preserve CLI turn semantics where message_complete means the current assistant turn is done, not every background task is finished Rejected: Treat background tasks as composer-active generation | this blocks follow-up input even though the CLI keeps the session responsive Confidence: high Scope-risk: narrow Tested: cd desktop && bun run test -- --run src/components/chat/MessageList.test.tsx src/components/chat/ChatInput.test.tsx src/pages/ActiveSession.test.tsx Tested: bun run check:desktop Tested: bun run verify --- .../src/components/chat/MessageList.test.tsx | 60 ++++++++++++++++++ desktop/src/components/chat/ToolCallGroup.tsx | 62 +++++++++++++++---- desktop/src/pages/ActiveSession.test.tsx | 62 +++++++++++++++++++ desktop/src/pages/ActiveSession.tsx | 7 ++- 4 files changed, 177 insertions(+), 14 deletions(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 3db66ebd..469e1a34 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -405,6 +405,66 @@ describe('MessageList nested tool calls', () => { expect(container.textContent).toContain('Agent') }) + it('keeps mixed tool groups active while a nested child tool call is unresolved', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + chatState: 'idle', + messages: [ + { + id: 'tool-task-update', + type: 'tool_use', + toolName: 'TaskUpdate', + toolUseId: 'task-update-1', + input: { tasks: [{ id: '4', status: 'in_progress', content: 'Run page integration' }] }, + timestamp: 1, + }, + { + id: 'tool-bash', + type: 'tool_use', + toolName: 'Bash', + toolUseId: 'bash-1', + input: { command: 'bun run dev' }, + timestamp: 2, + }, + { + id: 'result-task-update', + type: 'tool_result', + toolUseId: 'task-update-1', + content: 'updated', + isError: false, + timestamp: 3, + }, + { + id: 'result-bash', + type: 'tool_result', + toolUseId: 'bash-1', + content: 'started', + isError: false, + timestamp: 4, + }, + { + id: 'tool-local-bash', + type: 'tool_use', + toolName: 'local_bash', + toolUseId: 'local-bash-1', + input: { description: 'Run page integration checks' }, + timestamp: 5, + parentToolUseId: 'task-update-1', + }, + ], + }), + }, + }) + + render() + + const groupSummary = screen.getByText('TaskUpdate (1), ran a command') + const groupButton = groupSummary.closest('button') + expect(groupButton?.textContent).not.toContain('check_circle') + expect(screen.getByText('local_bash')).toBeTruthy() + }) + it('does not render blank assistant bubbles for whitespace-only text', () => { const messages: UIMessage[] = [ { diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index d97f7bf0..ada7d953 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -63,13 +63,51 @@ function generateSummary(toolCalls: ToolCall[], t: (key: TranslationKey, params? return parts.join(', ') } -function groupHasErrors(toolCalls: ToolCall[], resultMap: Map): boolean { +function toolCallHasError( + toolCall: ToolCall, + resultMap: Map, + childToolCallsByParent: Map, +): boolean { + const result = resultMap.get(toolCall.toolUseId) + if (result?.isError) return true + + return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).some((childToolCall) => + toolCallHasError(childToolCall, resultMap, childToolCallsByParent), + ) +} + +function groupHasErrors( + toolCalls: ToolCall[], + resultMap: Map, + childToolCallsByParent: Map, +): boolean { return toolCalls.some((tc) => { - const result = resultMap.get(tc.toolUseId) - return result?.isError + return toolCallHasError(tc, resultMap, childToolCallsByParent) }) } +function isToolCallResolved( + toolCall: ToolCall, + resultMap: Map, + childToolCallsByParent: Map, +): boolean { + if (!resultMap.has(toolCall.toolUseId)) return false + + return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).every((childToolCall) => + isToolCallResolved(childToolCall, resultMap, childToolCallsByParent), + ) +} + +function hasUnresolvedToolCalls( + toolCalls: ToolCall[], + resultMap: Map, + childToolCallsByParent: Map, +): boolean { + return toolCalls.some((toolCall) => + !isToolCallResolved(toolCall, resultMap, childToolCallsByParent), + ) +} + export function ToolCallGroup({ toolCalls, resultMap, @@ -380,15 +418,16 @@ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isSt const [expanded, setExpanded] = useState(false) const t = useTranslation() const summary = generateSummary(toolCalls, t) - const errorPresent = groupHasErrors(toolCalls, resultMap) - const allComplete = toolCalls.every((tc) => resultMap.has(tc.toolUseId)) + const errorPresent = groupHasErrors(toolCalls, resultMap, childToolCallsByParent) + const hasUnresolvedTools = hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent) + const isRunning = !!isStreaming || hasUnresolvedTools const hasNestedToolCalls = toolCalls.some((tc) => (childToolCallsByParent.get(tc.toolUseId)?.length ?? 0) > 0) useEffect(() => { - if (isStreaming || hasNestedToolCalls) { + if (isRunning || hasNestedToolCalls) { setExpanded(true) } - }, [hasNestedToolCalls, isStreaming]) + }, [hasNestedToolCalls, isRunning]) return (
@@ -403,16 +442,13 @@ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isSt {summary} - {!isStreaming && allComplete && !errorPresent && ( + {!isRunning && !errorPresent && ( check_circle )} - {!isStreaming && errorPresent && ( + {!isRunning && errorPresent && ( error )} - {!isStreaming && !allComplete && !errorPresent && ( - pending - )} - {isStreaming && ( + {isRunning && ( )} diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index 446225e4..ca5428cc 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -277,6 +277,68 @@ describe('ActiveSession task polling', () => { expect(screen.getByTestId('message-list')).toBeInTheDocument() }) + it('keeps the session header active while a background task is still running after the turn completes', () => { + const sessionId = 'background-shell-running-session' + + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Background Shell Session', + createdAt: '2026-05-07T00:00:00.000Z', + modifiedAt: new Date().toISOString(), + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId, title: 'Background Shell Session', type: 'session', status: 'idle' }], + activeTabId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [{ id: 'msg-1', type: 'assistant_text', content: 'task started', timestamp: 1 }], + backgroundAgentTasks: { + 'bash-task-1': { + taskId: 'bash-task-1', + toolUseId: 'bash-tool-1', + status: 'running', + taskType: 'local_bash', + description: 'Run page integration checks', + startedAt: 1, + updatedAt: 2, + }, + }, + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + expect(screen.getByText(/session active|会话活跃中/)).toBeInTheDocument() + expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default') + }) + it('refreshes CLI tasks repeatedly while a turn is active', async () => { vi.useFakeTimers() diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index f323533c..a4a7a0fb 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -210,8 +210,11 @@ export function ActiveSession() { const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks) const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId) const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed')) + const hasRunningTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status === 'in_progress')) const chatState = sessionState?.chatState ?? 'idle' const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } + const hasRunningBackgroundTasks = Object.values(sessionState?.backgroundAgentTasks ?? {}) + .some((task) => task.status === 'running') const session = sessions.find((s) => s.id === activeTabId) const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) @@ -265,7 +268,9 @@ export function ActiveSession() { const streamingText = sessionState?.streamingText ?? '' const isEmpty = messages.length === 0 && !streamingText && (session?.messageCount ?? 0) === 0 - const isActive = chatState !== 'idle' + const isActive = chatState !== 'idle' || + (trackedTaskSessionId === activeTabId && hasRunningTasks) || + hasRunningBackgroundTasks const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens const lastUpdated = useMemo(() => {