Merge pull request #52993 from NousResearch/bb/desktop-clarify-redesign

feat(desktop): redesign the clarify prompt + fix its awaiting-input states
This commit is contained in:
brooklyn! 2026-06-26 03:57:43 -05:00 committed by GitHub
commit ca82d0accc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 285 additions and 151 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

@ -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 (
<div className={cn(CLARIFY_SHELL_CLASS, className)} data-slot="clarify-inline" {...props}>
<span aria-hidden className="arc-border" />
{children}
</div>
)
}
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 (
<span
aria-hidden
<Kbd
className={cn(
'mt-0.5 grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
selected ? 'border-primary' : 'border-muted-foreground/40'
'mt-px',
selected && 'border-primary bg-primary text-white shadow-none',
!selected && preview && 'border-primary text-primary shadow-none'
)}
size="sm"
>
{selected && <span className="size-1.5 rounded-full bg-primary" />}
</span>
{char}
</Kbd>
)
}
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 <ToolFallback {...props} />
}
@ -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<string | null>(null)
const [otherFocused, setOtherFocused] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(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<HTMLTextAreaElement>) => {
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<HTMLFormElement>) => {
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 (
<ClarifyShell
aria-label={copy.loadingQuestion}
className="grid min-h-24 place-items-center px-3 py-6"
role="status"
>
<Loader2 aria-hidden className="size-5 animate-spin text-muted-foreground/80" />
<ClarifyShell aria-label={copy.loadingQuestion} className="grid min-h-12 place-items-center px-2.5 py-3" role="status">
<Loader2 aria-hidden className="size-4 animate-spin text-(--ui-text-tertiary)" />
</ClarifyShell>
)
}
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 (
<ClarifyShell className="grid gap-6 px-3 py-2.5">
<div className="flex items-start gap-2.5">
<span
aria-hidden
className="mt-px grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
>
<HelpCircle className="size-3.5" />
</span>
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">{question}</span>
<ClarifyShell className="grid gap-2 px-2.5 py-2">
<div className="flex items-start gap-2">
<span className="flex-1 whitespace-pre-wrap font-medium leading-(--conversation-line-height)">{question}</span>
<MessageQuestion aria-hidden className="mt-px size-4 shrink-0 text-(--ui-text-tertiary)" />
</div>
{!typing && hasChoices && (
<div className="grid gap-0.5" role="group">
{choices.map((choice, index) => (
<button
className={cn(
OPTION_ROW_CLASS,
'text-foreground/95 hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55',
selectedChoice === choice && 'bg-accent/60'
)}
data-choice
disabled={submitting}
key={`${index}-${choice}`}
onClick={() => {
setSelectedChoice(choice)
void respond(choice)
}}
type="button"
>
<RadioDot selected={selectedChoice === choice} />
<span className="flex-1 wrap-anywhere">{choice}</span>
{selectedChoice === choice && <Check aria-hidden className="mt-0.5 size-4 shrink-0 text-primary" />}
</button>
))}
<button
className={cn(OPTION_ROW_CLASS, 'text-muted-foreground hover:bg-accent/40 hover:text-foreground')}
disabled={submitting}
onClick={() => {
setTyping(true)
window.setTimeout(() => textareaRef.current?.focus({ preventScroll: true }), 0)
}}
type="button"
>
<RadioDot selected={false} />
<span className="flex-1">{copy.other}</span>
</button>
</div>
)}
{(typing || !hasChoices) && (
<form className="grid gap-2" onSubmit={handleSubmitFreeform}>
<form className="grid gap-2" onSubmit={handleSubmit}>
{hasChoices ? (
<div className="grid gap-px" role="group">
{choices.map((choice, index) => (
<button
className={cn(
OPTION_ROW_CLASS,
'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
selectedChoice === choice && 'text-(--ui-text-primary)'
)}
data-choice
disabled={submitting}
key={`${index}-${choice}`}
onClick={() => selectChoice(choice)}
type="button"
>
<KeyBadge char={letterFor(index)} selected={selectedChoice === choice} />
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
))}
{/* "Other" is an inline content-sizing field, not a separate view. */}
<label className={cn(OPTION_ROW_CLASS, 'focus-within:bg-(--chrome-action-hover)')}>
<KeyBadge char={letterFor(choices.length)} preview={otherFocused} selected={Boolean(trimmedDraft)} />
<textarea
className={FREEFORM_INPUT_CLASS}
disabled={submitting}
onBlur={() => setOtherFocused(false)}
onChange={event => onDraftChange(event.target.value)}
// Focusing "Other" is a switch to typing your own answer, so it
// deselects any picked choice — a chosen option and an active
// Other field can never both look selected.
onFocus={() => {
setSelectedChoice(null)
setOtherFocused(true)
}}
onKeyDown={handleTextareaKey}
placeholder={copy.other}
ref={textareaRef}
rows={1}
value={draft}
/>
</label>
</div>
) : (
<Textarea
className="min-h-20 resize-y rounded-lg border-transparent bg-accent/40 text-sm focus-visible:bg-background/60"
className={FREEFORM_INPUT_CLASS}
disabled={submitting}
onChange={event => setDraft(event.target.value)}
onChange={event => onDraftChange(event.target.value)}
onKeyDown={handleTextareaKey}
placeholder={copy.placeholder}
ref={textareaRef}
rows={1}
value={draft}
/>
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-muted-foreground/85">
<KbdCombo combo="enter" size="sm" />
<KbdCombo combo="shift+enter" size="sm" />
{t.composer.hotkeyDescs['composer.sendNewline']}
</span>
<div className="flex items-center gap-1.5">
{hasChoices && (
<Button
disabled={submitting}
onClick={() => {
setTyping(false)
setDraft('')
}}
size="sm"
type="button"
variant="ghost"
>
{copy.back}
</Button>
)}
<Button disabled={submitting} onClick={() => void respond('')} size="sm" type="button" variant="ghost">
{copy.skip}
</Button>
<Button disabled={submitting || !draft.trim()} size="sm" type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.send}
</Button>
</div>
</div>
</form>
)}
)}
{!typing && hasChoices && (
<div className="flex justify-end">
<Button
className="-mr-2"
disabled={submitting}
onClick={() => void respond('')}
size="xs"
type="button"
variant="text"
>
<div className="flex items-center justify-end gap-1">
<Button disabled={submitting} onClick={() => void respond('')} size="xs" type="button" variant="text">
{copy.skip}
</Button>
<Button disabled={submitting || !pendingAnswer} size="xs" type="submit">
{submitting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<>
{copy.continueLabel}
<span aria-hidden className="ml-0.5 text-[0.625rem] opacity-70">
</span>
</>
)}
</Button>
</div>
)}
</form>
</ClarifyShell>
)
}

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

@ -2028,10 +2028,8 @@ export const en: Translations = {
loadingQuestion: 'Loading question…',
other: 'Other (type your answer)',
placeholder: 'Type your answer…',
shortcutSuffix: ' to send',
back: 'Back',
skip: 'Skip',
send: 'Send'
continueLabel: 'Continue'
},
tool: {
code: 'Code',

View file

@ -2151,10 +2151,8 @@ export const ja = defineLocale({
loadingQuestion: '質問を読み込み中…',
other: 'その他(回答を入力)',
placeholder: '回答を入力…',
shortcutSuffix: ' で送信',
back: '戻る',
skip: 'スキップ',
send: '送信'
continueLabel: '続行'
},
tool: {
code: 'コード',

View file

@ -1678,10 +1678,8 @@ export interface Translations {
loadingQuestion: string
other: string
placeholder: string
shortcutSuffix: string
back: string
skip: string
send: string
continueLabel: string
}
tool: {
code: string

View file

@ -2085,10 +2085,8 @@ export const zhHant = defineLocale({
loadingQuestion: '正在載入問題…',
other: '其他(輸入您的答案)',
placeholder: '輸入您的答案…',
shortcutSuffix: ' 傳送',
back: '返回',
skip: '略過',
send: '傳送'
continueLabel: '繼續'
},
tool: {
code: '程式碼',

View file

@ -2198,10 +2198,8 @@ export const zh: Translations = {
loadingQuestion: '正在加载问题…',
other: '其他 (输入你的答案)',
placeholder: '输入你的答案…',
shortcutSuffix: ' 发送',
back: '返回',
skip: '跳过',
send: '发送'
continueLabel: '继续'
},
tool: {
code: '代码',

View file

@ -57,6 +57,7 @@ import {
IconLogin as LogIn,
IconMail as Mail,
IconMessageCircle as MessageCircle,
IconMessageQuestion as MessageQuestion,
IconMessage2 as MessageSquareText,
IconMicrophone as Mic,
IconMicrophoneOff as MicOff,
@ -166,6 +167,7 @@ export {
LogIn,
Mail,
MessageCircle,
MessageQuestion,
MessageSquareText,
Mic,
MicOff,

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 {