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 (
+
+ )
+ }
+
const variant = DOT_VARIANTS[dotState]
return (