diff --git a/apps/desktop/src/app/chat/session-status-dot.tsx b/apps/desktop/src/app/chat/session-status-dot.tsx index c84a1cae0dd7c..1f457c10154dd 100644 --- a/apps/desktop/src/app/chat/session-status-dot.tsx +++ b/apps/desktop/src/app/chat/session-status-dot.tsx @@ -1,88 +1,76 @@ import { useStore } from '@nanostores/react' -import { StatusPulse } from '@/components/ui/status-pulse' import { type Translations, useI18n } from '@/i18n' import { useStoreSelector } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' -import { $backgroundRunningSessionIds } from '@/store/composer-status' -import { $unreadFinishedSessionIds } from '@/store/session' import { $sessionColorById, sessionColorFor } from '@/store/session-color' -import { $attentionSessionIds, $stalledSessionIds, $workingSessionIds } from '@/store/session-states' +import { $sessionDotStateById, type SessionDotState } from '@/store/session-dot-state' import type { SessionInfo } from '@/types/hermes' -import { type SessionDotState, sessionDotState } from './sidebar/session-row-state' - // A pure lookup table: each state maps to its className, aria-label, and title. -// No priority resolution here — sessionDotState already picked one. Label/title -// resolve from sidebar.row translations, keyed by name. +// No priority resolution here — $sessionDotStateById already picked one. +// Label/title resolve from sidebar.row translations, keyed by name. type DotVariant = { ariaLabel?: (r: Translations['sidebar']['row']) => string className: string - pulse?: { - className: string - opacity: number - } 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' +const DOT_BASE = 'size-1.5 rounded-full' +// Three colors and one fill/hollow axis, none of it moving. Motion on a 6px +// circle can only say "something is happening" — which the row's arc already +// says, better — while costing a repaint per frame on every row at once. What +// the dot is for is telling states APART, and that is a job for color and fill: +// filled means producing, hollow means open but quiet. The two states this +// replaces differed by 30% opacity and were, in practice, the same dot. const DOT_VARIANTS: Record = { - // Amber steady — a clarify/approval is blocking the turn. Steady (not - // pulsing) reads as "your turn", distinct from the accent pulse of a turn. + // Amber — a clarify/approval is blocking the turn. The one "act now" color, + // and the only state the user is required to do something about. 'needs-input': { ariaLabel: r => r.needsInput, - className: `${DOT_BASE} quest-glow bg-amber-500`, + className: `${DOT_BASE} bg-amber-500`, role: 'status', title: r => r.waitingForAnswer }, - // Accent pulse — the LLM turn is actively running. + // Accent — the turn is running. The row's arc carries the motion. 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)]`, - pulse: { - className: 'absolute inset-0 rounded-full bg-(--ui-accent) opacity-0', - opacity: 0.7 - }, + className: `${DOT_BASE} bg-(--ui-accent)`, role: 'status' }, - // Quiet accent pulse — the turn is still authoritative-running, but no - // stream activity has arrived for the watchdog window. + // Hollow accent — still authoritatively running, but nothing has arrived for + // the watchdog window. Same color as working because it IS working; hollow + // because nothing is coming out of it right now. stalled: { ariaLabel: r => r.sessionRunning, - className: `${DOT_BASE} bg-(--ui-accent) opacity-70`, - pulse: { - className: 'absolute inset-0 rounded-full bg-(--ui-accent) opacity-0', - opacity: 0.4 - }, + className: `${DOT_BASE} border border-(--ui-accent)`, role: 'status', title: r => r.sessionRunning }, - // Pulsing gray — a terminal(background=true) process is alive while the LLM - // is idle. Gray (not accent) reads as "something chugging along". Brighter - // than muted-foreground so it's visible against the surface. + // Hollow muted — a terminal(background=true) process outlived the turn. An + // outline reads as "still open" without claiming the model is working; a + // filled grey dot read as finished, the opposite of what this means. background: { ariaLabel: r => r.backgroundRunning, - className: `${DOT_BASE} bg-muted-foreground/80`, - pulse: { - className: 'absolute inset-0 rounded-full bg-muted-foreground/80 opacity-0', - opacity: 0.6 - }, + className: `${DOT_BASE} border border-(--ui-text-tertiary)`, 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." + // Emerald — the turn finished while the user was looking elsewhere. unread: { ariaLabel: r => r.finishedUnread, className: `${DOT_BASE} bg-emerald-500`, role: 'status', title: r => r.finishedUnread }, + // Settled: the project color, or nothing at all. An uncolored session used to + // get a grey dot, which put a mark of the same weight as a status next to + // every resting row and made "no color" look like a state of its own. idle: { - className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80' + className: 'size-1 rounded-full' } } @@ -105,14 +93,13 @@ export interface SessionStatusDotProps { } /** - * SESSION STATUS DOT — the ONE primitive both the sidebar row and the pane tab - * render, so a session's status/color can never disagree between the two - * surfaces. It reads every signal itself from the shared stores keyed by the - * stored session id: live state (working / needs-input / stalled / unread / - * background, mutually exclusive via `sessionDotState`) and the resolved color - * (override → project color, via `sessionColorFor`). An idle session shows its - * project color; the active states own the dot with their semantic color so an - * attention cue is never masked by the inherited tint. + * SESSION STATUS DOT — the ONE primitive the sidebar row, the pane tabs, and + * the session switcher render, so a session's status can never disagree + * between surfaces. It resolves everything itself from the stored session id: + * the live state (via `$sessionDotStateById`, already reduced to one mutually + * exclusive answer) and the color (override → project, via `sessionColorFor`). + * An idle session shows its project color; the active states own the dot with + * their semantic color so an attention cue is never masked by the tint. */ export function SessionStatusDot({ storedSessionId, session, branchStem, className }: SessionStatusDotProps) { const { t } = useI18n() @@ -123,17 +110,9 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa useStore($sessionColorById) const color = sessionColorFor(session) ?? null - // Per-session membership as booleans via useStoreSelector: these arrays tick - // on every stream delta (any session working/stalled/etc changes the array - // reference), but a given dot only repaints when ITS OWN membership flips. - // A plain useStore(array).includes(id) re-rendered every dot on every tick. - const needsInput = useStoreSelector($attentionSessionIds, ids => ids.includes(storedSessionId)) - const isWorking = useStoreSelector($workingSessionIds, ids => ids.includes(storedSessionId)) - const isStalled = useStoreSelector($stalledSessionIds, ids => ids.includes(storedSessionId)) - const isUnread = useStoreSelector($unreadFinishedSessionIds, ids => ids.includes(storedSessionId)) - const hasBackground = useStoreSelector($backgroundRunningSessionIds, ids => ids.includes(storedSessionId)) - - const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput }) + // Selector, not a plain useStore: the map is rebuilt whenever any session's + // status changes, but a given dot only repaints when ITS OWN state flips. + const dotState = useStoreSelector($sessionDotStateById, states => states[storedSessionId] ?? 'idle') const variant = DOT_VARIANTS[dotState] return ( @@ -143,24 +122,18 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa {branchStem} ) : null} - {dotState === 'idle' && color ? ( -