From 8559246bfb043df3ec0d6e883c9b76527e7abb17 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 26 Jun 2026 03:55:29 -0500 Subject: [PATCH 1/2] feat(desktop): rebuild the clarify prompt to match the chat UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline clarify panel used its own card tokens, an animated ring, and oversized spacing — out of step with every other tool row. Rebuild it on the shared --ui-*/--conversation-* tokens: a compact panel, letter-key badges (A/B/C…) that double as a/b/c… shortcuts, an inline content-sizing "Other" field (CSS field-sizing — no view swap, no layout shift on focus), and a Continue button so picking an option selects rather than auto-sends. Selection lives on the letter badge alone (solid primary; outlined while Other is focused-but-empty). Also settle the panel into the standard tool block once the turn stops running, so a stopped turn no longer strands a live, unanswerable prompt. --- .../components/assistant-ui/clarify-tool.tsx | 340 +++++++++++------- apps/desktop/src/i18n/en.ts | 4 +- apps/desktop/src/i18n/ja.ts | 4 +- apps/desktop/src/i18n/types.ts | 4 +- apps/desktop/src/i18n/zh-hant.ts | 4 +- apps/desktop/src/i18n/zh.ts | 4 +- apps/desktop/src/lib/icons.ts | 2 + 7 files changed, 216 insertions(+), 146 deletions(-) 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. */} +