feat(desktop): persistent needs-input indicator + icon button consolidation

Replace the background-clarify toast (expired on alt-tab, easy to miss) with a
persistent, glowing amber "needs input" dot on the session's sidebar row,
driven off a new ClientSessionState.needsInput flag mirrored into a
$attentionSessionIds store. The flag is set on clarify.request and cleared the
moment the turn resumes (tool.complete) or ends.

Also: redesign the clarify tool UI (borderless choices, pseudo-radio dots,
right-aligned checkmark, arc border, tighter padding), make Button the single
source of icon-button styling (4px radius, new icon-titlebar variant, titlebar
buttons rendered polymorphically via asChild, Codicons throughout), put the
file-tree refresh action first, and .trim() pasted composer text.
This commit is contained in:
Brooklyn Nicholson
2026-06-03 21:44:30 -05:00
parent 72f556dfc4
commit 35a750eedd
14 changed files with 262 additions and 130 deletions
@@ -8,7 +8,7 @@ import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { triggerHaptic } from '@/lib/haptics'
import { HelpCircle, Loader2, PencilLine } from '@/lib/icons'
import { Check, HelpCircle, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
@@ -74,6 +74,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const [typing, setTyping] = useState(false)
const [draft, setDraft] = useState('')
const [submitting, setSubmitting] = useState(false)
const [selectedChoice, setSelectedChoice] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Race: tool.start fires a tick before clarify.request, so request_id
@@ -140,71 +141,59 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
[draft, respond]
)
const handleChoiceKey = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (typing || submitting) {
return
}
const numeric = Number.parseInt(event.key, 10)
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= choices.length) {
event.preventDefault()
void respond(choices[numeric - 1]!)
}
},
[choices, respond, submitting, typing]
)
return (
<div
className={cn(
'mb-3 mt-2 grid gap-3 rounded-xl border border-border/70 bg-card/40 px-4 py-3.5 text-sm',
'shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
)}
className="relative mb-3 mt-2 grid gap-2 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]"
data-slot="clarify-inline"
>
<div className="flex items-start gap-2.5">
<span aria-hidden className="arc-border" />
<div className="flex items-center gap-2.5">
<span
aria-hidden
className="mt-0.5 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"
className="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>
<div className="grid flex-1 gap-0.5">
<span className="text-[0.6875rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Hermes is asking
</span>
<span className="whitespace-pre-wrap leading-snug text-foreground">
{question || <em className="text-muted-foreground/70">Loading question</em>}
</span>
</div>
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
{question || <em className="font-normal text-muted-foreground/70">Loading question</em>}
</span>
</div>
{!typing && hasChoices && (
<div className="grid gap-1.5" onKeyDown={handleChoiceKey} role="group">
<div className="grid gap-0.5" role="group">
{choices.map((choice, index) => (
<button
className={cn(
'group/choice flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-left text-sm text-foreground/95',
'transition-colors hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55'
'flex w-full cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm text-foreground/95',
'transition-colors hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55',
selectedChoice === choice && 'bg-accent/60'
)}
data-choice
disabled={!ready || submitting}
key={`${index}-${choice}`}
onClick={() => void respond(choice)}
onClick={() => {
setSelectedChoice(choice)
void respond(choice)
}}
type="button"
>
<span className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-[0.6875rem] font-mono tabular-nums text-muted-foreground group-hover/choice:bg-background">
{index + 1}
<span
aria-hidden
className={cn(
'grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
selectedChoice === choice ? 'border-primary' : 'border-muted-foreground/40'
)}
>
{selectedChoice === choice && <span className="size-1.5 rounded-full bg-primary" />}
</span>
<span className="flex-1 wrap-anywhere">{choice}</span>
{selectedChoice === choice && <Check aria-hidden className="size-4 shrink-0 text-primary" />}
</button>
))}
<button
className={cn(
'flex w-full items-center gap-3 rounded-lg border border-dashed border-border/60 bg-transparent px-3 py-2 text-left text-sm text-muted-foreground',
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground'
'flex w-full cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm text-muted-foreground',
'transition-colors hover:bg-accent/40 hover:text-foreground'
)}
disabled={submitting}
onClick={() => {
@@ -213,12 +202,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
}}
type="button"
>
<span
aria-hidden
className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
>
<PencilLine className="size-3" />
</span>
<span aria-hidden className="size-3.5 shrink-0 rounded-full border border-muted-foreground/40" />
<span className="flex-1">Other (type your answer)</span>
</button>
</div>
@@ -227,7 +211,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
{(typing || !hasChoices) && (
<form className="grid gap-2" onSubmit={handleSubmitFreeform}>
<Textarea
className="min-h-20 resize-y rounded-lg border-border/70 bg-background/60 text-sm"
className="min-h-20 resize-y rounded-lg border-transparent bg-accent/40 text-sm focus-visible:bg-background/60"
disabled={submitting}
onChange={event => setDraft(event.target.value)}
onKeyDown={handleTextareaKey}
@@ -270,10 +254,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
)}
{!typing && hasChoices && (
<div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85">
<span>1{choices.length} to pick</span>
<div className="flex justify-end">
<button
className="bg-transparent text-muted-foreground/85 underline-offset-4 decoration-current/20 hover:text-foreground hover:underline disabled:opacity-50"
className="cursor-pointer bg-transparent text-[0.6875rem] text-muted-foreground/70 underline-offset-4 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:opacity-50"
disabled={!ready || submitting}
onClick={() => void respond('')}
type="button"
+5 -4
View File
@@ -23,10 +23,11 @@ const buttonVariants = cva(
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10'
icon: 'size-9 rounded-[4px]',
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8 rounded-[4px]',
'icon-lg': 'size-10 rounded-[4px]',
'icon-titlebar': 'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]'
}
},
defaultVariants: {