Skip to content

Commit 486fd23

Browse files
frenchie4111claude
andcommitted
fix(chat): unqueue mid-turn messages when claude picks them up
Mid-turn interjections kept their dashed "queued" bubble until the whole turn ended, even though claude ingests them at the next agent-loop step — often minutes earlier, and visibly so when the reply already referenced the queued message. Claude drains its pending-input queue immediately before building the next API request and emits {system, subtype: status, status: requesting} at that moment. Verified against a session transcript: the `queue-operation: remove` record lands ~1ms before that event. So unqueue there instead of waiting for `result`, which stays as the backstop for messages that land after the final request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c9f2639 commit 486fd23

3 files changed

Lines changed: 127 additions & 13 deletions

File tree

src/main/json-claude-manager.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,93 @@ describe('JsonClaudeManager', () => {
304304
expect(cmd).not.toContain('--name')
305305
})
306306

307+
// Mid-turn messages used to stay dashed/"queued" until the whole turn
308+
// ended, even though claude picks them up at the next agent-loop step.
309+
// Claude drains its input queue right before building the next API
310+
// request and emits {system, status: 'requesting'} at that moment — so
311+
// that event, not `result`, is when the bubble should go solid.
312+
describe('mid-turn queued messages', () => {
313+
function startBusySession(store: Store, sessionId: string) {
314+
const mgr = makeManager(store)
315+
const cwd = '/tmp/wt'
316+
store.dispatch({
317+
type: 'jsonClaude/sessionStarted',
318+
payload: { sessionId, worktreePath: cwd }
319+
})
320+
mgr.create(sessionId, cwd)
321+
mgr.send(sessionId, 'first turn')
322+
return { mgr, proc: sessionProcs()[0] }
323+
}
324+
325+
const queuedCount = (store: Store, sessionId: string) =>
326+
(store.getSnapshot().state.jsonClaude.sessions[sessionId]?.entries ?? []).filter(
327+
(e) => e.isQueued
328+
).length
329+
330+
it("clears isQueued on the next 'requesting' status, before the turn ends", () => {
331+
const store = new Store()
332+
const sessionId = 'sess-queued-requesting'
333+
const { mgr, proc } = startBusySession(store, sessionId)
334+
335+
mgr.send(sessionId, 'interjection')
336+
expect(queuedCount(store, sessionId)).toBe(1)
337+
338+
// A tool result mid-turn doesn't mean the message was picked up.
339+
proc.stdout.emit(
340+
'data',
341+
Buffer.from(
342+
JSON.stringify({
343+
type: 'user',
344+
message: {
345+
role: 'user',
346+
content: [
347+
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' }
348+
]
349+
}
350+
}) + '\n'
351+
)
352+
)
353+
expect(queuedCount(store, sessionId)).toBe(1)
354+
355+
proc.stdout.emit(
356+
'data',
357+
Buffer.from(
358+
JSON.stringify({ type: 'system', subtype: 'status', status: 'requesting' }) +
359+
'\n'
360+
)
361+
)
362+
expect(queuedCount(store, sessionId)).toBe(0)
363+
// Still mid-turn: busy stays true, the bubble just isn't queued.
364+
expect(store.getSnapshot().state.jsonClaude.sessions[sessionId]?.busy).toBe(true)
365+
})
366+
367+
it('leaves a message queued while a non-requesting status streams by', () => {
368+
const store = new Store()
369+
const sessionId = 'sess-queued-other-status'
370+
const { mgr, proc } = startBusySession(store, sessionId)
371+
mgr.send(sessionId, 'interjection')
372+
373+
proc.stdout.emit(
374+
'data',
375+
Buffer.from(
376+
JSON.stringify({ type: 'system', subtype: 'status', status: 'compacting' }) +
377+
'\n'
378+
)
379+
)
380+
expect(queuedCount(store, sessionId)).toBe(1)
381+
})
382+
383+
it('still unqueues on result for messages that land after the last request', () => {
384+
const store = new Store()
385+
const sessionId = 'sess-queued-result'
386+
const { mgr, proc } = startBusySession(store, sessionId)
387+
mgr.send(sessionId, 'interjection')
388+
389+
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'result' }) + '\n'))
390+
expect(queuedCount(store, sessionId)).toBe(0)
391+
})
392+
})
393+
307394
it('rate_limit_event over threshold emits one warning card; back-to-back duplicates dedup', () => {
308395
const store = new Store()
309396
const mgr = makeManager(store)

src/main/json-claude-manager.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -839,8 +839,11 @@ export class JsonClaudeManager {
839839
// stream-json input buffers between agent-loop steps, so the
840840
// message gets injected at the next safe boundary (typically
841841
// post-tool_result) within the current turn — matching the TUI's
842-
// interject-while-busy behavior. On `result` we clear the
843-
// isQueued flag so the bubble loses its dashed/queued styling.
842+
// interject-while-busy behavior. The isQueued flag clears when
843+
// claude drains its input queue into the next request (see the
844+
// `system/status: requesting` branch in handleLine), so the bubble
845+
// stops looking dashed the moment the message is really in the
846+
// conversation rather than at the end of the turn.
844847
const session =
845848
this.store.getSnapshot().state.jsonClaude.sessions[sessionId]
846849
if (session?.busy) {
@@ -959,6 +962,20 @@ export class JsonClaudeManager {
959962
}
960963
}
961964

965+
/** Clear the dashed/queued styling on every in-flight queued entry of
966+
* a session. Guarded on there actually being one: the reducer no-ops
967+
* when nothing is queued, but the dispatch would still fan out an
968+
* IPC broadcast to every renderer, and the caller on the hot path
969+
* (`system/status: requesting`) fires on every API request. */
970+
private unqueueUserEntries(sessionId: string): void {
971+
const session = this.store.getSnapshot().state.jsonClaude.sessions[sessionId]
972+
if (!session?.entries.some((e) => e.isQueued)) return
973+
this.store.dispatch({
974+
type: 'jsonClaude/userEntriesUnqueued',
975+
payload: { sessionId }
976+
})
977+
}
978+
962979
/** Remove a queued user entry from the renderer view. NOTE: the
963980
* message text is already in claude's stdin buffer by the time the
964981
* user clicks cancel, so this is UI-only — claude will still
@@ -1006,10 +1023,7 @@ export class JsonClaudeManager {
10061023
// so claude will still process them on the next agent-loop pass
10071024
// unless the interrupt itself drains the buffer (TBD — observe
10081025
// behavior in test).
1009-
this.store.dispatch({
1010-
type: 'jsonClaude/userEntriesUnqueued',
1011-
payload: { sessionId }
1012-
})
1026+
this.unqueueUserEntries(sessionId)
10131027
// Flip busy off optimistically; the result event (when the abort
10141028
// resolves into a turn boundary) will also clear it.
10151029
this.dispatchBusy(sessionId, false)
@@ -1555,6 +1569,20 @@ export class JsonClaudeManager {
15551569
}
15561570
return
15571571
}
1572+
if (type === 'system' && subtype === 'status' && parsed['status'] === 'requesting') {
1573+
// Claude drains its pending-input queue immediately before it
1574+
// builds the next API request, then emits this status. So this is
1575+
// the earliest point at which a mid-turn message we injected is
1576+
// genuinely part of the conversation — verified against the
1577+
// session transcript, where the `queue-operation: remove` record
1578+
// lands ~1ms before this event. Unqueue here rather than waiting
1579+
// for `result`, which only fires at the end of the whole turn and
1580+
// left the bubble dashed for minutes while claude was already
1581+
// acting on it. The `result` handler keeps its own unqueue as the
1582+
// backstop for messages that land after the last request.
1583+
this.unqueueUserEntries(instance.sessionId)
1584+
return
1585+
}
15581586
if (type === 'system' && subtype === 'init') {
15591587
// Session id is already known (we pinned it via --session-id), but
15601588
// the init payload includes the canonical slash_commands list — keep
@@ -1665,10 +1693,7 @@ export class JsonClaudeManager {
16651693
}
16661694
if (type === 'result') {
16671695
this.dispatchBusy(instance.sessionId, false)
1668-
this.store.dispatch({
1669-
type: 'jsonClaude/userEntriesUnqueued',
1670-
payload: { sessionId: instance.sessionId }
1671-
})
1696+
this.unqueueUserEntries(instance.sessionId)
16721697
const authMessage = detectAuthFailureFromResult(parsed)
16731698
if (
16741699
parsed['is_error'] === true ||

src/shared/state/json-claude.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,12 @@ export interface JsonClaudeChatEntry {
209209
* blinking cursor at the end of the text. */
210210
isPartial?: boolean
211211
/** True for a user entry that was typed while busy=true and has
212-
* been written to stdin but not yet resolved by claude (i.e.,
213-
* no `result` boundary has fired since it was queued). The
212+
* been written to stdin but claude hasn't picked it up yet. The
214213
* renderer styles these as dashed/muted "queued" bubbles with
215-
* a cancel affordance. Cleared on the next `result`. */
214+
* a cancel affordance. Cleared as soon as claude drains its
215+
* input queue into the next API request (the `system/status:
216+
* requesting` boundary), which is when the message genuinely
217+
* enters the conversation — not at the end of the whole turn. */
216218
isQueued?: boolean
217219
/** For kind === 'user'. Set when Ness injected the turn itself rather
218220
* than the human typing it, so the renderer can style the bubble as an

0 commit comments

Comments
 (0)