feat(desktop): share session color across sidebar rows and pane tabs

Route session color through one computed store ($sessionColorById) that
both the sidebar rows and the pane tabs read, so a session and its tab can
never show different colors. Recomputed only when the session list or
projects change (cold atoms — the streaming pulse lives elsewhere) and read
as an O(1) lookup, never re-derived per render.

Tabs previously had no color at all: the strip renders only a title string.
Add a generic `accent` to the pane contribution that the tab strip paints as
a lead dot; the session tiles (via paneMirror) and the main workspace tab
(syncWorkspaceTitle) feed it from the same shared map. Precedence now lives
in one place, ready for per-session override / agent-set color (#66565).
This commit is contained in:
Brooklyn Nicholson 2026-07-19 00:29:25 -04:00
parent 5a6e235833
commit 897f3da276
8 changed files with 178 additions and 10 deletions

View file

@ -31,6 +31,9 @@ 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
render: (key: string) => ReactNode
/** Wrap the tile's TAB (domain context menu — session verbs). */
tabWrap?: (key: string, tab: ReactElement) => ReactNode
@ -49,7 +52,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 }>()
const registered = new Map<string, { dispose: () => void; title: string; accent?: string }>()
const paneId = (key: string) => `${cfg.prefix}:${key}`
const sync = () => {
@ -59,10 +62,11 @@ 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 refreshes.
if (current && current.title === title) {
// register() replaces same-id in place — safe for live title/accent refreshes.
if (current && current.title === title && current.accent === accent) {
continue
}
@ -71,6 +75,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
area: 'panes',
title,
data: {
accent,
dock: {
before: cfg.before?.(tile),
pane: cfg.anchor?.(tile) ?? 'workspace',
@ -87,7 +92,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
render: () => cfg.render(key)
})
registered.set(key, { dispose, title })
registered.set(key, { dispose, title, accent })
if (!current) {
registerPaneCloser(paneId(key), () => cfg.close(key))

View file

@ -40,6 +40,7 @@ import {
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
import {
$sessionStates,
$sessionTiles,
@ -257,6 +258,12 @@ function tileTitle(storedSessionId: string): string {
return stored ? sessionTitle(stored) : '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($sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)))
}
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
function tileDragPayload(storedSessionId: string): SessionDragPayload {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
@ -407,7 +414,7 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
export const watchSessionTiles = paneMirror<SessionTile>({
source: $sessionTiles,
also: [$sessions],
also: [$sessions, $sessionColorById],
key: t => t.storedSessionId,
prefix: 'session-tile',
dir: t => t.dir,
@ -415,6 +422,7 @@ export const watchSessionTiles = paneMirror<SessionTile>({
before: t => t.before,
minWidth: '20rem',
title: tileTitle,
accent: tileAccent,
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
tabWrap: (storedSessionId, tab) => (
<SessionTabMenu

View file

@ -15,13 +15,12 @@ import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $projects } from '@/store/projects'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $sessionColorById } from '@/store/session-color'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { sessionProjectColor } from './projects/workspace-groups'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
import { useProfilePrewarm } from './use-profile-prewarm'
@ -93,9 +92,9 @@ export function SidebarSessionRow({
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)
// The color inherited from the session's project (idle dot tint). Follows the
// same membership the sidebar groups by; null unless the project is colored.
const projectColor = sessionProjectColor(session, useStore($projects))
// 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

View file

@ -51,6 +51,7 @@ 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'
@ -403,6 +404,9 @@ 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),
// Pages aren't tab-able: the main zone's bar stands down while one shows.
headerVeto: $workspaceIsPage.get(),
placement: 'main',
@ -417,6 +421,7 @@ 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,6 +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
}
export const paneChrome = (c: Contribution | undefined) => (c?.data ?? {}) as PaneChrome

View file

@ -449,6 +449,13 @@ 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 }}
/>
) : null}
<PaneTabLabel>{title}</PaneTabLabel>
</PaneTab>
)

View file

@ -0,0 +1,103 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { ProjectInfo, SessionInfo } from '@/types/hermes'
import { $projects } from './projects'
import { $sessions } from './session'
import { $sessionColorById, sessionColorFor } from './session-color'
let nextId = 0
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
archived: false,
cwd,
ended_at: null,
id: `s${nextId++}`,
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 1,
model: 'claude',
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 1_000,
title: null,
tool_call_count: 0,
...overrides
}
}
function makeProject(id: string, folders: string[], color: null | string): ProjectInfo {
return {
archived: false,
board_slug: null,
color,
created_at: 0,
description: null,
folders: folders.map((path, i) => ({ added_at: 0, is_primary: i === 0, label: null, path })),
icon: null,
id,
name: id,
primary_path: folders[0] ?? null,
slug: id
}
}
afterEach(() => {
$sessions.set([])
$projects.set([])
})
describe('$sessionColorById', () => {
it('maps each session under a colored project to that color, keyed by live id', () => {
const a = makeSession('/www/app/src', { git_repo_root: '/www/app' })
const b = makeSession('/other/place')
$projects.set([makeProject('p_app', ['/www/app'], '#4a9eff')])
$sessions.set([a, b])
const map = $sessionColorById.get()
expect(map[a.id]).toBe('#4a9eff')
// Sessions with no colored project are absent (a sparse map, not null-filled).
expect(b.id in map).toBe(false)
})
it('omits a session whose project has no color', () => {
const a = makeSession('/www/app', { git_repo_root: '/www/app' })
$projects.set([makeProject('p_app', ['/www/app'], null)])
$sessions.set([a])
expect(a.id in $sessionColorById.get()).toBe(false)
})
it('recomputes when the projects list changes (color applied later)', () => {
const a = makeSession('/www/app', { git_repo_root: '/www/app' })
$sessions.set([a])
$projects.set([makeProject('p_app', ['/www/app'], null)])
expect($sessionColorById.get()[a.id]).toBeUndefined()
$projects.set([makeProject('p_app', ['/www/app'], '#7bc86c')])
expect($sessionColorById.get()[a.id]).toBe('#7bc86c')
})
})
describe('sessionColorFor', () => {
it('reads a single session through the same shared map', () => {
const a = makeSession('/www/app', { git_repo_root: '/www/app' })
$projects.set([makeProject('p_app', ['/www/app'], '#5865f2')])
$sessions.set([a])
expect(sessionColorFor(a)).toBe('#5865f2')
})
it('returns undefined for a null/absent session', () => {
expect(sessionColorFor(null)).toBeUndefined()
expect(sessionColorFor(undefined)).toBeUndefined()
})
})

View file

@ -0,0 +1,36 @@
import { computed } from 'nanostores'
import { sessionProjectColor } from '@/app/chat/sidebar/projects/workspace-groups'
import { $projects } from '@/store/projects'
import { $sessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
// The resolved color for every session, keyed by live session id — the ONE
// source of truth both the sidebar rows and the pane tabs read, so the two
// surfaces can never drift. Recomputed only when the session list or the
// projects change (both cold atoms; the working/streaming pulse lives in
// $sessionStates, so a busy flip never rebuilds this), and every consumer reads
// it as an O(1) lookup rather than re-deriving membership per render.
//
// Precedence lives in one place: today a session inherits its project's color;
// when per-session overrides / agent-set colors land (#66565 layers 2-3), fold
// them in ABOVE the project fallback here and every surface updates for free.
export const $sessionColorById = computed([$sessions, $projects], (sessions, projects) => {
const map: Record<string, string> = {}
for (const session of sessions) {
const color = sessionProjectColor(session, projects)
if (color) {
map[session.id] = color
}
}
return map
})
// The color for a single session object (the tabs already hold the SessionInfo
// they render, so they resolve through the same map the sidebar reads).
export function sessionColorFor(session: null | SessionInfo | undefined): string | undefined {
return session ? ($sessionColorById.get()[session.id] ?? undefined) : undefined
}