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
@@ -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') {
@@ -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