mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
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:
parent
408e04e500
commit
e66751bfe9
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-desktop",
|
"name": "claude-code-desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.3",
|
"version": "0.5.4",
|
||||||
"description": "Desktop coding agent workbench for Claude Code Haha.",
|
"description": "Desktop coding agent workbench for Claude Code Haha.",
|
||||||
"homepage": "https://github.com/NanmiCoder/cc-haha",
|
"homepage": "https://github.com/NanmiCoder/cc-haha",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@ -3941,15 +3941,20 @@ describe('chatStore message queue', () => {
|
|||||||
expect(findUserMessageSends()).toHaveLength(0)
|
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({
|
useChatStore.setState({
|
||||||
sessions: { [TEST_SESSION_ID]: makeSession({ chatState: 'streaming' }) },
|
sessions: { [TEST_SESSION_ID]: makeSession({ chatState: 'streaming' }) },
|
||||||
})
|
})
|
||||||
useChatStore.getState().enqueueMessage(TEST_SESSION_ID, 'queued-1')
|
useChatStore.getState().enqueueMessage(TEST_SESSION_ID, 'queued-1')
|
||||||
useChatStore.getState().enqueueMessage(TEST_SESSION_ID, 'queued-2')
|
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().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' })
|
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'status', state: 'idle' })
|
||||||
expect(findUserMessageSends()).toHaveLength(0)
|
expect(findUserMessageSends()).toHaveLength(0)
|
||||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messageQueue).toHaveLength(2)
|
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messageQueue).toHaveLength(2)
|
||||||
@ -3959,7 +3964,6 @@ describe('chatStore message queue', () => {
|
|||||||
const afterPriority = findUserMessageSends()
|
const afterPriority = findUserMessageSends()
|
||||||
expect(afterPriority).toHaveLength(1)
|
expect(afterPriority).toHaveLength(1)
|
||||||
expect((afterPriority[0]![1] as { content: string }).content).toBe('priority now')
|
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)
|
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messageQueue).toHaveLength(2)
|
||||||
|
|
||||||
// When the priority turn completes, the queue resumes (one item drains).
|
// When the priority turn completes, the queue resumes (one item drains).
|
||||||
|
|||||||
@ -341,11 +341,12 @@ const flushTimerBySession = new Map<string, ReturnType<typeof setTimeout>>()
|
|||||||
const pendingToolInputDeltaBySession = new Map<string, string>()
|
const pendingToolInputDeltaBySession = new Map<string, string>()
|
||||||
const toolInputFlushTimerBySession = new Map<string, ReturnType<typeof setTimeout>>()
|
const toolInputFlushTimerBySession = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
// One-shot guard: when the user clicks Stop, skip exactly the next auto-drain
|
// When the user clicks Stop, pause queue auto-draining until the user sends
|
||||||
// of the message queue so the queue does not immediately flush. This lets the
|
// their next message. Stop can produce multiple idle-like events (result +
|
||||||
// user send a priority message that fires right away (session is idle). After
|
// status), so a one-shot skip is not enough — we stay paused and only clear it
|
||||||
// that message finishes, the queue resumes on the next idle as usual.
|
// when a fresh user message is sent (which then runs immediately, ahead of the
|
||||||
const skipNextQueueDrain = new Set<string>()
|
// queue). The queue resumes on the idle after that message completes.
|
||||||
|
const queueDrainPaused = new Set<string>()
|
||||||
|
|
||||||
function consumePendingDelta(sessionId: string): string {
|
function consumePendingDelta(sessionId: string): string {
|
||||||
const flushTimer = flushTimerBySession.get(sessionId)
|
const flushTimer = flushTimerBySession.get(sessionId)
|
||||||
@ -904,7 +905,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
clearPendingToolInputDelta(sessionId)
|
clearPendingToolInputDelta(sessionId)
|
||||||
clearPendingTaskToolUseIds(sessionId)
|
clearPendingTaskToolUseIds(sessionId)
|
||||||
clearPendingToolParentUseIds(sessionId)
|
clearPendingToolParentUseIds(sessionId)
|
||||||
skipNextQueueDrain.delete(sessionId)
|
queueDrainPaused.delete(sessionId)
|
||||||
wsManager.disconnect(sessionId)
|
wsManager.disconnect(sessionId)
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const { [sessionId]: _, ...rest } = s.sessions
|
const { [sessionId]: _, ...rest } = s.sessions
|
||||||
@ -913,6 +914,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
sendMessage: (sessionId, content, attachments, options) => {
|
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 isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId)
|
||||||
const hideDisplayContent = !isMemberSession && options?.hideDisplayContent === true
|
const hideDisplayContent = !isMemberSession && options?.hideDisplayContent === true
|
||||||
const userFacingContent =
|
const userFacingContent =
|
||||||
@ -1090,12 +1094,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
drainMessageQueue: (sessionId) => {
|
drainMessageQueue: (sessionId) => {
|
||||||
const session = get().sessions[sessionId]
|
const session = get().sessions[sessionId]
|
||||||
if (!session) return
|
if (!session) return
|
||||||
// A user-initiated Stop sets a one-shot skip so this idle does not flush
|
// Paused by a user Stop: do not drain on any idle event. Stays paused (we
|
||||||
// the queue. Consume it and bail; the queue resumes on the next idle.
|
// do NOT clear it here) until the user sends their next message, which
|
||||||
if (skipNextQueueDrain.has(sessionId)) {
|
// clears it. This survives the multiple idle events a Stop produces.
|
||||||
skipNextQueueDrain.delete(sessionId)
|
if (queueDrainPaused.has(sessionId)) return
|
||||||
return
|
|
||||||
}
|
|
||||||
// Only drain on a true idle turn boundary — never mid-stream, while a tool
|
// Only drain on a true idle turn boundary — never mid-stream, while a tool
|
||||||
// is running, or while a permission prompt is pending.
|
// is running, or while a permission prompt is pending.
|
||||||
if (session.chatState !== 'idle') return
|
if (session.chatState !== 'idle') return
|
||||||
@ -1155,11 +1157,12 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
|
|
||||||
stopGeneration: (sessionId) => {
|
stopGeneration: (sessionId) => {
|
||||||
wsManager.send(sessionId, { type: 'stop_generation' })
|
wsManager.send(sessionId, { type: 'stop_generation' })
|
||||||
// Skip the auto-drain triggered by the idle that this Stop produces, so the
|
// Pause queue draining until the user sends their next message. Stop emits
|
||||||
// queue is not flushed. The next message the user sends fires immediately
|
// multiple idle events (result + status); staying paused (rather than a
|
||||||
// (session is idle); the queue resumes draining after that turn completes.
|
// 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) {
|
if ((get().sessions[sessionId]?.messageQueue?.length ?? 0) > 0) {
|
||||||
skipNextQueueDrain.add(sessionId)
|
queueDrainPaused.add(sessionId)
|
||||||
}
|
}
|
||||||
if (pendingDeltaBySession.has(sessionId)) {
|
if (pendingDeltaBySession.has(sessionId)) {
|
||||||
const text = consumePendingDelta(sessionId)
|
const text = consumePendingDelta(sessionId)
|
||||||
|
|||||||
25
release-notes/v0.5.4.md
Normal file
25
release-notes/v0.5.4.md
Normal 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 Vitest:chatStore 94 passed(含"停止后多次 idle 都不 flush + 优先消息 + 队列续上"用例)✅
|
||||||
|
- 浏览器端到端:工作中入队 2 条 → 点停止后按钮稳定 Run、队列保持 2、零消费;发优先消息立即触发、不入队;
|
||||||
|
优先消息完成后队列自动续上(甲→乙)✅
|
||||||
|
- 本地 Windows x64 NSIS 打包 + package-smoke ✅
|
||||||
Loading…
x
Reference in New Issue
Block a user