From 5a6e235833bfa296a9e5d6eeb68080b00314d839 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 00:26:34 -0400 Subject: [PATCH] feat(desktop): inherit project color on session rows Sessions that belong to a colored project now pick up that color as the sidebar row's idle lead dot, so work/personal/project buckets are legible at a glance (Layer 1 of #66565). Derived from the same project membership the sidebar already groups by; active states (working / needs-input / background / unread) still own the dot so the tint never fights an attention cue. --- .../sidebar/projects/workspace-groups.test.ts | 41 +++++++++++++++++++ .../chat/sidebar/projects/workspace-groups.ts | 20 +++++++++ .../src/app/chat/sidebar/session-row.tsx | 39 ++++++++++++++++-- 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts index f4ca8424d2f..213bff09ba5 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts @@ -10,6 +10,7 @@ import { mergeRepoWorktreeGroups, overlayLiveLanes, overlayLivePreviews, + sessionProjectColor, type SidebarProjectTree, type SidebarSessionGroup, sortWorktreeGroups @@ -519,6 +520,46 @@ 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('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 04e18f7c7b9..afeb230c59f 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -396,6 +396,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 bf63369c403..72b32a9b044 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -15,11 +15,13 @@ 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 { $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' @@ -91,6 +93,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)) // 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 +245,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 +280,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 +295,7 @@ function SessionRowLeadDot({ {branchStem} ) : null} - + ) } @@ -348,9 +356,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 ( +