fix(tui): drain message queue on every busy → false transition

Previously the queue only drained inside the message.complete event
handler, so anything enqueued while a shell.exec (!sleep, !cmd) or a
failed agent turn was running would stay stuck forever — neither of
those paths emits message.complete. After Ctrl+C an interrupted
session would also orphan the queue because idle() flips busy=false
locally without going through message.complete.

Single source of truth: a useEffect that watches ui.busy. When the
session is settled (sid present, busy false, not editing a queue
item), pull one message and send it. Covers agent turn end,
interrupt, shell.exec completion, error recovery, and the original
startup hydration (first-sid case) all at once.

Dropped the now-redundant dequeue/sendQueued from
createGatewayEventHandler.message.complete and the accompanying
GatewayEventHandlerContext.composer field — the effect handles it.
This commit is contained in:
Brooklyn Nicholson 2026-04-19 08:56:29 -05:00
parent 393175e60c
commit d32e8d2ace
3 changed files with 6 additions and 25 deletions

View file

@ -46,7 +46,6 @@ const pushNote = pushUnique(6)
const pushTool = pushUnique(8) const pushTool = pushUnique(8)
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
const { dequeue, queueEditRef, sendQueued } = ctx.composer
const { rpc } = ctx.gateway const { rpc } = ctx.gateway
const { STARTUP_RESUME_ID, newSession, resumeById, setCatalog } = ctx.session const { STARTUP_RESUME_ID, newSession, resumeById, setCatalog } = ctx.session
const { bellOnComplete, stdout, sys } = ctx.system const { bellOnComplete, stdout, sys } = ctx.system
@ -394,16 +393,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
patchUiState(state => ({ ...state, usage: { ...state.usage, ...ev.payload!.usage } })) patchUiState(state => ({ ...state, usage: { ...state.usage, ...ev.payload!.usage } }))
} }
if (queueEditRef.current !== null) {
return
}
const next = dequeue()
if (next) {
sendQueued(next)
}
return return
} }

View file

@ -193,11 +193,6 @@ export interface InputHandlerResult {
} }
export interface GatewayEventHandlerContext { export interface GatewayEventHandlerContext {
composer: {
dequeue: () => string | undefined
queueEditRef: MutableRefObject<null | number>
sendQueued: (text: string) => void
}
gateway: GatewayServices gateway: GatewayServices
session: { session: {
STARTUP_RESUME_ID: string STARTUP_RESUME_ID: string

View file

@ -380,12 +380,13 @@ export function useMainApp(gw: GatewayClient) {
sys sys
}) })
const prevSidRef = useRef<null | string>(null) // Drain one queued message whenever the session settles (busy → false):
// agent turn ends, interrupt, shell.exec finishes, error recovered, or the
// session first comes up with pre-queued messages. Without this, shell.exec
// and error paths never emit message.complete, so anything enqueued while
// `!sleep` / a failed turn was running would stay stuck forever.
useEffect(() => { useEffect(() => {
const prev = prevSidRef.current if (!ui.sid || ui.busy || composerRefs.queueEditRef.current !== null) {
prevSidRef.current = ui.sid
if (prev !== null || !ui.sid || ui.busy || composerRefs.queueEditRef.current !== null) {
return return
} }
@ -416,7 +417,6 @@ export function useMainApp(gw: GatewayClient) {
const onEvent = useMemo( const onEvent = useMemo(
() => () =>
createGatewayEventHandler({ createGatewayEventHandler({
composer: { dequeue: composerActions.dequeue, queueEditRef: composerRefs.queueEditRef, sendQueued },
gateway, gateway,
session: { session: {
STARTUP_RESUME_ID, STARTUP_RESUME_ID,
@ -432,11 +432,8 @@ export function useMainApp(gw: GatewayClient) {
[ [
appendMessage, appendMessage,
bellOnComplete, bellOnComplete,
composerActions,
composerRefs,
gateway, gateway,
panel, panel,
sendQueued,
session.newSession, session.newSession,
session.resetSession, session.resetSession,
session.resumeById, session.resumeById,