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.
This commit is contained in:
Brooklyn Nicholson 2026-07-19 00:26:34 -04:00
parent 667b98b5cf
commit 5a6e235833
3 changed files with 96 additions and 4 deletions

View file

@ -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({

View file

@ -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)

View file

@ -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}
/>
</SidebarRowGrab>
) : (
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} />
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} projectColor={projectColor} />
</SidebarRowLead>
)}
{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 (
<span className={cn('flex items-center gap-0.5', className)}>
@ -287,7 +295,7 @@ function SessionRowLeadDot({
{branchStem}
</span>
) : null}
<SidebarRowDot dotState={dotState} />
<SidebarRowDot dotState={dotState} projectColor={projectColor} />
</span>
)
}
@ -348,9 +356,32 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
}
}
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 (
<span
aria-hidden="true"
className={cn('size-1 rounded-full', className)}
style={{ backgroundColor: projectColor }}
/>
)
}
const variant = DOT_VARIANTS[dotState]
return (