fix(desktop): treat a pending prompt as paused-on-you, not working

A clarify/approval/sudo/secret prompt blocks the turn on the user, but the UI
treated it as an in-flight turn: the "thinking" timer kept ticking and Esc
interrupted the run — discarding a question you might want to come back to. Add
$activeSessionAwaitingInput (the pet's awaitingInput concept, scoped to the
active session) and use it to suppress the stall indicator and disarm Esc while a
prompt waits. Clear the session's prompts (and needsInput) on Stop and on turn
end so a resolved/aborted turn can't leave a dead panel or a stuck "needs input"
dot.
This commit is contained in:
Brooklyn Nicholson 2026-06-26 03:55:34 -05:00
parent 8559246bfb
commit 54b50037e1
6 changed files with 69 additions and 5 deletions

View file

@ -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
}

View file

@ -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)
}

View file

@ -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 })

View file

@ -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) {

View file

@ -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)
})
})

View file

@ -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 {