From 0d2ad3993eb91c486854bc71e2721b747ab1d0f4 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sun, 19 Jul 2026 17:05:16 -0400 Subject: [PATCH] feat(desktop): per-session color override (#66565 layer 2) (#67681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/chat/sidebar/session-actions-menu.tsx | 70 +++++++++++++++++-- apps/desktop/src/store/session-color.test.ts | 40 ++++++++++- apps/desktop/src/store/session-color.ts | 57 +++++++++++---- 3 files changed, 147 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index d60f10016fc4..889ebd4e0d6a 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -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 ( + 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 && } {identityItems.map(item => renderMenuItem(kit.Item, item))} + + + + {t.sidebar.projects.menuAppearance} + + + + + { $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' }) diff --git a/apps/desktop/src/store/session-color.ts b/apps/desktop/src/store/session-color.ts index 79426ae2d938..08f5f079448c 100644 --- a/apps/desktop/src/store/session-color.ts +++ b/apps/desktop/src/store/session-color.ts @@ -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>( + '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 = {} +// 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 = {} - 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).