mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
f793895fb3
commit
52682a4c76
@ -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: {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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] ${
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1418,6 +1418,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'tool.generatingContent': 'コンテンツを生成中',
|
||||
'tool.preparingEdit': '編集を準備中',
|
||||
'tool.preparingTool': 'ツールを準備中',
|
||||
'tool.stopped': '停止済み',
|
||||
'tool.readFileContents': 'ファイルの内容を読み取り',
|
||||
'tool.createFile': 'ファイルを作成',
|
||||
'tool.updateFileContents': 'ファイルの内容を更新',
|
||||
|
||||
@ -1418,6 +1418,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'tool.generatingContent': '콘텐츠 생성 중',
|
||||
'tool.preparingEdit': '편집 준비 중',
|
||||
'tool.preparingTool': '도구 준비 중',
|
||||
'tool.stopped': '중지됨',
|
||||
'tool.readFileContents': '파일 내용 읽기',
|
||||
'tool.createFile': '파일 만들기',
|
||||
'tool.updateFileContents': '파일 내용 업데이트',
|
||||
|
||||
@ -1418,6 +1418,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'tool.generatingContent': '正在生成內容',
|
||||
'tool.preparingEdit': '正在準備編輯',
|
||||
'tool.preparingTool': '正在準備工具',
|
||||
'tool.stopped': '已停止',
|
||||
'tool.readFileContents': '讀取檔案內容',
|
||||
'tool.createFile': '建立檔案',
|
||||
'tool.updateFileContents': '更新檔案內容',
|
||||
|
||||
@ -1418,6 +1418,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'tool.generatingContent': '正在生成内容',
|
||||
'tool.preparingEdit': '正在准备编辑',
|
||||
'tool.preparingTool': '正在准备工具',
|
||||
'tool.stopped': '已停止',
|
||||
'tool.readFileContents': '读取文件内容',
|
||||
'tool.createFile': '创建文件',
|
||||
'tool.updateFileContents': '更新文件内容',
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user