feat(desktop): per-session color override (#66565 layer 2) (#67681)

Add a color picker to the session menu (an Appearance submenu of reusable
ColorSwatches, in both the dropdown and right-click flavors). The pick is a
per-session override that wins over the inherited project color; clearing
falls back to it.

Storage is desktop-local like pins ($sessionColorOverrides persistentAtom),
keyed by the DURABLE lineage id so a color survives auto-compression's id
rotation. Precedence folds into the existing $sessionColorById resolver, so
sidebar rows AND pane tabs pick it up with no changes to either — the payoff
of the shared store. To take this to the TUI later, promote this one atom to
a backend SessionInfo.color field; the resolver and picker stay put.
This commit is contained in:
brooklyn! 2026-07-19 17:05:16 -04:00 committed by GitHub
parent 1b17015f7a
commit 0d2ad3993e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 147 additions and 20 deletions

View file

@ -10,11 +10,15 @@ import {
} from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { CopyButton } from '@/components/ui/copy-button'
@ -31,16 +35,28 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { PROFILE_SWATCHES } from '@/lib/profile-color'
import { exportSession } from '@/lib/session-export'
import { activeGateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { $activeSessionId, $selectedStoredSessionId, setSessions } from '@/store/session'
import {
$activeSessionId,
$selectedStoredSessionId,
$sessions,
sessionMatchesStoredId,
sessionPinId,
setSessions
} from '@/store/session'
import { $sessionColorOverrides, setSessionColorOverride } from '@/store/session-color'
import { $sessionTiles, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
@ -116,14 +132,30 @@ interface SessionActions {
type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem
/** A menu flavour (dropdown / context) — item + separator components. */
/** A menu flavour (dropdown / context) — item + separator + submenu components. */
interface MenuKit {
Item: MenuItem
Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator
Sub: typeof DropdownMenuSub | typeof ContextMenuSub
SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger
SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent
}
const DROPDOWN_KIT: MenuKit = { Item: DropdownMenuItem, Separator: DropdownMenuSeparator }
const CONTEXT_KIT: MenuKit = { Item: ContextMenuItem, Separator: ContextMenuSeparator }
const DROPDOWN_KIT: MenuKit = {
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
Sub: DropdownMenuSub,
SubContent: DropdownMenuSubContent,
SubTrigger: DropdownMenuSubTrigger
}
const CONTEXT_KIT: MenuKit = {
Item: ContextMenuItem,
Separator: ContextMenuSeparator,
Sub: ContextMenuSub,
SubContent: ContextMenuSubContent,
SubTrigger: ContextMenuSubTrigger
}
interface ItemSpec {
className?: string
@ -134,6 +166,27 @@ interface ItemSpec {
variant?: 'destructive'
}
// The color picker inside the session menu's Appearance submenu. Its own
// component so only an OPEN submenu subscribes to the stores (not every row's
// menu). Reads/writes the override keyed by the DURABLE id so a color survives
// compression; clearing falls back to the inherited project color.
function SessionColorSwatches({ sessionId }: { sessionId: string }) {
const { t } = useI18n()
const overrides = useStore($sessionColorOverrides)
const session = useStore($sessions).find(s => sessionMatchesStoredId(s, sessionId))
const durableId = session ? sessionPinId(session) : sessionId
return (
<ColorSwatches
clearIcon="circle-slash"
clearLabel={t.sidebar.projects.noColor}
onChange={color => setSessionColorOverride(durableId, color)}
swatches={PROFILE_SWATCHES}
value={overrides[durableId] ?? null}
/>
)
}
function useSessionActions({
sessionId,
title,
@ -326,6 +379,15 @@ function useSessionActions({
{openItems.map(item => renderMenuItem(kit.Item, item))}
{openItems.length > 0 && <kit.Separator />}
{identityItems.map(item => renderMenuItem(kit.Item, item))}
<kit.Sub>
<kit.SubTrigger disabled={!sessionId}>
<Codicon name="symbol-color" size="0.875rem" />
<span>{t.sidebar.projects.menuAppearance}</span>
</kit.SubTrigger>
<kit.SubContent className="p-2">
<SessionColorSwatches sessionId={sessionId} />
</kit.SubContent>
</kit.Sub>
<CopyButton
appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
disabled={!sessionId}

View file

@ -4,7 +4,7 @@ import type { ProjectInfo, SessionInfo } from '@/types/hermes'
import { $projects } from './projects'
import { $sessions } from './session'
import { $sessionColorById, sessionColorFor } from './session-color'
import { $sessionColorById, $sessionColorOverrides, sessionColorFor, setSessionColorOverride } from './session-color'
let nextId = 0
@ -48,6 +48,7 @@ function makeProject(id: string, folders: string[], color: null | string): Proje
afterEach(() => {
$sessions.set([])
$projects.set([])
$sessionColorOverrides.set({})
})
describe('$sessionColorById', () => {
@ -86,6 +87,43 @@ describe('$sessionColorById', () => {
})
})
describe('$sessionColorOverrides', () => {
it('an override wins over the inherited project color', () => {
const a = makeSession('/www/app', { git_repo_root: '/www/app' })
$projects.set([makeProject('p_app', ['/www/app'], '#4a9eff')])
$sessions.set([a])
setSessionColorOverride(a.id, '#ff0000')
expect($sessionColorById.get()[a.id]).toBe('#ff0000')
})
it('clearing an override falls back to the project color', () => {
const a = makeSession('/www/app', { git_repo_root: '/www/app' })
$projects.set([makeProject('p_app', ['/www/app'], '#4a9eff')])
$sessions.set([a])
setSessionColorOverride(a.id, '#ff0000')
expect($sessionColorById.get()[a.id]).toBe('#ff0000')
setSessionColorOverride(a.id, null)
expect($sessionColorById.get()[a.id]).toBe('#4a9eff')
})
it('keys on the durable lineage id so a color survives compression', () => {
// The live id rotates on auto-compression; the override is stored against the
// lineage root, so the continuation tip still resolves to the same color.
const root = makeSession('/x', { id: 'root' })
const tip = makeSession('/x', { id: 'tip', _lineage_root_id: 'root' })
setSessionColorOverride('root', '#abcdef')
$sessions.set([tip])
expect($sessionColorById.get().tip).toBe('#abcdef')
})
})
describe('sessionColorFor', () => {
it('reads a single session through the same shared map', () => {
const a = makeSession('/www/app', { git_repo_root: '/www/app' })

View file

@ -1,33 +1,60 @@
import { computed } from 'nanostores'
import { sessionProjectColor } from '@/app/chat/sidebar/projects/workspace-groups'
import { Codecs, persistentAtom } from '@/lib/persisted'
import { $projects } from '@/store/projects'
import { $sessions } from '@/store/session'
import { $sessions, sessionPinId } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
// Per-session color OVERRIDES — a user-picked color that wins over the inherited
// project color (#66565 layer 2). Desktop-local like pins, keyed by the DURABLE
// lineage id so a color survives auto-compression's session-id rotation. To take
// this to the TUI later, promote this one atom to a backend SessionInfo.color
// field — the resolver below and the picker UI stay exactly as they are.
export const $sessionColorOverrides = persistentAtom<Record<string, string>>(
'hermes.desktop.sessionColors',
{},
Codecs.stringRecord
)
// Set a session's override (null clears it → falls back to the project color).
export function setSessionColorOverride(durableId: string, color: null | string): void {
const prev = $sessionColorOverrides.get()
if (color) {
$sessionColorOverrides.set({ ...prev, [durableId]: color })
} else if (durableId in prev) {
const next = { ...prev }
delete next[durableId]
$sessionColorOverrides.set(next)
}
}
// 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
// surfaces can never drift. Recomputed only when the session list, projects, or
// overrides change (all 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> = {}
// Precedence in one place: an explicit per-session override wins over the
// inherited project color. Agent-set color (#66565 layer 3) slots in here too.
export const $sessionColorById = computed(
[$sessions, $projects, $sessionColorOverrides],
(sessions, projects, overrides) => {
const map: Record<string, string> = {}
for (const session of sessions) {
const color = sessionProjectColor(session, projects)
for (const session of sessions) {
const color = overrides[sessionPinId(session)] ?? sessionProjectColor(session, projects)
if (color) {
map[session.id] = color
if (color) {
map[session.id] = color
}
}
}
return map
})
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).