mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
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:
commit
ca82d0accc
13 changed files with 285 additions and 151 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -2151,10 +2151,8 @@ export const ja = defineLocale({
|
|||
loadingQuestion: '質問を読み込み中…',
|
||||
other: 'その他(回答を入力)',
|
||||
placeholder: '回答を入力…',
|
||||
shortcutSuffix: ' で送信',
|
||||
back: '戻る',
|
||||
skip: 'スキップ',
|
||||
send: '送信'
|
||||
continueLabel: '続行'
|
||||
},
|
||||
tool: {
|
||||
code: 'コード',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2085,10 +2085,8 @@ export const zhHant = defineLocale({
|
|||
loadingQuestion: '正在載入問題…',
|
||||
other: '其他(輸入您的答案)',
|
||||
placeholder: '輸入您的答案…',
|
||||
shortcutSuffix: ' 傳送',
|
||||
back: '返回',
|
||||
skip: '略過',
|
||||
send: '傳送'
|
||||
continueLabel: '繼續'
|
||||
},
|
||||
tool: {
|
||||
code: '程式碼',
|
||||
|
|
|
|||
|
|
@ -2198,10 +2198,8 @@ export const zh: Translations = {
|
|||
loadingQuestion: '正在加载问题…',
|
||||
other: '其他 (输入你的答案)',
|
||||
placeholder: '输入你的答案…',
|
||||
shortcutSuffix: ' 发送',
|
||||
back: '返回',
|
||||
skip: '跳过',
|
||||
send: '发送'
|
||||
continueLabel: '继续'
|
||||
},
|
||||
tool: {
|
||||
code: '代码',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue