From 87aaf877485d9cbe6ea07d67c9a5ab318ec01449 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 22:43:51 -0500 Subject: [PATCH] fix(desktop): render session status dot from one primitive (sidebar, tiles, main tab) The sidebar row and the pane tabs each painted their own dot from different data: the sidebar read color from $sessionColorById[id] (map-only, no resolver fallback) layered with live state, while a tab painted a flat 'accent' color via sessionColorFor (map + fallback). A session older than the recents page missed the map, so the same session showed grey in the sidebar and its project color in the tab. Add a single SessionStatusDot primitive keyed by the stored session id (the key every live-state atom already uses) that resolves color (override -> project, with fallback) and live state (working/needs-input/stalled/unread/background) itself. The sidebar row, session tiles, and the main workspace tab all render it, so a session's status/color can never disagree across surfaces. Tabs gain the full live status (pulse etc.), not just a static color. Collapses the now-orphaned generic 'accent' tab-dot path into the one primitive. --- apps/desktop/src/app/chat/pane-mirror.ts | 18 +- .../src/app/chat/session-status-dot.tsx | 141 ++++++++++++++++ apps/desktop/src/app/chat/session-tile.tsx | 21 +-- .../src/app/chat/sidebar/session-row.tsx | 158 +----------------- apps/desktop/src/app/contrib/controller.tsx | 10 +- .../pane-shell/tree/renderer/track-model.ts | 10 +- .../pane-shell/tree/renderer/tree-group.tsx | 8 +- 7 files changed, 181 insertions(+), 185 deletions(-) create mode 100644 apps/desktop/src/app/chat/session-status-dot.tsx diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index b78189593693..03a6e167891c 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -31,9 +31,10 @@ export interface PaneMirror { before?: (tile: T) => null | string | undefined minWidth: string title: (key: string) => string - /** Lead-dot color for the tile's tab (e.g. a session's project color). Re-read - * on every `also` change, so pass the color source in `also` to keep it live. */ - accent?: (key: string) => string | undefined + /** Custom lead NODE for the tile's tab (rendered before the label). A live, + * self-subscribing component (e.g. a session's status dot) so the strip needn't + * re-sync on status/color change — only `title` drives re-registration. */ + tabLead?: (key: string) => ReactNode render: (key: string) => ReactNode /** Wrap the tile's TAB (domain context menu — session verbs). */ tabWrap?: (key: string, tab: ReactElement) => ReactNode @@ -52,7 +53,7 @@ export interface PaneMirror { /** Build a `watch*` fn: syncs once, then re-syncs on every source/also change. * Module-level state lives in the returned closure, so call it once per app. */ export function paneMirror(cfg: PaneMirror): () => void { - const registered = new Map void; title: string; accent?: string }>() + const registered = new Map void; title: string }>() const paneId = (key: string) => `${cfg.prefix}:${key}` const sync = () => { @@ -62,11 +63,10 @@ export function paneMirror(cfg: PaneMirror): () => void { for (const tile of tiles) { const key = cfg.key(tile) const title = cfg.title(key) - const accent = cfg.accent?.(key) const current = registered.get(key) - // register() replaces same-id in place — safe for live title/accent refreshes. - if (current && current.title === title && current.accent === accent) { + // register() replaces same-id in place — safe for live title refreshes. + if (current && current.title === title) { continue } @@ -75,7 +75,7 @@ export function paneMirror(cfg: PaneMirror): () => void { area: 'panes', title, data: { - accent, + tabLead: cfg.tabLead ? () => cfg.tabLead!(key) : undefined, dock: { before: cfg.before?.(tile), pane: cfg.anchor?.(tile) ?? 'workspace', @@ -92,7 +92,7 @@ export function paneMirror(cfg: PaneMirror): () => void { render: () => cfg.render(key) }) - registered.set(key, { dispose, title, accent }) + registered.set(key, { dispose, title }) if (!current) { registerPaneCloser(paneId(key), () => cfg.close(key)) diff --git a/apps/desktop/src/app/chat/session-status-dot.tsx b/apps/desktop/src/app/chat/session-status-dot.tsx new file mode 100644 index 000000000000..684b21e9927e --- /dev/null +++ b/apps/desktop/src/app/chat/session-status-dot.tsx @@ -0,0 +1,141 @@ +import { useStore } from '@nanostores/react' + +import { type Translations, useI18n } from '@/i18n' +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 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. +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 = { + // 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' + }, + // Quiet accent pulse — the turn is still authoritative-running, but no + // stream activity has arrived for the watchdog window. + stalled: { + ariaLabel: r => r.sessionRunning, + className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`, + 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. + background: { + ariaLabel: r => r.backgroundRunning, + className: `${DOT_BASE} bg-muted-foreground/80 ${PING} before:bg-muted-foreground/80 before:opacity-60`, + 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' + } +} + +export interface SessionStatusDotProps { + /** The STORED session id — the key every live-state atom (working / + * attention / stalled / unread / background) is keyed by, on BOTH surfaces: + * the sidebar row's `session.id` and a pane tile's `storedSessionId` are the + * same stored id (`$workingSessionIds` et al. map `storedSessionId`). */ + storedSessionId: string + /** The session row for color resolution — recents OR the project tree. Both + * call sites already hold it; passing it lets the idle dot inherit the + * project color even for a session older than the paginated recents page + * (which has no `$sessionColorById` entry). */ + session?: null | SessionInfo + /** TUI-style tree stem for a branched session (`└─ ` / `├─ `). */ + branchStem?: string + /** Applied to the OUTER wrapper (stem + dot) — e.g. hover-fade on the + * reorder handle. */ + className?: string +} + +/** + * 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. + */ +export function SessionStatusDot({ storedSessionId, session, branchStem, className }: SessionStatusDotProps) { + const { t } = useI18n() + const r = t.sidebar.row + + // Subscribe to the shared color map for reactivity; sessionColorFor falls + // back to the resolver for a session outside the recents page. + useStore($sessionColorById) + const color = sessionColorFor(session) ?? null + + const needsInput = useStore($attentionSessionIds).includes(storedSessionId) + const isWorking = useStore($workingSessionIds).includes(storedSessionId) + const isStalled = useStore($stalledSessionIds).includes(storedSessionId) + const isUnread = useStore($unreadFinishedSessionIds).includes(storedSessionId) + const hasBackground = useStore($backgroundRunningSessionIds).includes(storedSessionId) + + const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput }) + + return ( + + {branchStem ? ( + + {branchStem} + + ) : null} + {dotState === 'idle' && color ? ( +