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

View File

@ -407,13 +407,19 @@ export function ChatBar({
return
}
const pastedText = event.clipboardData.getData('text')
// Trim surrounding whitespace so a copy that dragged along leading/trailing
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
if (!pastedText) {
event.preventDefault()
return
}
if (DATA_IMAGE_URL_RE.test(pastedText.trim())) {
if (DATA_IMAGE_URL_RE.test(pastedText)) {
event.preventDefault()
return

View File

@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
@ -6,6 +7,7 @@ import type { SessionInfo } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
@ -61,6 +63,10 @@ export function SidebarSessionRow({
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at)
const handleLabel = `Reorder ${title}`
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
const needsInput = useStore($attentionSessionIds).includes(session.id)
return (
<SessionContextMenu
@ -84,7 +90,7 @@ export function SidebarSessionRow({
style={style}
{...rest}
>
{isWorking && <span aria-hidden="true" className="arc-border" />}
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
<button
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
onClick={event => {
@ -114,16 +120,25 @@ export function SidebarSessionRow({
<span
{...dragHandleProps}
aria-label={handleLabel}
className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
className={cn(
// Scope the dot↔grabber swap to a local group so the grabber
// only reveals when hovering/focusing the handle itself, not
// anywhere on the row.
'group/handle relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
// The quest-glow box-shadow extends past the dot; let it bleed
// out instead of being clipped by this handle's overflow-hidden.
needsInput && 'overflow-visible'
)}
onClick={event => event.stopPropagation()}
>
<SidebarRowDot
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
isWorking={isWorking}
needsInput={needsInput}
/>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
@ -131,8 +146,8 @@ export function SidebarSessionRow({
/>
</span>
) : (
<span className="grid w-3.5 shrink-0 place-items-center overflow-hidden">
<SidebarRowDot isWorking={isWorking} />
<span className={cn('grid w-3.5 shrink-0 place-items-center', needsInput ? 'overflow-visible' : 'overflow-hidden')}>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
@ -169,7 +184,30 @@ export function SidebarSessionRow({
)
}
function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) {
function SidebarRowDot({
isWorking,
needsInput = false,
className
}: {
isWorking: boolean
needsInput?: boolean
className?: string
}) {
// "Needs input" wins over "working": a clarify-blocked session is technically
// still running, but the actionable state is that it's waiting on the user.
// Amber + steady (no ping) reads as "your turn", distinct from the accent
// pulse of an active turn.
if (needsInput) {
return (
<span
aria-label="Needs your input"
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
role="status"
title="Waiting for your answer"
/>
)
}
return (
<span
aria-label={isWorking ? 'Session running' : undefined}

View File

@ -31,7 +31,7 @@ interface RightSidebarTab {
}
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
{ id: 'files', label: 'File system', icon: 'files' },
{ id: 'files', label: 'File system', icon: 'list-tree' },
{ id: 'terminal', label: 'Terminal', icon: 'terminal' }
]
@ -141,23 +141,24 @@ function RightSidebarChrome({
<div className="flex items-center gap-2 px-2.5 py-1">
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
{tabs.map(tab => (
<button
<Button
aria-label={tab.label}
aria-pressed={tab.id === activeTab}
className={cn(
'grid size-6 shrink-0 place-items-center rounded-lg text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring active:bg-(--ui-control-active-background) active:text-foreground',
'data-[active=true]:bg-(--ui-control-active-background) data-[active=true]:text-foreground'
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
)}
data-active={tab.id === activeTab}
key={tab.id}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
title={tab.label}
type="button"
variant="ghost"
>
<Codicon name={tab.icon} size="0.875rem" />
</button>
</Button>
))}
</nav>
{branch && (
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
@ -178,8 +179,11 @@ interface FilesystemTabProps extends FileTreeBodyProps {
onRefresh: () => void
}
// Sidebar-specific color/hover treatment only — size, radius, cursor and the
// base focus ring come from <Button size="icon-xs">. This constant exists
// purely to share the sidebar palette + the hover-reveal behavior below.
const HEADER_ACTION_CLASS =
'size-6 shrink-0 rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-2 focus-visible:ring-sidebar-ring'
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
@ -213,11 +217,22 @@ function FilesystemTab({
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_CLASS}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon-xs"
title="Refresh tree"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
<Button
aria-label="Open folder"
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon"
size="icon-xs"
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
variant="ghost"
>
@ -228,23 +243,12 @@ function FilesystemTab({
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon"
size="icon-xs"
title="Collapse all folders"
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon"
title="Refresh tree"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</RightSidebarSectionHeader>
<FileTreeBody
collapseNonce={collapseNonce}
@ -264,7 +268,7 @@ function FilesystemTab({
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="flex h-7 shrink-0 items-center px-2">{children}</div>
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
}
interface FileTreeBodyProps {

View File

@ -531,7 +531,8 @@ export function useMessageStream({
streamId: null,
pendingBranchGroup: null,
awaitingResponse: false,
busy: false
busy: false,
needsInput: false
}
})
@ -588,7 +589,8 @@ export function useMessageStream({
pendingBranchGroup: null,
sawAssistantPayload: true,
awaitingResponse: false,
busy: false
busy: false,
needsInput: false
}
})
},
@ -786,6 +788,11 @@ export function useMessageStream({
if (sessionId) {
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
// A pending clarify blocks the turn, so the first tool.complete after
// one is the clarify resolving — drop the "needs input" flag here so
// the sidebar indicator clears as soon as it's answered, not only at
// message.complete.
updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state))
}
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
@ -829,13 +836,11 @@ export function useMessageStream({
// The transcript only renders the active session, so a background
// clarify is otherwise invisible (the row just keeps spinning like
// it's working). Nudge the user toward the chat that needs them.
if (!isActiveEvent) {
notify({
kind: 'info',
title: 'Another chat needs input',
message: 'Hermes asked a question in a background session. Open it to answer.'
})
// it's working). Flag the session so the sidebar shows a persistent
// "needs input" indicator on its row — works for the active session
// too, and survives alt-tab / window blur (unlike a toast).
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
}
} else if (event.type === 'approval.request') {

View File

@ -4,7 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../../types'
@ -152,7 +152,12 @@ export function useSessionStateCache({
setSessionWorking(previous.storedSessionId, false)
}
if (previous.storedSessionId !== next.storedSessionId || !next.needsInput) {
setSessionAttention(previous.storedSessionId, false)
}
setSessionWorking(next.storedSessionId, next.busy)
setSessionAttention(next.storedSessionId, next.needsInput)
// Every state update is effectively a "still alive" heartbeat for
// streaming events. The session-store watchdog uses this to keep the
// working flag alive during long-running turns and to clear it once

View File

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import type { ComponentProps, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
@ -12,7 +13,6 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import { Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout'
@ -109,7 +109,7 @@ export function TitlebarControls({
const systemTools: TitlebarTool[] = [
{
active: hapticsMuted,
icon: hapticsMuted ? <VolumeX /> : <Volume2 />,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
id: 'haptics',
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
onSelect: toggleHaptics
@ -181,15 +181,20 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
<Button
aria-label="Profiles"
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent select-none [&_svg]:size-4')}
className={cn(titlebarButtonClass, 'bg-transparent select-none')}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title="Profiles"
type="button"
variant="ghost"
>
<Codicon name="account" />
</button>
{/* Optical bump: the `account` glyph has more internal padding than
`search`/`settings-gear`, so at the shared 0.875rem it reads small.
Nudge just this glyph to visually match its neighbours. */}
<Codicon name="account" size="1rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" sideOffset={8}>
<DropdownMenuLabel>
@ -216,29 +221,30 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof useNavigate>; tool: TitlebarTool }) {
const className = cn(
titlebarButtonClass,
'grid place-items-center bg-transparent select-none [&_svg]:size-4',
'bg-transparent select-none',
tool.active && 'bg-(--ui-control-active-background)! text-foreground!',
tool.className
)
if (tool.href) {
return (
<a
aria-label={tool.label}
className={className}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
<Button asChild className={className} size="icon-titlebar" variant="ghost">
<a
aria-label={tool.label}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
</Button>
)
}
return (
<button
<Button
aria-label={tool.label}
aria-pressed={tool.active ?? undefined}
className={className}
@ -251,10 +257,12 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
tool.onSelect?.()
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title={tool.title ?? tool.label}
type="button"
variant="ghost"
>
{tool.icon}
</button>
</Button>
)
}

View File

@ -12,8 +12,10 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
// (traffic lights are hidden). Matches the right-cluster's 0.75rem padding.
export const TITLEBAR_EDGE_INSET = 14
export const titlebarButtonClass =
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] cursor-pointer rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
// Titlebar palette only. All sizing/radius/cursor/centering come from the
// shared <Button size="icon-titlebar"> (used polymorphically via asChild) —
// Button is the single source of button styling.
export const titlebarButtonClass = 'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
export const titlebarHeaderBaseClass =
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'

View File

@ -73,4 +73,7 @@ export interface ClientSessionState {
sawAssistantPayload: boolean
pendingBranchGroup: string | null
interrupted: boolean
/** A blocking clarify prompt is waiting on the user for this session. Drives
* the sidebar "needs input" indicator; cleared when the turn resumes/ends. */
needsInput: boolean
}

View File

@ -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"

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: {

View File

@ -46,7 +46,8 @@ export function createClientSessionState(
streamId: null,
sawAssistantPayload: false,
pendingBranchGroup: null,
interrupted: false
interrupted: false,
needsInput: false
}
}

View File

@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { mergeSessionPage, sessionPinId } from './session'
import { $attentionSessionIds, mergeSessionPage, sessionPinId, setSessionAttention } from './session'
const session = (over: Partial<SessionInfo>): SessionInfo => ({
archived: false,
@ -23,6 +23,34 @@ const session = (over: Partial<SessionInfo>): SessionInfo => ({
...over
})
describe('setSessionAttention', () => {
it('adds and removes a session id without duplicating it', () => {
$attentionSessionIds.set([])
setSessionAttention('s1', true)
setSessionAttention('s1', true)
expect($attentionSessionIds.get()).toEqual(['s1'])
setSessionAttention('s2', true)
expect($attentionSessionIds.get()).toEqual(['s1', 's2'])
setSessionAttention('s1', false)
expect($attentionSessionIds.get()).toEqual(['s2'])
$attentionSessionIds.set([])
})
it('ignores empty ids and no-op clears', () => {
$attentionSessionIds.set([])
setSessionAttention(null, true)
setSessionAttention(undefined, true)
setSessionAttention('', true)
setSessionAttention('missing', false)
expect($attentionSessionIds.get()).toEqual([])
})
})
describe('sessionPinId', () => {
it('uses the live id when there is no compression lineage', () => {
expect(sessionPinId(session({ id: 'abc' }))).toBe('abc')

View File

@ -193,24 +193,41 @@ export function noteSessionActivity(sessionId: string | null | undefined) {
armSessionWatchdog(sessionId)
}
// Toggle an id's membership in a string-set atom, no-op when unchanged (keeps
// the same array reference so subscribers don't churn).
const toggleMembership = (set: (next: Updater<string[]>) => void, id: string, on: boolean) =>
set(current => {
const present = current.includes(id)
if (on) {
return present ? current : [...current, id]
}
return present ? current.filter(x => x !== id) : current
})
// Stored session ids with a blocking prompt (clarify) waiting on the user.
// Separate from $workingSessionIds: a session can be "working" (turn running)
// AND need input. The sidebar row reads this for a persistent indicator that,
// unlike a toast, survives window blur / alt-tab.
export const $attentionSessionIds = atom<string[]>([])
export const setAttentionSessionIds = (next: Updater<string[]>) => updateAtom($attentionSessionIds, next)
export function setSessionAttention(sessionId: string | null | undefined, needsInput: boolean) {
if (sessionId) {
toggleMembership(setAttentionSessionIds, sessionId, needsInput)
}
}
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
if (!sessionId) {
return
}
setWorkingSessionIds(current => {
const alreadyWorking = current.includes(sessionId)
toggleMembership(setWorkingSessionIds, sessionId, working)
if (working) {
return alreadyWorking ? current : [...current, sessionId]
}
return alreadyWorking ? current.filter(id => id !== sessionId) : current
})
// Bookend the watchdog: arm it whenever a session enters "working",
// disarm it whenever it leaves. A subsequent noteSessionActivity() from
// a streaming event will refresh the timer.
// Bookend the watchdog: arm on enter, disarm on leave. A later
// noteSessionActivity() from a streaming event refreshes the timer.
if (working) {
armSessionWatchdog(sessionId)
} else {

View File

@ -467,6 +467,37 @@
--arc-c1: var(--dt-foreground);
}
/* Quest-style "needs you" pulse for a clarify-blocked session's dot —
a soft amber glow that breathes so the row draws the eye without a toast. */
@keyframes quest-glow {
0%,
100% {
transform: scale(1);
box-shadow:
0 0 0.1875rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 70%, transparent),
0 0 0.5rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 45%, transparent);
}
50% {
transform: scale(1.18);
box-shadow:
0 0 0.3125rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 90%, transparent),
0 0 0.875rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 65%, transparent);
}
}
.quest-glow {
animation: quest-glow 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.quest-glow {
animation: none;
box-shadow:
0 0 0.25rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 80%, transparent),
0 0 0.625rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 55%, transparent);
}
}
.arc-border::before {
content: '';
position: absolute;