feat(desktop): session color — inherit from project, shared across sidebar and tabs (#67469)

* feat(desktop): inherit project color on session rows

Sessions that belong to a colored project now pick up that color as the
sidebar row's idle lead dot, so work/personal/project buckets are legible
at a glance (Layer 1 of #66565). Derived from the same project membership
the sidebar already groups by; active states (working / needs-input /
background / unread) still own the dot so the tint never fights an
attention cue.

* 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).

* fix(desktop): resolve session color for repo-root-only sessions

liveSessionProjectId bailed the instant a session had no cwd, so an
older/imported session carrying only a git_repo_root — which the backend
still groups under its project — got no project and rendered a grey idle
dot instead of the project color ("grouped but grey"). Anchor on the repo
root when cwd is absent, matching how the sidebar grouped the row, and keep
the sibling-worktree guard for the cwd-present case.
This commit is contained in:
brooklyn! 2026-07-19 07:41:47 -04:00 committed by GitHub
commit ad0d21188f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 295 additions and 14 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

@ -10,6 +10,7 @@ import {
mergeRepoWorktreeGroups,
overlayLiveLanes,
overlayLivePreviews,
sessionProjectColor,
type SidebarProjectTree,
type SidebarSessionGroup,
sortWorktreeGroups
@ -470,6 +471,15 @@ describe('liveSessionProjectId', () => {
expect(id).toBe('p_app')
})
it('anchors a cwd-less session on its git_repo_root (backend groups it there too)', () => {
// Older/imported rows carry only a repo root; the sidebar files them under
// the repo's project, so membership (and color) must resolve from the root.
expect(liveSessionProjectId(makeSession(null, { git_repo_root: '/www/app' }), [])).toBe('/www/app')
expect(
liveSessionProjectId(makeSession(null, { git_repo_root: '/www/app' }), [makeProject('p_app', ['/www/app'])])
).toBe('p_app')
})
it('skips cwd-less, kanban-task, and out-of-tree (sibling) worktree sessions', () => {
expect(liveSessionProjectId(makeSession(null), [])).toBeNull()
// Kanban task worktree → folds into the kanban bucket, not a project preview.
@ -519,6 +529,52 @@ describe('liveSessionProjectId', () => {
})
})
describe('sessionProjectColor', () => {
const colored = (id: string, folders: string[], color: string): ProjectInfo => ({
...makeProject(id, folders),
color
})
it('inherits the color of the explicit project the session belongs to', () => {
const session = makeSession('/www/app/src', { git_repo_root: '/www/app' })
expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff')
})
it('returns null when the owning project has no color set', () => {
const session = makeSession('/www/app/src', { git_repo_root: '/www/app' })
expect(sessionProjectColor(session, [makeProject('p_app', ['/www/app'])])).toBeNull()
})
it('colors a cwd-less session by its git_repo_root project (the grouped-but-grey fix)', () => {
const session = makeSession(null, { git_repo_root: '/www/app' })
expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff')
})
it('returns null for a session that only maps to an auto repo root (no explicit project)', () => {
// liveSessionProjectId falls back to the repo root id, which is not a
// project row and therefore carries no color.
expect(sessionProjectColor(makeSession('/www/app'), [])).toBeNull()
})
it('returns null for an unplaceable (cwd-less) session', () => {
expect(sessionProjectColor(makeSession(null), [colored('p_app', ['/www/app'], '#4a9eff')])).toBeNull()
})
it('uses the longest-prefix project when nested projects both match', () => {
const session = makeSession('/www/app/packages/api/src', { git_repo_root: '/www/app' })
const projects = [
colored('p_root', ['/www/app'], '#111111'),
colored('p_api', ['/www/app/packages/api'], '#222222')
]
expect(sessionProjectColor(session, projects)).toBe('#222222')
})
})
describe('overlayLiveLanes', () => {
it('injects a live session into the matching main lane instantly', () => {
const project = projectNode({

View file

@ -361,15 +361,21 @@ function isPathUnder(folder: string, target: string): boolean {
*/
export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string {
const cwd = (session.cwd || '').trim()
// A session may carry only a git_repo_root and no cwd — older/imported rows,
// or ones captured before cwd tracking. The backend still groups those by repo
// root, so anchor on it here too; otherwise the sidebar files the row under a
// project but the color derivation drops it (the "grouped but grey" bug).
const repoRoot = (session.git_repo_root || '').trim() || cwd
const anchor = cwd || repoRoot
if (!cwd || kanbanWorktreeDir(cwd)) {
if (!anchor || kanbanWorktreeDir(anchor)) {
return null
}
// No persisted repo root yet (brand-new session) → the cwd is the root.
const repoRoot = (session.git_repo_root || '').trim() || cwd
if (!isPathUnder(repoRoot, cwd)) {
// With a cwd present it must sit under the repo root (a sibling worktree
// outside the root can't be placed from the row alone); a root-only session
// skips this — the root IS the anchor.
if (cwd && !isPathUnder(repoRoot, cwd)) {
return null
}
@ -396,6 +402,26 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
return projectId || repoRoot
}
/**
* The color a session inherits from its owning project the explicit project
* whose folder is the longest prefix of the session's cwd/repo-root, when that
* project carries a user-set color. Auto-promoted repo projects have no color
* unless the user set one, so a session only tints when it belongs to a colored
* project (inheritance is opt-in by coloring the project). Reuses
* {@link liveSessionProjectId} so the color follows the SAME membership the
* sidebar groups by; returns null for cwd-less / kanban / out-of-tree rows and
* for sessions under an uncolored (or auto) project.
*/
export function sessionProjectColor(session: SessionInfo, projects: ProjectInfo[]): null | string {
const projectId = liveSessionProjectId(session, projects)
if (!projectId) {
return null
}
return projects.find(project => project.id === projectId)?.color ?? null
}
const upsertSession = (rows: SessionInfo[], session: SessionInfo): SessionInfo[] =>
[session, ...rows.filter(row => row.id !== session.id)].sort((a, b) => b.started_at - a.started_at)

View file

@ -16,6 +16,7 @@ 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, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
@ -91,6 +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 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
@ -240,11 +244,12 @@ export function SidebarSessionRow({
branchStem={branchStem}
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
dotState={dotState}
projectColor={projectColor}
/>
</SidebarRowGrab>
) : (
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} />
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} projectColor={projectColor} />
</SidebarRowLead>
)}
{handoffSource && handoffLabel ? (
@ -274,11 +279,13 @@ type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'worki
function SessionRowLeadDot({
branchStem,
dotState = 'idle',
className
className,
projectColor
}: {
branchStem?: string
dotState?: SessionDotState
className?: string
projectColor?: null | string
}) {
return (
<span className={cn('flex items-center gap-0.5', className)}>
@ -287,7 +294,7 @@ function SessionRowLeadDot({
{branchStem}
</span>
) : null}
<SidebarRowDot dotState={dotState} />
<SidebarRowDot dotState={dotState} projectColor={projectColor} />
</span>
)
}
@ -348,9 +355,32 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
}
}
function SidebarRowDot({ dotState, className }: { dotState: SessionDotState; className?: string }) {
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 (

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
}