fix(desktop): fully pause message queue on Stop (multi-idle safe)

v0.5.3's one-shot skip only blocked a single auto-drain, but a Stop emits
multiple idle events (message_complete + status idle); the second one still
drained one queued message and flipped the button back to running. Replace
the one-shot skip with a sticky queueDrainPaused flag: Stop pauses all
auto-drains until the user sends their next message (which clears it and
fires immediately, ahead of the queue). The queue then resumes FIFO on the
next idle. Bumps desktop app to 0.5.4 with release notes.

Tested: tsc --noEmit; Vitest chatStore 94 passed (incl. multi-idle stop /
priority message / queue resume); browser end-to-end (Stop keeps button at
Run with queue intact, priority send immediate, queue resumes); Windows x64
NSIS package + package-smoke PASS.
Scope-risk: narrow
Confidence: high
This commit is contained in:
你的姓名 2026-06-08 04:20:33 +08:00
parent 408e04e500
commit e66751bfe9
4 changed files with 52 additions and 20 deletions

View File

@ -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": {

View File

@ -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).

View File

@ -341,11 +341,12 @@ const flushTimerBySession = new Map<string, ReturnType<typeof setTimeout>>()
const pendingToolInputDeltaBySession = new Map<string, string>()
const toolInputFlushTimerBySession = new Map<string, ReturnType<typeof setTimeout>>()
// 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<string>()
// 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<string>()
function consumePendingDelta(sessionId: string): string {
const flushTimer = flushTimerBySession.get(sessionId)
@ -904,7 +905,7 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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)

25
release-notes/v0.5.4.md Normal file
View File

@ -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 VitestchatStore 94 passed含"停止后多次 idle 都不 flush + 优先消息 + 队列续上"用例)✅
- 浏览器端到端:工作中入队 2 条 → 点停止后按钮稳定 Run、队列保持 2、零消费发优先消息立即触发、不入队
优先消息完成后队列自动续上(甲→乙)✅
- 本地 Windows x64 NSIS 打包 + package-smoke ✅