From 11d36232c03dd950942a97a08003aeca18eb4b2e Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 17 Jul 2026 15:44:10 -0400 Subject: [PATCH] fix(desktop): stop button sends interrupt to wrong session + stale events re-arm busy (#66485) Co-authored-by: Brooklyn Nicholson --- .../hooks/use-message-stream/gateway-event.ts | 38 +++++++++++++++---- .../session/hooks/use-prompt-actions/index.ts | 12 +++++- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index ebef5492070f..57fc89f40d03 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -249,6 +249,15 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { } if (busy) { + // Don't re-arm busy from a stale session.info if the user + // just clicked Stop (interrupted=true). The backend's + // cooperative interrupt may not have propagated yet, so + // running is still true in the heartbeat. The turn's + // finally block will emit running=false to clear busy. + if (state.interrupted) { + return state + } + return { ...state, busy, @@ -307,14 +316,27 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { triggerHaptic('streamStart') } - updateSessionState(sessionId, state => ({ - ...state, - busy: true, - awaitingResponse: true, - sawAssistantPayload: false, - interrupted: false, - turnStartedAt: Date.now() - })) + updateSessionState(sessionId, state => { + // If the user clicked Stop (cancelRun set interrupted=true), don't + // let a stale message.start from a chained turn (goal follow-up, + // completion drain) or an in-flight LLM response re-arm busy. + // The interrupt is user intent — the backend's cooperative cancel + // may not have propagated yet, so its events are stale. The turn's + // finally block will emit session.info with running=false to clear + // busy for real once the agent loop actually exits. + if (state.interrupted) { + return state + } + + return { + ...state, + busy: true, + awaitingResponse: true, + sawAssistantPayload: false, + interrupted: false, + turnStartedAt: Date.now() + } + }) if (isActiveEvent) { setTurnStartedAt(Date.now()) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 421c90716a03..f42fdff6b301 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -514,7 +514,16 @@ export function usePromptActions({ ) const cancelRun = useCallback(async () => { - const sessionId = activeSessionId || activeSessionIdRef.current + // Read from the ref, not the closure-captured `activeSessionId`. The + // actions bag is a stable ref mutated in place (Object.assign on each + // ContribWiring render), and ChatRoutesSurface is memoized on that stable + // ref — so it does NOT re-render when activeSessionId changes, which means + // the ChatView element's onCancel prop holds a stale cancelRun closure. + // The closure's `activeSessionId` can be a previous session's id (or null + // from a new-chat draft), sending session.interrupt to the wrong session. + // The ref is updated via useEffect on every activeSessionId change, so it + // always reflects the current session — same pattern submitText uses. + const sessionId = activeSessionIdRef.current const releaseBusy = () => { setMutableRef(busyRef, false) @@ -589,7 +598,6 @@ export function usePromptActions({ notifyError(stopError, copy.stopFailed) } }, [ - activeSessionId, activeSessionIdRef, busyRef, copy.stopFailed,