mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(desktop): add background-task indicator to sidebar session rows (#65174)
* feat(desktop): add background-task indicator to sidebar session rows
A session with a live terminal(background=true) process but no active LLM
turn now shows a pulsing gray dot in the sidebar — distinct from the accent
pulse of an active turn and the steady amber/green of needs-input/unread.
New $backgroundRunningSessionIds computed atom joins
$backgroundStatusBySession (runtime-keyed) with $sessionStates
(runtime→stored) to produce stored session ids the sidebar row can match
against — same pattern as $attentionSessionIds and $unreadFinishedSessionIds.
Also refactors SidebarRowDot from a 3-branch if-else chain into a
table-driven priority array of DotState entries. Each state declares its
active flag, className, ariaLabel, and title in one place — adding a new
indicator state is now one array entry instead of another conditional.
* fix(desktop): keep pulse-dot before:bg-* static so Tailwind emits it
The PING() helper interpolated the color into `before:bg-${color}`, but
Tailwind v4 only generates utilities it finds as complete static strings — a
template-composed `before:bg-(--ui-accent)` / `before:bg-muted-foreground/50`
is never emitted, so both pulse rings (working + new background) lost their
halo color. Make PING a static scaffold and write the before:bg color inline
per variant.
---------
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
This commit is contained in:
parent
8b209e0dd7
commit
b80b52aa46
7 changed files with 122 additions and 61 deletions
|
|
@ -13,6 +13,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { coarseElapsed } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||
import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
|
@ -80,6 +81,21 @@ export function SidebarSessionRow({
|
|||
// True when the session's most recent turn finished in the background (while
|
||||
// the user was viewing a different session) and hasn't been opened since.
|
||||
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
|
||||
// True when a terminal(background=true) process is alive in this session.
|
||||
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
|
||||
|
||||
// Resolve the dot's display state once — the four signals are mutually
|
||||
// exclusive by priority, so threading them as booleans through wrappers just
|
||||
// to collapse them at the leaf is backwards.
|
||||
const dotState: SessionDotState = needsInput
|
||||
? 'needs-input'
|
||||
: isWorking
|
||||
? 'working'
|
||||
: hasBackground
|
||||
? 'background'
|
||||
: isUnread
|
||||
? 'unread'
|
||||
: 'idle'
|
||||
|
||||
return (
|
||||
<SessionContextMenu
|
||||
|
|
@ -210,14 +226,12 @@ export function SidebarSessionRow({
|
|||
<SessionRowLeadDot
|
||||
branchStem={branchStem}
|
||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
isWorking={isWorking}
|
||||
needsInput={needsInput}
|
||||
isUnread={isUnread}
|
||||
dotState={dotState}
|
||||
/>
|
||||
</SidebarRowGrab>
|
||||
) : (
|
||||
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
|
||||
<SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} isUnread={isUnread} />
|
||||
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} />
|
||||
</SidebarRowLead>
|
||||
)}
|
||||
{handoffSource && handoffLabel ? (
|
||||
|
|
@ -238,17 +252,18 @@ export function SidebarSessionRow({
|
|||
)
|
||||
}
|
||||
|
||||
/** The session's display state for the sidebar lead dot. The call site
|
||||
* resolves this from the four underlying signals (needs-input, working,
|
||||
* background, unread) so the dot component itself is a pure lookup. */
|
||||
type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'working'
|
||||
|
||||
function SessionRowLeadDot({
|
||||
branchStem,
|
||||
isWorking,
|
||||
needsInput = false,
|
||||
isUnread = false,
|
||||
dotState = 'idle',
|
||||
className
|
||||
}: {
|
||||
branchStem?: string
|
||||
isWorking: boolean
|
||||
needsInput?: boolean
|
||||
isUnread?: boolean
|
||||
dotState?: SessionDotState
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -258,66 +273,77 @@ function SessionRowLeadDot({
|
|||
{branchStem}
|
||||
</span>
|
||||
) : null}
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} isUnread={isUnread} />
|
||||
<SidebarRowDot dotState={dotState} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRowDot({
|
||||
isWorking,
|
||||
needsInput = false,
|
||||
isUnread = false,
|
||||
className
|
||||
}: {
|
||||
isWorking: boolean
|
||||
needsInput?: boolean
|
||||
isUnread?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
// A pure lookup table: each state maps to its className, aria-label, and
|
||||
// title. No priority resolution here — the call site already picked one.
|
||||
// Label/title are resolved from sidebar.row translations, keyed by name.
|
||||
type DotVariant = {
|
||||
ariaLabel?: (r: Translations['sidebar']['row']) => string
|
||||
className: string
|
||||
role?: 'status'
|
||||
title?: (r: Translations['sidebar']['row']) => string
|
||||
}
|
||||
|
||||
// Shared base for every active dot; idle is smaller and uses its own class.
|
||||
const DOT_BASE = 'relative size-1.5 rounded-full'
|
||||
|
||||
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
|
||||
// the two pulsing dots. The `before:bg-*` color is written inline per variant
|
||||
// (NOT interpolated here): Tailwind only generates utilities it can see as
|
||||
// complete static strings, so a `before:bg-${color}` template never emits.
|
||||
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
|
||||
|
||||
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
||||
// Amber steady — a clarify/approval is blocking the turn. Steady (not
|
||||
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
|
||||
'needs-input': {
|
||||
ariaLabel: r => r.needsInput,
|
||||
className: `${DOT_BASE} quest-glow bg-amber-500`,
|
||||
role: 'status',
|
||||
title: r => r.waitingForAnswer
|
||||
},
|
||||
// Accent pulse — the LLM turn is actively running.
|
||||
working: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
|
||||
role: 'status'
|
||||
},
|
||||
// Pulsing gray — a terminal(background=true) process is alive while the LLM
|
||||
// is idle. Gray (not accent) reads as "something chugging along".
|
||||
background: {
|
||||
ariaLabel: r => r.backgroundRunning,
|
||||
className: `${DOT_BASE} bg-muted-foreground/50 ${PING} before:bg-muted-foreground/50 before:opacity-50`,
|
||||
role: 'status',
|
||||
title: r => r.backgroundRunning
|
||||
},
|
||||
// Steady green — a background session's turn completed and the user hasn't
|
||||
// opened it since. "Something new here, go look."
|
||||
unread: {
|
||||
ariaLabel: r => r.finishedUnread,
|
||||
className: `${DOT_BASE} bg-emerald-500`,
|
||||
role: 'status',
|
||||
title: r => r.finishedUnread
|
||||
},
|
||||
idle: {
|
||||
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
|
||||
}
|
||||
}
|
||||
|
||||
function SidebarRowDot({ dotState, className }: { dotState: SessionDotState; className?: string }) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
|
||||
// "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={r.needsInput}
|
||||
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
|
||||
role="status"
|
||||
title={r.waitingForAnswer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// "Unread finished" wins over the default gray dot: a background session's
|
||||
// turn completed and the user hasn't opened it since. Steady green (no pulse)
|
||||
// reads as "something new here, go look" — distinct from the live accent
|
||||
// pulse of a running turn.
|
||||
if (isUnread) {
|
||||
return (
|
||||
<span
|
||||
aria-label={r.finishedUnread}
|
||||
className={cn('size-1.5 rounded-full bg-emerald-500', className)}
|
||||
role="status"
|
||||
title={r.finishedUnread}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const variant = DOT_VARIANTS[dotState]
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={isWorking ? r.sessionRunning : undefined}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isWorking
|
||||
? "relative size-1.5 bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] before:absolute before:inset-0 before:animate-ping before:rounded-full before:bg-(--ui-accent) before:opacity-70 before:content-['']"
|
||||
: 'size-1 bg-(--ui-text-quaternary) opacity-80',
|
||||
className
|
||||
)}
|
||||
role={isWorking ? 'status' : undefined}
|
||||
aria-label={variant.ariaLabel?.(r)}
|
||||
className={cn(variant.className, className)}
|
||||
role={variant.role}
|
||||
title={variant.title?.(r)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1656,6 +1656,7 @@ export const en: Translations = {
|
|||
needsInput: 'Needs your input',
|
||||
waitingForAnswer: 'Waiting for your answer',
|
||||
finishedUnread: 'Finished — unread',
|
||||
backgroundRunning: 'Background task running',
|
||||
handoffOrigin: platform => `Handed off from ${platform}`,
|
||||
renamed: 'Renamed',
|
||||
renameFailed: 'Rename failed',
|
||||
|
|
|
|||
|
|
@ -1574,6 +1574,7 @@ export const ja = defineLocale({
|
|||
needsInput: '入力が必要です',
|
||||
waitingForAnswer: '回答を待っています',
|
||||
finishedUnread: '完了 — 未読',
|
||||
backgroundRunning: 'バックグラウンドタスク実行中',
|
||||
handoffOrigin: platform => `${platform} から引き継ぎ`,
|
||||
renamed: '名前を変更しました',
|
||||
renameFailed: '名前の変更に失敗しました',
|
||||
|
|
|
|||
|
|
@ -1379,6 +1379,7 @@ export interface Translations {
|
|||
needsInput: string
|
||||
waitingForAnswer: string
|
||||
finishedUnread: string
|
||||
backgroundRunning: string
|
||||
handoffOrigin: (platform: string) => string
|
||||
renamed: string
|
||||
renameFailed: string
|
||||
|
|
|
|||
|
|
@ -1524,6 +1524,7 @@ export const zhHant = defineLocale({
|
|||
needsInput: '需要您的輸入',
|
||||
waitingForAnswer: '等待您的回答',
|
||||
finishedUnread: '已完成 — 未讀',
|
||||
backgroundRunning: '背景任務執行中',
|
||||
handoffOrigin: platform => `從 ${platform} 轉接`,
|
||||
renamed: '已重新命名',
|
||||
renameFailed: '重新命名失敗',
|
||||
|
|
|
|||
|
|
@ -1832,6 +1832,7 @@ export const zh: Translations = {
|
|||
needsInput: '需要你输入',
|
||||
waitingForAnswer: '正在等待你的回答',
|
||||
finishedUnread: '已完成 — 未读',
|
||||
backgroundRunning: '后台任务运行中',
|
||||
handoffOrigin: platform => `从 ${platform} 转接`,
|
||||
renamed: '已重命名',
|
||||
renameFailed: '重命名失败',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { TodoItem, TodoStatus } from '@/lib/todos'
|
|||
import { $gateway } from './gateway'
|
||||
import { dispatchNativeNotification } from './native-notifications'
|
||||
import { notifyError } from './notifications'
|
||||
import { $sessionStates } from './session-states'
|
||||
import { $subagentsBySession, type SubagentProgress } from './subagents'
|
||||
import { $todosBySession } from './todos'
|
||||
|
||||
|
|
@ -35,6 +36,35 @@ export interface ComposerStatusItem {
|
|||
// registry (`terminal(background=true)` spawns) via `process.list`.
|
||||
export const $backgroundStatusBySession = atom<Record<string, ComposerStatusItem[]>>({})
|
||||
|
||||
// Stored session ids that have at least one RUNNING background process. The
|
||||
// sidebar row reads this for a pulsing gray dot — distinct from the accent
|
||||
// pulse of an active LLM turn — so the user can tell at a glance "this session
|
||||
// has something chugging along in the background" even when the turn is idle.
|
||||
//
|
||||
// $backgroundStatusBySession is keyed by RUNTIME session id (gateway events
|
||||
// and process.list both speak that); the sidebar row knows only the STORED id.
|
||||
// $sessionStates bridges the two: runtime id → state.storedSessionId.
|
||||
export const $backgroundRunningSessionIds = computed(
|
||||
[$backgroundStatusBySession, $sessionStates],
|
||||
(bg, states) => {
|
||||
const ids = new Set<string>()
|
||||
|
||||
for (const [runtimeId, items] of Object.entries(bg)) {
|
||||
if (!items.some(i => i.state === 'running')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const storedId = states[runtimeId]?.storedSessionId
|
||||
|
||||
if (storedId) {
|
||||
ids.add(storedId)
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids]
|
||||
}
|
||||
)
|
||||
|
||||
// Rows the user X-ed away. The registry keeps finished processes around for a
|
||||
// while, so without this every refresh would resurrect a dismissed row.
|
||||
const dismissedBySession = new Map<string, Set<string>>()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue