diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index 8e8a968367022..b78189593693f 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -31,6 +31,9 @@ export interface PaneMirror { 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 { /** 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(cfg: PaneMirror): () => void { - const registered = new Map void; title: string }>() + const registered = new Map void; title: string; accent?: string }>() const paneId = (key: string) => `${cfg.prefix}:${key}` const sync = () => { @@ -59,10 +62,11 @@ export function paneMirror(cfg: PaneMirror): () => 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(cfg: PaneMirror): () => void { area: 'panes', title, data: { + accent, dock: { before: cfg.before?.(tile), pane: cfg.anchor?.(tile) ?? 'workspace', @@ -87,7 +92,7 @@ export function paneMirror(cfg: PaneMirror): () => void { render: () => cfg.render(key) }) - registered.set(key, { dispose, title }) + registered.set(key, { dispose, title, accent }) if (!current) { registerPaneCloser(paneId(key), () => cfg.close(key)) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 04ddaf1cc630c..1a9ee67ac0cd0 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -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({ 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({ before: t => t.before, minWidth: '20rem', title: tileTitle, + accent: tileAccent, render: storedSessionId => , tabWrap: (storedSessionId, tab) => ( { 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({ diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 04e18f7c7b95d..7931030feb111 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -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) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index bf63369c40365..a03a37574a0f7 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -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} /> ) : ( - + )} {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 ( @@ -287,7 +294,7 @@ function SessionRowLeadDot({ {branchStem} ) : null} - + ) } @@ -348,9 +355,32 @@ const DOT_VARIANTS: Record = { } } -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 ( +