mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
8ff5353455
commit
3511f30978
@ -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[] = [
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user