mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
feat(desktop): rebuild the clarify prompt to match the chat UI
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.
This commit is contained in:
parent
063fe4f6ef
commit
8559246bfb
7 changed files with 216 additions and 146 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue