diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 890ba02840c..379b6732a88 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -63,6 +63,7 @@ import { $statusItemsBySession } from '@/store/composer-status' import { notify } from '@/store/notifications' import { $previewStatusBySession } from '@/store/preview-status' import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' +import { $activeSessionAwaitingInput } from '@/store/prompts' import { toggleReview } from '@/store/review' import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' @@ -229,6 +230,11 @@ export function ChatBar({ const statusItemsBySession = useStore($statusItemsBySession) const previewStatusBySession = useStore($previewStatusBySession) const scrolledUp = useStore($threadScrolledUp) + // The turn is parked on the user (clarify / approval / sudo / secret). Esc must + // not interrupt it — there's nothing actively running to stop, and stopping + // would discard a question the user may want to come back to. The blocking + // prompt owns its own dismissal (Skip, Reject, dialog close). + const awaitingInput = useStore($activeSessionAwaitingInput) // Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N // tiny window, subagent watch windows) always start docked and can't pop out: // a floating composer makes no sense in a single-session side window, and it @@ -1214,8 +1220,10 @@ export function ChatBar({ return } - // Otherwise Esc interrupts the running turn (Stop-button parity). - if (busy) { + // Otherwise Esc interrupts the running turn (Stop-button parity) — unless + // the turn is parked waiting on the user, where Esc must not discard the + // pending prompt. + if (busy && !awaitingInput) { event.preventDefault() triggerHaptic('cancel') void Promise.resolve(onCancel()) @@ -1779,7 +1787,10 @@ export function ChatBar({ const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {}) escCancelRef.current = (event: globalThis.KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented || !busy) { + // `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret + // prompt, which owns Esc (or is meant to persist) — never cancel the stream + // out from under it. + if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) { return } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index e4d9d03430b..09ba7591716 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -27,7 +27,7 @@ import { import { triggerHaptic } from '@/lib/haptics' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { parseTodos } from '@/lib/todos' -import { setClarifyRequest } from '@/store/clarify' +import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' import { setSessionCompacting } from '@/store/compaction' import { refreshBackgroundProcesses } from '@/store/composer-status' import { $gateway } from '@/store/gateway' @@ -913,6 +913,7 @@ export function useMessageStream({ // session so a background turn finishing can't wipe the active chat's // prompt, and vice versa. clearAllPrompts(sessionId) + clearClarifyRequest(undefined, sessionId) setSessionCompacting(sessionId, false) flushQueuedDeltas(sessionId) @@ -1185,6 +1186,7 @@ export function useMessageStream({ // the failed turn (same intent as the message.complete clear). if (sessionId) { clearAllPrompts(sessionId) + clearClarifyRequest(undefined, sessionId) setSessionCompacting(sessionId, false) compactedTurnRef.current.delete(sessionId) } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 513043d01b8..6e2829d6178 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -27,6 +27,7 @@ import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { setSessionYolo } from '@/lib/yolo-session' +import { clearClarifyRequest } from '@/store/clarify' import { openCommandPalettePage } from '@/store/command-palette' import { $composerAttachments, @@ -44,6 +45,7 @@ import { setPetScale } from '@/store/pet-gallery' import { $petGenInput, openPetGenerate } from '@/store/pet-generate' import { clearPreviewArtifacts } from '@/store/preview-status' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { clearAllPrompts } from '@/store/prompts' import { $busy, $connection, @@ -1535,6 +1537,7 @@ export function usePromptActions({ awaitingResponse: false, streamId: null, pendingBranchGroup: null, + needsInput: false, interrupted: true } }) @@ -1542,6 +1545,12 @@ export function usePromptActions({ clearSessionTodos(sessionId) clearSessionSubagents(sessionId) resetSessionBackground(sessionId) + // Stop ends the turn, so the gateway is no longer blocked on any prompt it + // raised. Drop this session's pending clarify / approval / sudo / secret so + // a dead panel (and the sidebar "needs input" dot) can't linger and accept + // an answer the backend will reject. + clearAllPrompts(sessionId) + clearClarifyRequest(undefined, sessionId) try { await requestGateway('session.interrupt', { session_id: sessionId }) diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index c087c5986b8..706345f708a 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -100,6 +100,7 @@ import { $backgroundResume } from '@/store/background-delegation' import { $compactionActive } from '@/store/compaction' import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' +import { $activeSessionAwaitingInput } from '@/store/prompts' import { $connection } from '@/store/session' import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll' import { $voicePlayback } from '@/store/voice-playback' @@ -485,6 +486,10 @@ const StreamStallIndicator: FC = () => { const [stalled, setStalled] = useState(false) const compacting = useStore($compactionActive) + // A pending clarify / approval / sudo / secret means the turn is paused on the + // user, not working — so don't resurrect the "thinking" timer while they + // decide (matches the pet's awaitingInput pose taking priority over busy). + const awaitingInput = useStore($activeSessionAwaitingInput) useEffect(() => { setStalled(false) @@ -493,7 +498,7 @@ const StreamStallIndicator: FC = () => { return () => window.clearTimeout(id) }, [activity]) - const active = stalled || compacting + const active = (stalled || compacting) && !awaitingInput const elapsed = useElapsedSeconds(active) if (!active) { diff --git a/apps/desktop/src/store/prompts.test.ts b/apps/desktop/src/store/prompts.test.ts index 57f1985e7a1..a761adf6f25 100644 --- a/apps/desktop/src/store/prompts.test.ts +++ b/apps/desktop/src/store/prompts.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { clearClarifyRequest, setClarifyRequest } from './clarify' import { + $activeSessionAwaitingInput, $approvalRequest, $secretRequest, $sudoRequest, @@ -22,6 +24,7 @@ beforeEach(() => { afterEach(() => { clearAllPrompts() + clearClarifyRequest() $activeSessionId.set(null) }) @@ -130,3 +133,26 @@ describe('clearAllPrompts', () => { expect($approvalRequest.get()?.command).toBe('y') }) }) + +describe('$activeSessionAwaitingInput', () => { + it('is true while any blocking prompt (clarify or approval/sudo/secret) is parked on the active session', () => { + expect($activeSessionAwaitingInput.get()).toBe(false) + + setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' }) + expect($activeSessionAwaitingInput.get()).toBe(true) + + clearApprovalRequest('s1') + expect($activeSessionAwaitingInput.get()).toBe(false) + + setClarifyRequest({ choices: null, question: 'q', requestId: 'c1', sessionId: 's1' }) + expect($activeSessionAwaitingInput.get()).toBe(true) + }) + + it('ignores a prompt parked on a background session', () => { + setSudoRequest({ requestId: 'r', sessionId: 's2' }) + expect($activeSessionAwaitingInput.get()).toBe(false) + + $activeSessionId.set('s2') + expect($activeSessionAwaitingInput.get()).toBe(true) + }) +}) diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts index 2d7a74baa8b..285abad10f1 100644 --- a/apps/desktop/src/store/prompts.ts +++ b/apps/desktop/src/store/prompts.ts @@ -1,5 +1,6 @@ import { atom, computed, type ReadableAtom } from 'nanostores' +import { $clarifyRequest } from './clarify' import { $activeSessionId } from './session' // Blocking interactive prompts the gateway raises mid-turn. Each maps to a @@ -110,6 +111,16 @@ export const $secretRequest = secret.$active export const setSecretRequest = secret.set export const clearSecretRequest = secret.clear +// True when the active session is blocked on the user (clarify question or an +// approval / sudo / secret prompt). Mirrors the pet's `awaitingInput` concept +// (agent/pet/state.py): the turn is paused on you, not working — so callers can +// suppress "thinking" indicators and the Esc-to-interrupt shortcut while you +// decide, instead of treating the wait as an in-flight turn. +export const $activeSessionAwaitingInput = computed( + [$clarifyRequest, $approvalRequest, $sudoRequest, $secretRequest], + (clarify, approval, sudo, secret) => Boolean(clarify || approval || sudo || secret) +) + // Drop in-flight prompts for `sessionId` (a turn ended) across all three kinds — // or every parked prompt when no session is given (global reset / tests). export function clearAllPrompts(sessionId?: string | null): void {