diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx index dd83d04f0c0fc..1619288b494c1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { $clarifyRequests } from '@/store/clarify' import type { ComposerAttachment } from '@/store/composer' import { $gateway } from '@/store/gateway' +import { clearAllPrompts, hasBlockingPromptRequest, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts' import { useComposerSubmit } from './use-composer-submit' @@ -263,3 +264,98 @@ describe('useComposerSubmit with a clarify parked on the session', () => { expect($clarifyRequests.get()['other-session']).toBeDefined() }) }) + +describe('useComposerSubmit with a blocking prompt parked on the session', () => { + // Typing cannot answer approval/sudo/secret prompts, so the busy submit must + // route text to the QUEUE — a steer would sit undelivered behind the blocked + // tool batch, and interrupting to force it through resolves the prompt empty + // and ends the turn as "Operation interrupted." with the message lost. + afterEach(() => { + cleanup() + clearAllPrompts() + vi.restoreAllMocks() + }) + + it('queues a busy text follow-up instead of steering while an approval is pending', () => { + setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous', sessionId: 'runtime-session' }) + + const { hook, onCancel, onSteer, queueCurrentDraft } = renderSubmitHook({ + busy: true, + text: 'and also fix the padding' + }) + + act(() => { + hook.result.current.submitDraft() + }) + + expect(queueCurrentDraft).toHaveBeenCalledTimes(1) + expect(onSteer).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + }) + + it('queues while a sudo prompt is pending', () => { + setSudoRequest({ requestId: 'sudo-1', sessionId: 'runtime-session' }) + + const { hook, onSteer, queueCurrentDraft } = renderSubmitHook({ busy: true, text: 'next thing' }) + + act(() => { + hook.result.current.submitDraft() + }) + + expect(queueCurrentDraft).toHaveBeenCalledTimes(1) + expect(onSteer).not.toHaveBeenCalled() + }) + + it('queues while a secret prompt is pending', () => { + setSecretRequest({ envVar: 'API_KEY', prompt: 'key?', requestId: 'sec-1', sessionId: 'runtime-session' }) + + const { hook, onSteer, queueCurrentDraft } = renderSubmitHook({ busy: true, text: 'next thing' }) + + act(() => { + hook.result.current.submitDraft() + }) + + expect(queueCurrentDraft).toHaveBeenCalledTimes(1) + expect(onSteer).not.toHaveBeenCalled() + }) + + it('still runs slash commands inline', async () => { + setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous', sessionId: 'runtime-session' }) + + const { hook, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ busy: true, text: '/status' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('/status', { composerScope: 'stored-session' })) + expect(queueCurrentDraft).not.toHaveBeenCalled() + expect(onSteer).not.toHaveBeenCalled() + }) + + it("ignores another session's blocking prompt and still steers", async () => { + setApprovalRequest({ command: 'ls', description: 'other', sessionId: 'other-session' }) + + const { hook, onSteer, queueCurrentDraft } = renderSubmitHook({ busy: true, text: 'change course' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course')) + expect(queueCurrentDraft).not.toHaveBeenCalled() + }) + + it('leaves the prompt pending — queueing must not resolve or dismiss it', () => { + setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous', sessionId: 'runtime-session' }) + + const { hook } = renderSubmitHook({ busy: true, text: 'follow-up' }) + + act(() => { + hook.result.current.submitDraft() + }) + + // The approval card is still the turn's owner; only its own buttons answer it. + expect(hasBlockingPromptRequest('runtime-session')).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index 9d34ddfca3f13..47f9616a08cd3 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -6,6 +6,7 @@ import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify' import { clearSessionDraft, type ComposerAttachment } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue' +import { hasBlockingPromptRequest } from '@/store/prompts' import { cloneAttachments, type QueueEditState } from '../composer-utils' import { onComposerSubmitRequest } from '../focus' @@ -161,6 +162,15 @@ export function useComposerSubmit({ void skipClarifyRequest(sessionId) } + // Approval / sudo / secret prompts also park the turn inside a tool batch, + // but typing CANNOT answer them (no message text approves a command or + // supplies a password), so there is no skip-and-steer path: a steer would + // sit undelivered behind the blocked prompt, and stopping the turn to force + // it through resolves the prompt to empty and ends the turn as "Operation + // interrupted." — the message looks eaten. Queue the words as the next turn + // instead; the prompt stays answerable and the queue drains on settle. + const blockingPrompt = !queueEdit && hasBlockingPromptRequest(sessionId) + if (queueEdit) { exitQueuedEdit('save') } else if (busy) { @@ -175,14 +185,16 @@ export function useComposerSubmit({ triggerHaptic('submit') clearDraft() dispatchSubmit(text) - } else if (!compacting && !attachments.length && text.trim()) { + } else if (!compacting && !blockingPrompt && !attachments.length && text.trim()) { // Cursor-style stop-and-correct: interrupt the live turn and redirect // it with this text. redirect() preserves the shown reasoning/work; if // the turn already ended, steerDraft re-queues so nothing is lost. steerDraft() } else if (payloadPresent) { // Attachments can't ride a redirect (no tool-result image carriage) — - // queue the whole payload for the next turn. + // queue the whole payload for the next turn. Same for a turn parked on + // an approval/sudo/secret prompt: a steer can't reach the model while + // the tool batch is blocked, so the message runs as the next turn. queueCurrentDraft() } else { // Stop button (the only way to reach here while busy with an empty diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 5d490eb61c412..6b22c2fda5420 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -19,6 +19,7 @@ import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } f import { POPOUT_WIDTH_REM } from '@/store/composer-popout' import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue' import { $hudMode } from '@/store/hud' +import { sessionBlockingPrompt } from '@/store/prompts' import { toggleReview } from '@/store/review' import { $gatewayState } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' @@ -156,6 +157,10 @@ export function ChatBar({ // 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(scope.$awaitingInput) + // Parked on an approval/sudo/secret prompt: typing can't answer those, so the + // busy submit routes text to the queue instead of a steer (which would sit + // undelivered behind the blocked tool batch). Drives the button affordance. + const blockingPrompt = useStore(useMemo(() => sessionBlockingPrompt(sessionId ?? null), [sessionId])) const activeQueueSessionKey = queueSessionKey || sessionId || null // Status items (subagents, background processes) are keyed by the RUNTIME @@ -328,7 +333,9 @@ export function ChatBar({ // Steer only makes sense mid-turn, text-only (the gateway can't carry images // into a tool result) and never for a slash command (those execute inline). - const canSteer = busy && !compacting && !!onSteer && attachments.length === 0 && isSteerableText + // A blocking prompt (approval/sudo/secret) also rules it out: the tool batch + // is parked on the user, so a steer can't reach the model — text queues. + const canSteer = busy && !compacting && !blockingPrompt && !!onSteer && attachments.length === 0 && isSteerableText // While busy: text redirects the live turn (Cursor-style stop-and-correct), // attachments queue for the next turn, an empty composer stops. diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts index f15e4b33ffb37..0efe97515a993 100644 --- a/apps/desktop/src/store/prompts.ts +++ b/apps/desktop/src/store/prompts.ts @@ -147,6 +147,28 @@ export const $activeSessionAwaitingInput = computed( (clarify, approval, sudo, secret) => Boolean(clarify || approval || sudo || secret) ) +/** True when `sessionId` is parked on a blocking prompt that typing cannot + * answer (approval / sudo / secret). Clarify is deliberately excluded: typing + * a real message IS an answer to a clarify ("none of these" — the composer + * skips it and routes the words), but no message text can approve a command + * or supply a password. Imperative read — the composer checks this on Enter, + * not on every render. */ +export const hasBlockingPromptRequest = (sessionId: string | null | undefined): boolean => { + const key = keyFor(sessionId) + + return Boolean(approval.$all.get()[key] || sudo.$all.get()[key] || secret.$all.get()[key]) +} + +/** Reactive twin of `hasBlockingPromptRequest`, for the composer's busy-action + * affordance (the primary button must advertise queue, not steer, while the + * turn is parked on a prompt Enter can't answer). */ +export const sessionBlockingPrompt = (sessionId: string | null) => + computed([approval.$all, sudo.$all, secret.$all], (approvals, sudos, secrets) => { + const key = keyFor(sessionId) + + return Boolean(approvals[key] || sudos[key] || secrets[key]) + }) + /** Per-session `awaitingInput` — the tile composer's counterpart of * `$activeSessionAwaitingInput` (same sources, fixed session instead of the * active one). */