fix(desktop): stop pending tool spinners on interrupt (#703)

When the user stops generation, finalize local streaming state by flushing any buffered assistant text into the transcript and marking pending tool-use cards as stopped instead of leaving them in a generating state.

Tested: cd desktop && bun run test -- src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx src/components/chat/chatBlocks.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Confidence: high
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-15 16:50:45 +08:00
parent f793895fb3
commit 52682a4c76
12 changed files with 120 additions and 8 deletions

View File

@ -1044,6 +1044,33 @@ describe('MessageList nested tool calls', () => {
expect(container.querySelectorAll('[data-message-shell="assistant"]')).toHaveLength(0)
})
it('renders stopped tool calls as terminal instead of still generating content', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'idle',
messages: [
{
id: 'tool-write',
type: 'tool_use',
toolName: 'Write',
toolUseId: 'write-1',
input: { file_path: '/tmp/story.md' },
timestamp: 1,
isPending: false,
status: 'stopped',
} as UIMessage,
],
}),
},
})
render(<MessageList />)
expect(screen.getByText('Stopped')).toBeTruthy()
expect(screen.queryByText('Generating content')).toBeNull()
})
it('renders saved memory events with an entrypoint to memory settings', () => {
useChatStore.setState({
sessions: {

View File

@ -1151,7 +1151,7 @@ function getMessageMetricSignature(message: UIMessage): string {
case 'system':
return `${message.type}:${message.content.length}`
case 'tool_use':
return `${message.type}:${message.toolName}:${message.toolUseId}:${message.partialInput?.length ?? 0}:${message.isPending ? 1 : 0}`
return `${message.type}:${message.toolName}:${message.toolUseId}:${message.partialInput?.length ?? 0}:${message.isPending ? 1 : 0}:${message.status ?? ''}`
case 'tool_result':
return `${message.type}:${message.toolUseId}:${message.isError ? 1 : 0}`
case 'compact_summary':
@ -2141,6 +2141,7 @@ export const MessageBlock = memo(function MessageBlock({
input={message.input}
result={toolResult}
isPending={message.isPending}
status={message.status}
partialInput={message.partialInput}
agentTaskNotification={
message.toolName === 'Agent'

View File

@ -1,5 +1,5 @@
import { memo, useMemo, useState } from 'react'
import { LoaderCircle } from 'lucide-react'
import { CircleStop, LoaderCircle } from 'lucide-react'
import { CodeViewer } from './CodeViewer'
import { DiffViewer } from './DiffViewer'
import { TerminalChrome } from './TerminalChrome'
@ -17,6 +17,7 @@ type Props = {
agentTaskNotification?: AgentTaskNotification
compact?: boolean
isPending?: boolean
status?: 'stopped'
partialInput?: string
}
@ -37,7 +38,7 @@ const TOOL_ICONS: Record<string, string> = {
const WRITER_PREVIEW_MAX_LINES = 120
const WRITER_PREVIEW_MAX_CHARS = 30000
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) {
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, status, partialInput }: Props) {
const isPlanTool = isExitPlanModeTool(toolName)
const [expanded, setExpanded] = useState(isPlanTool)
const t = useTranslation()
@ -54,6 +55,9 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
const pendingSummary = isPending && !result
? getPendingSummary(toolName, t)
: ''
const stoppedSummary = status === 'stopped' && !result
? t('tool.stopped')
: ''
const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
const details = useMemo(() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined), [isPending, obj, partialInput, toolName, t])
@ -108,6 +112,11 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
<LoaderCircle size={12} strokeWidth={2.4} className="animate-spin" aria-hidden="true" />
{pendingSummary}
</span>
) : stoppedSummary ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-outline)]">
<CircleStop size={12} strokeWidth={2.25} aria-hidden="true" />
{stoppedSummary}
</span>
) : result && outputSummary ? (
<span
className={`shrink-0 text-[10px] ${

View File

@ -91,6 +91,7 @@ function isToolCallResolved(
resultMap: Map<string, ToolResult>,
childToolCallsByParent: Map<string, ToolCall[]>,
): boolean {
if (toolCall.status === 'stopped') return true
if (!resultMap.has(toolCall.toolUseId)) return false
return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).every((childToolCall) =>
@ -652,6 +653,7 @@ function ToolCallTree({
result={result ? { content: result.content, isError: result.isError } : null}
compact={compact}
isPending={toolCall.isPending}
status={toolCall.status}
partialInput={toolCall.partialInput}
/>
{childToolCalls.length > 0 && (

View File

@ -1416,6 +1416,7 @@ export const en = {
'tool.generatingContent': 'Generating content',
'tool.preparingEdit': 'Preparing edit',
'tool.preparingTool': 'Preparing tool',
'tool.stopped': 'Stopped',
'tool.readFileContents': 'Read file contents',
'tool.createFile': 'Create file',
'tool.updateFileContents': 'Update file contents',

View File

@ -1418,6 +1418,7 @@ export const jp: Record<TranslationKey, string> = {
'tool.generatingContent': 'コンテンツを生成中',
'tool.preparingEdit': '編集を準備中',
'tool.preparingTool': 'ツールを準備中',
'tool.stopped': '停止済み',
'tool.readFileContents': 'ファイルの内容を読み取り',
'tool.createFile': 'ファイルを作成',
'tool.updateFileContents': 'ファイルの内容を更新',

View File

@ -1418,6 +1418,7 @@ export const kr: Record<TranslationKey, string> = {
'tool.generatingContent': '콘텐츠 생성 중',
'tool.preparingEdit': '편집 준비 중',
'tool.preparingTool': '도구 준비 중',
'tool.stopped': '중지됨',
'tool.readFileContents': '파일 내용 읽기',
'tool.createFile': '파일 만들기',
'tool.updateFileContents': '파일 내용 업데이트',

View File

@ -1418,6 +1418,7 @@ export const zh: Record<TranslationKey, string> = {
'tool.generatingContent': '正在生成內容',
'tool.preparingEdit': '正在準備編輯',
'tool.preparingTool': '正在準備工具',
'tool.stopped': '已停止',
'tool.readFileContents': '讀取檔案內容',
'tool.createFile': '建立檔案',
'tool.updateFileContents': '更新檔案內容',

View File

@ -1418,6 +1418,7 @@ export const zh: Record<TranslationKey, string> = {
'tool.generatingContent': '正在生成内容',
'tool.preparingEdit': '正在准备编辑',
'tool.preparingTool': '正在准备工具',
'tool.stopped': '已停止',
'tool.readFileContents': '读取文件内容',
'tool.createFile': '创建文件',
'tool.updateFileContents': '更新文件内容',

View File

@ -1896,6 +1896,45 @@ describe('chatStore history mapping', () => {
vi.useRealTimers()
})
it('marks pending tool input as stopped when generation is stopped', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ chatState: 'tool_executing' }),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_start',
blockType: 'tool_use',
toolName: 'Write',
toolUseId: 'write-1',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
toolInput: '{"file_path":"/private/tmp/story.md","content":"第一章',
})
vi.advanceTimersByTime(60)
useChatStore.getState().stopGeneration(TEST_SESSION_ID)
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.chatState).toBe('idle')
expect(session?.activeToolUseId).toBeNull()
expect(session?.activeToolName).toBeNull()
expect(session?.streamingToolInput).toBe('')
expect(session?.messages[0]).toMatchObject({
type: 'tool_use',
toolUseId: 'write-1',
isPending: false,
status: 'stopped',
})
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('refreshes merged slash commands when a live CLI update omits project commands', async () => {
const cliCommand = { name: 'builtin-help', description: 'Built-in command' }
const projectCommand = { name: 'project-probe', description: 'Project custom command' }
@ -3652,7 +3691,10 @@ describe('chatStore history mapping', () => {
useChatStore.getState().stopGeneration('session-a')
expect(useChatStore.getState().sessions['session-a']?.streamingText).toBe('A-only response')
expect(useChatStore.getState().sessions['session-a']?.streamingText).toBe('')
expect(useChatStore.getState().sessions['session-a']?.messages).toMatchObject([
{ type: 'assistant_text', content: 'A-only response' },
])
expect(useChatStore.getState().sessions['session-b']?.streamingText).toBe('')
useChatStore.getState().handleServerMessage('session-b', {

View File

@ -356,6 +356,20 @@ function upsertToolUseMessage(
return next
}
function markPendingToolUseMessagesStopped(messages: UIMessage[]): UIMessage[] {
let changed = false
const stoppedMessages = messages.map((message) => {
if (message.type !== 'tool_use' || !message.isPending) return message
changed = true
return {
...message,
isPending: false,
status: 'stopped' as const,
}
})
return changed ? stoppedMessages : messages
}
// Streaming throttle for content_delta. Buffers must be per-session because
// multiple desktop tabs can stream at the same time.
const pendingDeltaBySession = new Map<string, string>()
@ -1087,21 +1101,31 @@ export const useChatStore = create<ChatStore>((set, get) => ({
stopGeneration: (sessionId) => {
wsManager.send(sessionId, { type: 'stop_generation' })
if (pendingDeltaBySession.has(sessionId)) {
const text = consumePendingDelta(sessionId)
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
}
const bufferedText = consumePendingDelta(sessionId)
clearPendingToolInputDelta(sessionId)
clearPendingTaskToolUseIds(sessionId)
clearPendingToolParentUseIds(sessionId)
set((s) => {
const session = s.sessions[sessionId]
if (!session) return s
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
const pendingAssistantText = `${session.streamingText}${bufferedText}`
const messagesWithFlushedText = pendingAssistantText.trim()
? appendAssistantTextMessage(session.messages, pendingAssistantText, Date.now())
: session.messages
return {
sessions: {
...s.sessions,
[sessionId]: {
...session,
messages: markPendingToolUseMessagesStopped(messagesWithFlushedText),
chatState: 'idle',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
streamingText: '',
streamingToolInput: '',
statusVerb: '',
pendingPermission: null,
pendingComputerUsePermission: null,
apiRetry: null,
@ -1111,6 +1135,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
},
}
})
useTabStore.getState().updateTabStatus(sessionId, 'idle')
},
loadHistory: async (sessionId) => {

View File

@ -277,6 +277,7 @@ export type UIMessage =
timestamp: number
parentToolUseId?: string
isPending?: boolean
status?: 'stopped'
partialInput?: string
}
| { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string }