From 302ee80b6faa7f99eb8f8ae5ccc98b4b385ff67f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 15:02:42 -0500 Subject: [PATCH] refactor(desktop): give the status dot one source of truth and a quieter look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Priority between the overlapping signals — a session can be working and unread and running a background job at once — was resolved at the call site from five separate membership lookups, which is how surfaces drift apart. `$sessionDotStateById` does it once and hands each surface a single answer. The dot's visual language collapses to three colors on one fill/hollow axis with nothing moving. Motion on a six-pixel circle can only say "something is happening", which the row's arc already says better, and it cost a repaint per frame on every row at once; filled now means producing and hollow means open but quiet. Working and stalled had differed by 30% opacity and were in practice the same dot. A settled session paints its project color or nothing, rather than a grey mark of the same weight as a real status next to every resting row. The switcher had grown its own dot with its own three states, so it disagreed with the sidebar on the same session. It renders the shared one now. --- .../src/app/chat/session-status-dot.tsx | 115 +++++++----------- .../chat/sidebar/session-row-state.test.ts | 32 +---- .../src/app/chat/sidebar/session-row-state.ts | 38 +----- .../src/app/chat/sidebar/session-row.test.tsx | 24 ++-- apps/desktop/src/app/session-switcher.tsx | 33 +---- apps/desktop/src/lib/stable-array.ts | 10 ++ apps/desktop/src/store/session-dot-state.ts | 75 ++++++++++++ 7 files changed, 152 insertions(+), 175 deletions(-) create mode 100644 apps/desktop/src/store/session-dot-state.ts 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 ? ( -