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/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index 9c0e918cf74..d5f5b0de511 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -1,21 +1,32 @@ 'use client' -import { type ToolCallMessagePartProps } from '@assistant-ui/react' +import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type ComponentProps, type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react' +import { + type ComponentProps, + type FormEvent, + type KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react' import { ToolFallback } from '@/components/assistant-ui/tool-fallback' import { Button } from '@/components/ui/button' -import { KbdCombo } from '@/components/ui/kbd' +import { Kbd } from '@/components/ui/kbd' import { Textarea } from '@/components/ui/textarea' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { Check, HelpCircle, Loader2 } from '@/lib/icons' +import { Loader2, MessageQuestion } from '@/lib/icons' import { cn } from '@/lib/utils' import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' +import { selectMessageRunning } from './tool-fallback-model' + interface ClarifyArgs { question?: string choices?: string[] | null @@ -35,40 +46,64 @@ function readClarifyArgs(args: unknown): ClarifyArgs { } } -// Choice and "Other" rows share a layout; only color/hover differs. -const OPTION_ROW_CLASS = 'flex w-full items-start gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors' +// Each option (and "Other") is keyed A, B, C… so it can be picked by pressing +// that letter — the badge doubles as the shortcut hint. +const letterFor = (index: number): string => String.fromCharCode(65 + index) +// Choice and "Other" rows share a layout; only color differs. Mirrors a tool +// row's compact rhythm so the panel reads as part of the transcript. +const OPTION_ROW_CLASS = + 'flex w-full items-start gap-2 rounded-[0.25rem] px-1.5 py-1 text-left disabled:cursor-not-allowed disabled:opacity-50' + +// Content-sizing freeform field (CSS `field-sizing` — same primitive as the +// commit bar and search field): starts at one line, grows with what's typed, +// and never reflows the panel when focused. Bare so the "Other" row matches the +// choice rows above it. +const FREEFORM_INPUT_CLASS = + 'field-sizing-content max-h-40 min-h-0 w-full resize-none bg-transparent p-0 leading-(--conversation-line-height) text-(--ui-text-primary) outline-none placeholder:text-(--ui-text-tertiary) disabled:opacity-50' + +// Quiet inline panel that matches the surrounding tool rows: a single hairline +// border in the shared stroke token, a soft surface fill, and a faint primary +// accent that signals "this one needs you" without the loud animated ring. const CLARIFY_SHELL_CLASS = - 'relative mb-3 mt-2 rounded-[0.5rem] border border-border/70 bg-card/40 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]' + 'my-1.5 rounded-md border border-primary/20 bg-(--ui-chat-surface-background) text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)' function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) { return (
- {children}
) } -function RadioDot({ selected }: { selected: boolean }) { +// Selection lives on the letter badge alone — a solid primary fill — not the +// whole row, which stays a quiet hover target. `preview` is the focused-but-empty +// "Other" state: the badge outlines in primary to show it's armed, then fills +// once a value is actually typed. +function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean; selected: boolean }) { return ( - - {selected && } - + {char} + ) } export const ClarifyTool = (props: ToolCallMessagePartProps) => { - const isPending = props.result === undefined + const messageRunning = useAuiState(selectMessageRunning) + + // Only the live, still-blocked turn shows the interactive panel. Once the + // message stops running — answered, the turn ended, or the user hit Stop — + // fall back to the standard tool block so the Q/A settles like every other + // row instead of stranding a dead prompt the gateway no longer waits on. + const isPending = messageRunning && props.result === undefined - // Once Hermes records an answer, fall back to the standard tool block so - // the past Q/A renders consistently with every other tool in the thread. if (!isPending) { return } @@ -104,10 +139,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { const hasChoices = choices.length > 0 - const [typing, setTyping] = useState(false) const [draft, setDraft] = useState('') const [submitting, setSubmitting] = useState(false) const [selectedChoice, setSelectedChoice] = useState(null) + const [otherFocused, setOtherFocused] = useState(false) const textareaRef = useRef(null) // Race: tool.start fires a tick before clarify.request, so request_id @@ -150,6 +185,30 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { [copy.gatewayDisconnected, copy.notReady, copy.sendFailed, gateway, matchingRequest, ready] ) + const trimmedDraft = draft.trim() + // The answer is whichever input is active: a picked choice, or typed text. + // Picking a choice no longer fires immediately — it selects, then the user + // confirms with Continue (or Enter from the field). + const pendingAnswer = selectedChoice ?? (trimmedDraft || null) + + const selectChoice = useCallback((choice: string) => { + // Picking a choice and typing are mutually exclusive answers. + setDraft('') + setSelectedChoice(choice) + }, []) + + const submitAnswer = useCallback(() => { + if (selectedChoice !== null) { + void respond(selectedChoice) + + return + } + + if (trimmedDraft) { + void respond(trimmedDraft) + } + }, [respond, selectedChoice, trimmedDraft]) + const handleTextareaKey = useCallback( (event: KeyboardEvent) => { if (event.nativeEvent.isComposing) { @@ -158,147 +217,166 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() - const trimmed = draft.trim() - - if (trimmed) { - void respond(trimmed) - } + submitAnswer() } }, - [draft, respond] + [submitAnswer] ) - const handleSubmitFreeform = useCallback( + const handleSubmit = useCallback( (event: FormEvent) => { event.preventDefault() - const trimmed = draft.trim() - - if (trimmed) { - void respond(trimmed) - } + submitAnswer() }, - [draft, respond] + [submitAnswer] ) + // Letter shortcuts: A/B/C… pick the matching option, the trailing letter jumps + // into "Other", and Enter confirms the current pick. Stands down whenever a + // field is focused (you're typing, not navigating) so it never eats keystrokes + // meant for the composer or the Other box. + useEffect(() => { + if (!ready || !hasChoices || submitting) { + return + } + + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey || event.defaultPrevented) { + return + } + + const active = document.activeElement as HTMLElement | null + + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { + return + } + + const key = event.key.toLowerCase() + + if (key.length === 1 && key >= 'a' && key <= 'z') { + const index = key.charCodeAt(0) - 97 + + if (index < choices.length) { + event.preventDefault() + selectChoice(choices[index]) + } else if (index === choices.length) { + event.preventDefault() + textareaRef.current?.focus() + } + + return + } + + if (event.key === 'Enter' && pendingAnswer) { + event.preventDefault() + submitAnswer() + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, [choices, hasChoices, pendingAnswer, ready, selectChoice, submitAnswer, submitting]) + if (loading) { return ( - - + + ) } + const onDraftChange = (value: string) => { + setDraft(value) + + // Typing is its own answer — drop any picked choice so the two inputs can't + // both look selected. + if (value.trim()) { + setSelectedChoice(null) + } + } + return ( - -
- - - - {question} + +
+ {question} +
- {!typing && hasChoices && ( -
- {choices.map((choice, index) => ( - - ))} - -
- )} - - {(typing || !hasChoices) && ( -
+ + {hasChoices ? ( +
+ {choices.map((choice, index) => ( + + ))} + {/* "Other" is an inline content-sizing field, not a separate view. */} +