mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
5a6e235833
commit
897f3da276
8 changed files with 178 additions and 10 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
103
apps/desktop/src/store/session-color.test.ts
Normal file
103
apps/desktop/src/store/session-color.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
36
apps/desktop/src/store/session-color.ts
Normal file
36
apps/desktop/src/store/session-color.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue