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
{pendingSummary}
+ ) : stoppedSummary ? (
+
+
+ {stoppedSummary}
+
) : result && outputSummary ? (
,
childToolCallsByParent: Map,
): 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 && (
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index 41c401a8..a76d9fd5 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -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',
diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts
index a477fa82..92bc758d 100644
--- a/desktop/src/i18n/locales/jp.ts
+++ b/desktop/src/i18n/locales/jp.ts
@@ -1418,6 +1418,7 @@ export const jp: Record = {
'tool.generatingContent': 'コンテンツを生成中',
'tool.preparingEdit': '編集を準備中',
'tool.preparingTool': 'ツールを準備中',
+ 'tool.stopped': '停止済み',
'tool.readFileContents': 'ファイルの内容を読み取り',
'tool.createFile': 'ファイルを作成',
'tool.updateFileContents': 'ファイルの内容を更新',
diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts
index 02375e64..823f29db 100644
--- a/desktop/src/i18n/locales/kr.ts
+++ b/desktop/src/i18n/locales/kr.ts
@@ -1418,6 +1418,7 @@ export const kr: Record = {
'tool.generatingContent': '콘텐츠 생성 중',
'tool.preparingEdit': '편집 준비 중',
'tool.preparingTool': '도구 준비 중',
+ 'tool.stopped': '중지됨',
'tool.readFileContents': '파일 내용 읽기',
'tool.createFile': '파일 만들기',
'tool.updateFileContents': '파일 내용 업데이트',
diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts
index 3cb445e5..9f3a2fbf 100644
--- a/desktop/src/i18n/locales/zh-TW.ts
+++ b/desktop/src/i18n/locales/zh-TW.ts
@@ -1418,6 +1418,7 @@ export const zh: Record = {
'tool.generatingContent': '正在生成內容',
'tool.preparingEdit': '正在準備編輯',
'tool.preparingTool': '正在準備工具',
+ 'tool.stopped': '已停止',
'tool.readFileContents': '讀取檔案內容',
'tool.createFile': '建立檔案',
'tool.updateFileContents': '更新檔案內容',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 17e6b6bc..c0098e3c 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -1418,6 +1418,7 @@ export const zh: Record = {
'tool.generatingContent': '正在生成内容',
'tool.preparingEdit': '正在准备编辑',
'tool.preparingTool': '正在准备工具',
+ 'tool.stopped': '已停止',
'tool.readFileContents': '读取文件内容',
'tool.createFile': '创建文件',
'tool.updateFileContents': '更新文件内容',
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts
index d6a48202..d00698d1 100644
--- a/desktop/src/stores/chatStore.test.ts
+++ b/desktop/src/stores/chatStore.test.ts
@@ -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', {
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index 0edf7c8c..e72d9e85 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -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()
@@ -1087,21 +1101,31 @@ export const useChatStore = create((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((set, get) => ({
},
}
})
+ useTabStore.getState().updateTabStatus(sessionId, 'idle')
},
loadHistory: async (sessionId) => {
diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts
index 15a17875..9e37f197 100644
--- a/desktop/src/types/chat.ts
+++ b/desktop/src/types/chat.ts
@@ -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 }