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.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 22:43:51 -05:00
parent 067cb9e033
commit 87aaf87748
7 changed files with 181 additions and 185 deletions

View file

@ -31,9 +31,10 @@ export interface PaneMirror<T> {
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<T> {
/** 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<T>(cfg: PaneMirror<T>): () => void {
const registered = new Map<string, { dispose: () => void; title: string; accent?: string }>()
const registered = new Map<string, { dispose: () => void; title: string }>()
const paneId = (key: string) => `${cfg.prefix}:${key}`
const sync = () => {
@ -62,11 +63,10 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => 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<T>(cfg: PaneMirror<T>): () => 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<T>(cfg: PaneMirror<T>): () => void {
render: () => cfg.render(key)
})
registered.set(key, { dispose, title, accent })
registered.set(key, { dispose, title })
if (!current) {
registerPaneCloser(paneId(key), () => cfg.close(key))

View file

@ -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<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'
},
// 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 (
<span className={cn('flex items-center gap-0.5', className)}>
{branchStem ? (
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
{branchStem}
</span>
) : null}
{dotState === 'idle' && color ? (
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
) : (
<span
aria-label={DOT_VARIANTS[dotState].ariaLabel?.(r)}
className={DOT_VARIANTS[dotState].className}
role={DOT_VARIANTS[dotState].role}
title={DOT_VARIANTS[dotState].title?.(r)}
/>
)}
</span>
)
}

View file

@ -46,7 +46,6 @@ import {
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
import {
$sessionStates,
$sessionTiles,
@ -63,6 +62,7 @@ import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
import { useComposerActions } from './hooks/use-composer-actions'
import { paneMirror } from './pane-mirror'
import { startSessionDrag } from './session-drag'
import { SessionStatusDot } from './session-status-dot'
import { useSessionTileActions } from './session-tile-actions'
import { type SessionView, SessionViewProvider } from './session-view'
import { SessionContextMenu } from './sidebar/session-actions-menu'
@ -353,12 +353,6 @@ function tileTitle(storedSessionId: string): string {
return stored ? sessionTitle(stored) : 'New session'
}
/** The tab's lead-dot color the tile's session resolved through the SAME
* shared map the sidebar reads, so a row and its tab always agree. */
function tileAccent(storedSessionId: string): string | undefined {
return sessionColorFor(tileStoredRow(storedSessionId))
}
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
function tileDragPayload(storedSessionId: string): SessionDragPayload {
const stored = tileStoredRow(storedSessionId)
@ -513,8 +507,9 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
export const watchSessionTiles = paneMirror<SessionTile>({
source: $sessionTiles,
// $projectTree: a tile whose session is older than the recents page resolves
// its title/accent through the tree, which loads after the tiles register.
also: [$sessions, $sessionColorById, $projectTree],
// its title through the tree, which loads after the tiles register. (The tab's
// status dot subscribes to color/state itself, so it needs no `also` entry.)
also: [$sessions, $projectTree],
key: t => t.storedSessionId,
prefix: 'session-tile',
dir: t => t.dir,
@ -522,7 +517,13 @@ export const watchSessionTiles = paneMirror<SessionTile>({
before: t => t.before,
minWidth: '20rem',
title: tileTitle,
accent: tileAccent,
// The tab's status dot — the SAME primitive the sidebar row renders, keyed by
// the stored id, so a session's status/color can never disagree between the
// two surfaces. Self-subscribing (live state + resolved color), so the strip
// needn't re-sync when it changes.
tabLead: storedSessionId => (
<SessionStatusDot session={tileStoredRow(storedSessionId)} storedSessionId={storedSessionId} />
),
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
tabWrap: (storedSessionId, tab) => (
<SessionTabMenu

View file

@ -14,15 +14,14 @@ 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 { $unreadFinishedSessionIds } from '@/store/session'
import { $sessionColorById } from '@/store/session-color'
import { $attentionSessionIds, $stalledSessionIds, openSessionTile } from '@/store/session-states'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SessionStatusDot } from '../session-status-dot'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
import { type SessionDotState, sessionDotState, sessionShowsRunningArc } from './session-row-state'
import { sessionShowsRunningArc } from './session-row-state'
import { useProfilePrewarm } from './use-profile-prewarm'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
@ -88,22 +87,6 @@ export function SidebarSessionRow({
const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
// True when a clarify prompt in this session is waiting on the user.
const needsInput = useStore($attentionSessionIds).includes(session.id)
// 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 the turn is still running but the stream has been quiet long
// enough to soften the animation. This must never look like an idle row.
const isStalled = useStore($stalledSessionIds).includes(session.id)
// True when a terminal(background=true) process is alive in this session.
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
// The session's resolved color (idle dot tint), read from the ONE shared map
// the pane tabs also read — an O(1) lookup, never re-derived per render.
const projectColor = useStore($sessionColorById)[session.id] ?? null
// 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({ hasBackground, isStalled, isUnread, isWorking, needsInput })
return (
<SessionContextMenu
@ -237,16 +220,16 @@ export function SidebarSessionRow({
dragHandleProps={dragHandleProps}
leadClassName={needsInput ? 'overflow-visible' : undefined}
>
<SessionRowLeadDot
<SessionStatusDot
branchStem={branchStem}
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
dotState={dotState}
projectColor={projectColor}
session={session}
storedSessionId={session.id}
/>
</SidebarRowGrab>
) : (
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} projectColor={projectColor} />
<SessionStatusDot branchStem={branchStem} session={session} storedSessionId={session.id} />
</SidebarRowLead>
)}
{handoffSource && handoffLabel ? (
@ -267,128 +250,3 @@ export function SidebarSessionRow({
</SessionContextMenu>
)
}
function SessionRowLeadDot({
branchStem,
dotState = 'idle',
className,
projectColor
}: {
branchStem?: string
dotState?: SessionDotState
className?: string
projectColor?: null | string
}) {
return (
<span className={cn('flex items-center gap-0.5', className)}>
{branchStem ? (
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
{branchStem}
</span>
) : null}
<SidebarRowDot dotState={dotState} projectColor={projectColor} />
</span>
)
}
// 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'
},
// 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 sidebar 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'
}
}
function SidebarRowDot({
dotState,
className,
projectColor
}: {
dotState: SessionDotState
className?: string
projectColor?: null | string
}) {
const { t } = useI18n()
const r = t.sidebar.row
// An idle session inherits its project's color (a quiet marker matching the
// project row's own color dot). The active states (working / needs-input /
// background / unread) own the dot and keep their semantic color, so the
// inherited tint never competes with an attention cue.
if (dotState === 'idle' && projectColor) {
return (
<span
aria-hidden="true"
className={cn('size-1 rounded-full', className)}
style={{ backgroundColor: projectColor }}
/>
)
}
const variant = DOT_VARIANTS[dotState]
return (
<span
aria-label={variant.ariaLabel?.(r)}
className={cn(variant.className, className)}
role={variant.role}
title={variant.title?.(r)}
/>
)
}

View file

@ -3,6 +3,7 @@ import { computed } from 'nanostores'
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
import { SessionStatusDot } from '@/app/chat/session-status-dot'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { IdleMount } from '@/components/idle-mount'
@ -53,7 +54,6 @@ import {
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
@ -410,9 +410,10 @@ const syncWorkspaceTitle = () => {
area: 'panes',
title: stored ? storedSessionTitle(stored) : 'New session',
data: {
// The tab's lead dot — same shared map the sidebar row reads, so the
// main tab and its sidebar row always show the same color.
accent: sessionColorFor(stored),
// The tab's status dot — the SAME primitive the sidebar row and session
// tiles render, so the main tab never disagrees with its sidebar row. No
// dot on a fresh draft (no session yet).
tabLead: selected ? () => <SessionStatusDot session={stored} storedSessionId={selected} /> : undefined,
// Pages aren't tab-able: the main zone's bar stands down while one shows.
headerVeto: $workspaceIsPage.get(),
placement: 'main',
@ -427,7 +428,6 @@ const syncWorkspaceTitle = () => {
$selectedStoredSessionId.listen(syncWorkspaceTitle)
$sessions.listen(syncWorkspaceTitle)
$sessionColorById.listen(syncWorkspaceTitle)
$workspaceIsPage.listen(syncWorkspaceTitle)
// Layout reset collapses every session tile into main as a tab (after the

View file

@ -58,11 +58,11 @@ interface PaneChrome {
* (artifacts/skills/plugin pages) are not tab-able surfaces. The flag is
* live: the workspace contribution re-registers it on route changes. */
headerVeto?: boolean
/** A lead-dot color for this pane's TAB (a session tab inheriting its
* project color). Generic any pane may contribute one; the strip just
* renders a tinted dot before the label. Live: the owning contribution
* re-registers it when the resolved color changes. */
accent?: string
/** A lead NODE for this pane's TAB, rendered before the label. A session
* pane (main workspace + tiles) passes its live `SessionStatusDot` here so
* the tab and the sidebar row render status/color from the ONE primitive
* (self-subscribing it updates without the strip re-registering). */
tabLead?: () => React.ReactNode
}
export const paneChrome = (c: Contribution | undefined) => (c?.data ?? {}) as PaneChrome

View file

@ -472,12 +472,8 @@ export function TreeGroup({
role="tab"
style={{ cursor: 'grab' }}
>
{chrome.accent ? (
<span
aria-hidden="true"
className="ml-2 -mr-1 size-1 shrink-0 rounded-full"
style={{ backgroundColor: chrome.accent }}
/>
{chrome.tabLead ? (
<span className="ml-2 -mr-1 flex shrink-0 items-center">{chrome.tabLead()}</span>
) : null}
<PaneTabLabel>{title}</PaneTabLabel>
</PaneTab>