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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 15:12:49 +08:00
parent 8ff5353455
commit 3511f30978
4 changed files with 177 additions and 14 deletions

View File

@ -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(<MessageList />)
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[] = [
{

View File

@ -63,13 +63,51 @@ function generateSummary(toolCalls: ToolCall[], t: (key: TranslationKey, params?
return parts.join(', ')
}
function groupHasErrors(toolCalls: ToolCall[], resultMap: Map<string, ToolResult>): boolean {
function toolCallHasError(
toolCall: ToolCall,
resultMap: Map<string, ToolResult>,
childToolCallsByParent: Map<string, ToolCall[]>,
): 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<string, ToolResult>,
childToolCallsByParent: Map<string, ToolCall[]>,
): 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<string, ToolResult>,
childToolCallsByParent: Map<string, ToolCall[]>,
): 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<string, ToolResult>,
childToolCallsByParent: Map<string, ToolCall[]>,
): 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 (
<div className="mb-2">
@ -403,16 +442,13 @@ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isSt
<span className="flex-1 truncate text-[12px] text-[var(--color-text-secondary)]">
{summary}
</span>
{!isStreaming && allComplete && !errorPresent && (
{!isRunning && !errorPresent && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
)}
{!isStreaming && errorPresent && (
{!isRunning && errorPresent && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)]">error</span>
)}
{!isStreaming && !allComplete && !errorPresent && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">pending</span>
)}
{isStreaming && (
{isRunning && (
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-brand)] animate-pulse-dot" />
)}
</button>

View File

@ -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(<ActiveSession />)
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()

View File

@ -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(() => {