diff --git a/desktop/package.json b/desktop/package.json index 9eecad3e..5c8cf02e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "claude-code-desktop", "private": true, - "version": "0.5.3", + "version": "0.5.4", "description": "Desktop coding agent workbench for Claude Code Haha.", "homepage": "https://github.com/NanmiCoder/cc-haha", "author": { diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 5fa18ea2..a587a055 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -3941,15 +3941,20 @@ describe('chatStore message queue', () => { expect(findUserMessageSends()).toHaveLength(0) }) - it('does not flush the queue on the idle produced by a user Stop, then resumes on the next idle', () => { + it('does not flush the queue on the idle(s) produced by a user Stop, then resumes after the next message', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ chatState: 'streaming' }) }, }) useChatStore.getState().enqueueMessage(TEST_SESSION_ID, 'queued-1') useChatStore.getState().enqueueMessage(TEST_SESSION_ID, 'queued-2') - // User clicks Stop: the resulting idle must NOT drain the queue. + // User clicks Stop. A Stop can emit multiple idle-like events (result + + // status); NONE of them may drain the queue, or the turn would "resume". useChatStore.getState().stopGeneration(TEST_SESSION_ID) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'message_complete', + usage: { input_tokens: 1, output_tokens: 1 }, + }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'status', state: 'idle' }) expect(findUserMessageSends()).toHaveLength(0) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messageQueue).toHaveLength(2) @@ -3959,7 +3964,6 @@ describe('chatStore message queue', () => { const afterPriority = findUserMessageSends() expect(afterPriority).toHaveLength(1) expect((afterPriority[0]![1] as { content: string }).content).toBe('priority now') - // Queue untouched by the priority send. expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messageQueue).toHaveLength(2) // When the priority turn completes, the queue resumes (one item drains). diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 4b8d0b13..5a895871 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -341,11 +341,12 @@ const flushTimerBySession = new Map>() const pendingToolInputDeltaBySession = new Map() const toolInputFlushTimerBySession = new Map>() -// One-shot guard: when the user clicks Stop, skip exactly the next auto-drain -// of the message queue so the queue does not immediately flush. This lets the -// user send a priority message that fires right away (session is idle). After -// that message finishes, the queue resumes on the next idle as usual. -const skipNextQueueDrain = new Set() +// When the user clicks Stop, pause queue auto-draining until the user sends +// their next message. Stop can produce multiple idle-like events (result + +// status), so a one-shot skip is not enough — we stay paused and only clear it +// when a fresh user message is sent (which then runs immediately, ahead of the +// queue). The queue resumes on the idle after that message completes. +const queueDrainPaused = new Set() function consumePendingDelta(sessionId: string): string { const flushTimer = flushTimerBySession.get(sessionId) @@ -904,7 +905,7 @@ export const useChatStore = create((set, get) => ({ clearPendingToolInputDelta(sessionId) clearPendingTaskToolUseIds(sessionId) clearPendingToolParentUseIds(sessionId) - skipNextQueueDrain.delete(sessionId) + queueDrainPaused.delete(sessionId) wsManager.disconnect(sessionId) set((s) => { const { [sessionId]: _, ...rest } = s.sessions @@ -913,6 +914,9 @@ export const useChatStore = create((set, get) => ({ }, sendMessage: (sessionId, content, attachments, options) => { + // A fresh user send takes priority over any paused queue: clear the pause + // so this message fires now, and the queue resumes on the next idle. + queueDrainPaused.delete(sessionId) const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId) const hideDisplayContent = !isMemberSession && options?.hideDisplayContent === true const userFacingContent = @@ -1090,12 +1094,10 @@ export const useChatStore = create((set, get) => ({ drainMessageQueue: (sessionId) => { const session = get().sessions[sessionId] if (!session) return - // A user-initiated Stop sets a one-shot skip so this idle does not flush - // the queue. Consume it and bail; the queue resumes on the next idle. - if (skipNextQueueDrain.has(sessionId)) { - skipNextQueueDrain.delete(sessionId) - return - } + // Paused by a user Stop: do not drain on any idle event. Stays paused (we + // do NOT clear it here) until the user sends their next message, which + // clears it. This survives the multiple idle events a Stop produces. + if (queueDrainPaused.has(sessionId)) return // Only drain on a true idle turn boundary — never mid-stream, while a tool // is running, or while a permission prompt is pending. if (session.chatState !== 'idle') return @@ -1155,11 +1157,12 @@ export const useChatStore = create((set, get) => ({ stopGeneration: (sessionId) => { wsManager.send(sessionId, { type: 'stop_generation' }) - // Skip the auto-drain triggered by the idle that this Stop produces, so the - // queue is not flushed. The next message the user sends fires immediately - // (session is idle); the queue resumes draining after that turn completes. + // Pause queue draining until the user sends their next message. Stop emits + // multiple idle events (result + status); staying paused (rather than a + // one-shot skip) prevents any queued message from auto-firing, so the turn + // truly stops. The next user message clears this and fires immediately. if ((get().sessions[sessionId]?.messageQueue?.length ?? 0) > 0) { - skipNextQueueDrain.add(sessionId) + queueDrainPaused.add(sessionId) } if (pendingDeltaBySession.has(sessionId)) { const text = consumePendingDelta(sessionId) diff --git a/release-notes/v0.5.4.md b/release-notes/v0.5.4.md new file mode 100644 index 00000000..89451d32 --- /dev/null +++ b/release-notes/v0.5.4.md @@ -0,0 +1,25 @@ +# v0.5.4 + +## 修复 + +- **彻底修复"点击停止仍会消费一条队列消息"**。v0.5.3 的一次性跳过只挡住一次自动续队, + 而停止会产生多个 idle 事件(`message_complete` + `status idle`),第二个事件仍会续队、 + 消费一条消息并把按钮重置回运行中(需再点一次才停得下来)。 + 现在改为**暂停标志**:点击停止后持续跳过所有自动续队,直到用户发出下一条消息才解除—— + - 停止只停当前回合,**一条队列消息都不消费**,按钮稳定停在"运行"。 + - 此时再发消息**立即优先触发**(不进队列,即使队列有货)。 + - 该优先消息完成后,队列在下一次 idle 照常按 FIFO 继续。 + +## 范围 + +- 修改:`desktop/src/stores/chatStore.ts`(一次性 `skipNextQueueDrain` 改为持久 `queueDrainPaused`, + 停止时置位、`drainMessageQueue` 始终跳过、`sendMessage` 解除)。 +- 仅桌面端逻辑改动;队列入队/排空/编辑机制与持久化未变。 + +## 验证 + +- `cd desktop && bun run lint`(tsc --noEmit)✅ +- desktop Vitest:chatStore 94 passed(含"停止后多次 idle 都不 flush + 优先消息 + 队列续上"用例)✅ +- 浏览器端到端:工作中入队 2 条 → 点停止后按钮稳定 Run、队列保持 2、零消费;发优先消息立即触发、不入队; + 优先消息完成后队列自动续上(甲→乙)✅ +- 本地 Windows x64 NSIS 打包 + package-smoke ✅