From 52682a4c76798d678a8150e61af53f0219b3391b 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, 15 Jun 2026 16:50:45 +0800 Subject: [PATCH] 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 --- .../src/components/chat/MessageList.test.tsx | 27 ++++++++++++ desktop/src/components/chat/MessageList.tsx | 3 +- desktop/src/components/chat/ToolCallBlock.tsx | 13 +++++- desktop/src/components/chat/ToolCallGroup.tsx | 2 + desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/jp.ts | 1 + desktop/src/i18n/locales/kr.ts | 1 + desktop/src/i18n/locales/zh-TW.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + desktop/src/stores/chatStore.test.ts | 44 ++++++++++++++++++- desktop/src/stores/chatStore.ts | 33 ++++++++++++-- desktop/src/types/chat.ts | 1 + 12 files changed, 120 insertions(+), 8 deletions(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index b2e144f8..a9e37cb7 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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() + + 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: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 14282d53..99b578ad 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -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' diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index 81b2d7d7..568e33e8 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -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 = { 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