mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
parent
1b17015f7a
commit
0d2ad3993e
3 changed files with 147 additions and 20 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue