mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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')
|
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', () => {
|
it('does not render blank assistant bubbles for whitespace-only text', () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -63,13 +63,51 @@ function generateSummary(toolCalls: ToolCall[], t: (key: TranslationKey, params?
|
|||||||
return parts.join(', ')
|
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) => {
|
return toolCalls.some((tc) => {
|
||||||
const result = resultMap.get(tc.toolUseId)
|
return toolCallHasError(tc, resultMap, childToolCallsByParent)
|
||||||
return result?.isError
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
export function ToolCallGroup({
|
||||||
toolCalls,
|
toolCalls,
|
||||||
resultMap,
|
resultMap,
|
||||||
@ -380,15 +418,16 @@ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isSt
|
|||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const summary = generateSummary(toolCalls, t)
|
const summary = generateSummary(toolCalls, t)
|
||||||
const errorPresent = groupHasErrors(toolCalls, resultMap)
|
const errorPresent = groupHasErrors(toolCalls, resultMap, childToolCallsByParent)
|
||||||
const allComplete = toolCalls.every((tc) => resultMap.has(tc.toolUseId))
|
const hasUnresolvedTools = hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent)
|
||||||
|
const isRunning = !!isStreaming || hasUnresolvedTools
|
||||||
const hasNestedToolCalls = toolCalls.some((tc) => (childToolCallsByParent.get(tc.toolUseId)?.length ?? 0) > 0)
|
const hasNestedToolCalls = toolCalls.some((tc) => (childToolCallsByParent.get(tc.toolUseId)?.length ?? 0) > 0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming || hasNestedToolCalls) {
|
if (isRunning || hasNestedToolCalls) {
|
||||||
setExpanded(true)
|
setExpanded(true)
|
||||||
}
|
}
|
||||||
}, [hasNestedToolCalls, isStreaming])
|
}, [hasNestedToolCalls, isRunning])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-2">
|
<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)]">
|
<span className="flex-1 truncate text-[12px] text-[var(--color-text-secondary)]">
|
||||||
{summary}
|
{summary}
|
||||||
</span>
|
</span>
|
||||||
{!isStreaming && allComplete && !errorPresent && (
|
{!isRunning && !errorPresent && (
|
||||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
|
<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>
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)]">error</span>
|
||||||
)}
|
)}
|
||||||
{!isStreaming && !allComplete && !errorPresent && (
|
{isRunning && (
|
||||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">pending</span>
|
|
||||||
)}
|
|
||||||
{isStreaming && (
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-brand)] animate-pulse-dot" />
|
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-brand)] animate-pulse-dot" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -277,6 +277,68 @@ describe('ActiveSession task polling', () => {
|
|||||||
expect(screen.getByTestId('message-list')).toBeInTheDocument()
|
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 () => {
|
it('refreshes CLI tasks repeatedly while a turn is active', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
|||||||
@ -210,8 +210,11 @@ export function ActiveSession() {
|
|||||||
const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks)
|
const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks)
|
||||||
const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId)
|
const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId)
|
||||||
const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed'))
|
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 chatState = sessionState?.chatState ?? 'idle'
|
||||||
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
|
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 session = sessions.find((s) => s.id === activeTabId)
|
||||||
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
||||||
@ -265,7 +268,9 @@ export function ActiveSession() {
|
|||||||
const streamingText = sessionState?.streamingText ?? ''
|
const streamingText = sessionState?.streamingText ?? ''
|
||||||
const isEmpty = messages.length === 0 && !streamingText && (session?.messageCount ?? 0) === 0
|
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 totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
|
||||||
|
|
||||||
const lastUpdated = useMemo(() => {
|
const lastUpdated = useMemo(() => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user