Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions apps/desktop/src/app/chat/sidebar/session-row.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { SessionInfo } from '@/hermes'
import { $projectTree } from '@/store/projects'

import { SidebarSessionRow } from './session-row'

// The row pulls a lot of live wiring; stub the seams that need a gateway.
vi.mock('./use-profile-prewarm', () => ({
useProfilePrewarm: () => ({ cancelPrewarm: vi.fn(), startPrewarm: vi.fn() })
}))

vi.mock('@/store/windows', () => ({
canOpenSessionWindow: () => false,
// layout.ts reads this at import time (window-scoped store init).
isSecondaryWindow: () => false,
openSessionInNewWindow: vi.fn()
}))

const session = (over: Partial<SessionInfo>): SessionInfo =>
({
cwd: '/Users/s/work/api',
ended_at: null,
id: 'ses_1',
input_tokens: 0,
is_active: true,
last_active: 0,
message_count: 1,
output_tokens: 0,
started_at: 0,
title: 'A session',
...over
}) as SessionInfo

const noop = () => undefined

function renderRow(over: Partial<SessionInfo> = {}, isWorking = false) {
return render(
<SidebarSessionRow
isPinned={false}
isSelected={false}
isWorking={isWorking}
onArchive={noop}
onDelete={noop}
onPin={noop}
onResume={noop}
session={session(over)}
/>
)
}

/** The lead dot: the row's first status span (idle dots carry no role). */
const leadDot = (container: HTMLElement) =>
container.querySelector('[data-row-actions]')?.parentElement?.querySelector('span.rounded-full') ??
container.querySelector('span.rounded-full')

describe('SidebarSessionRow project color', () => {
beforeEach(() => {
$projectTree.set([
{
archived: false,
color: '#4a9eff',
icon: null,
id: 'work',
label: 'work',
path: '/Users/s/work',
repos: [],
sessionCount: 1
}
] as never)
})

afterEach(() => {
cleanup()
$projectTree.set([])
})

it('tints the idle dot with the owning project color', () => {
const { container } = renderRow()
const dot = leadDot(container)

expect(dot).toBeTruthy()
expect((dot as HTMLElement).style.backgroundColor).toBe('rgb(74, 158, 255)')
})

it('leaves sessions outside any colored project untinted', () => {
const { container } = renderRow({ cwd: '/Users/s/elsewhere' })
const dot = leadDot(container)

expect(dot).toBeTruthy()
expect((dot as HTMLElement).style.backgroundColor).toBe('')
})

it('never tints a working session — status color wins', () => {
const { container } = renderRow({}, true)
const running = screen.getByRole('status')

expect(running.style.backgroundColor).toBe('')
// And the working dot keeps its accent class.
expect(running.className).toContain('bg-(--ui-accent)')
expect(container.querySelector('[style*="background-color"]')).toBeNull()
})
})
27 changes: 24 additions & 3 deletions apps/desktop/src/app/chat/sidebar/session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $projectColorIndex, projectColorForCwd } from '@/store/projects'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
Expand Down Expand Up @@ -91,6 +92,10 @@ 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)
// Owning project's color (user-set in the project menu) — tints the IDLE dot
// only, so an at-a-glance "blue = work" read never competes with status.
const colorIndex = useStore($projectColorIndex)
const projectColor = projectColorForCwd(colorIndex, session.cwd)

// Resolve the dot's display state once — the four signals are mutually
// exclusive by priority, so threading them as booleans through wrappers just
Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -274,10 +280,12 @@ type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'worki
function SessionRowLeadDot({
branchStem,
dotState = 'idle',
projectColor,
className
}: {
branchStem?: string
dotState?: SessionDotState
projectColor?: null | string
className?: string
}) {
return (
Expand All @@ -287,7 +295,7 @@ function SessionRowLeadDot({
{branchStem}
</span>
) : null}
<SidebarRowDot dotState={dotState} />
<SidebarRowDot dotState={dotState} projectColor={projectColor} />
</span>
)
}
Expand Down Expand Up @@ -348,16 +356,29 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
}
}

function SidebarRowDot({ dotState, className }: { dotState: SessionDotState; className?: string }) {
function SidebarRowDot({
dotState,
projectColor,
className
}: {
dotState: SessionDotState
projectColor?: null | string
className?: string
}) {
const { t } = useI18n()
const r = t.sidebar.row
const variant = DOT_VARIANTS[dotState]
// Project tint applies to the idle dot ONLY: status colors are semantic
// (amber = your turn, accent = running, green = unread) and always win.
// Inline style, not a class — project colors are arbitrary user values.
const tint = dotState === 'idle' && projectColor ? { backgroundColor: projectColor, opacity: 1 } : undefined

return (
<span
aria-label={variant.ariaLabel?.(r)}
className={cn(variant.className, className)}
role={variant.role}
style={tint}
title={variant.title?.(r)}
/>
)
Expand Down
86 changes: 86 additions & 0 deletions apps/desktop/src/store/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ import { $sidebarAgentsGrouped } from '@/store/layout'

import {
$activeProjectId,
$projectColorIndex,
$projectScope,
$projectsRpcAvailable,
$projectTree,
$worktreeRefreshToken,
ALL_PROJECTS,
createProject,
enterProject,
exitProjectScope,
openProjectCreate,
pickProjectFolder,
projectColorForCwd,
refreshProjects,
refreshWorktrees
} from './projects'
Expand Down Expand Up @@ -194,3 +197,86 @@ describe('projects RPC capability', () => {
)
})
})

describe('project color for session rows', () => {
const tree = (nodes: unknown[]) => $projectTree.set(nodes as never)

const project = (id: string, color: null | string, paths: { root?: string; lanes?: string[] }, archived = false) => ({
archived,
color,
icon: null,
id,
label: id,
path: paths.root ?? null,
repos: (paths.lanes ?? []).length
? [
{
groups: (paths.lanes ?? []).map(lane => ({ id: lane, label: lane, path: lane, sessions: [] })),
id: `${id}-repo`,
label: `${id}-repo`,
path: paths.root ?? null,
sessionCount: 0
}
]
: [],
sessionCount: 0
})

beforeEach(() => {
tree([])
})

it('resolves a session cwd to its owning project color', () => {
tree([project('work', '#4a9eff', { root: '/Users/s/work' })])

expect(projectColorForCwd($projectColorIndex.get(), '/Users/s/work/api')).toBe('#4a9eff')
expect(projectColorForCwd($projectColorIndex.get(), '/Users/s/personal/blog')).toBeNull()
})

it('prefers the longest matching prefix when projects nest', () => {
tree([
project('umbrella', '#111111', { root: '/Users/s/work' }),
project('inner', '#222222', { root: '/Users/s/work/special' })
])

const index = $projectColorIndex.get()

expect(projectColorForCwd(index, '/Users/s/work/special/thing')).toBe('#222222')
expect(projectColorForCwd(index, '/Users/s/work/other')).toBe('#111111')
})

it('matches worktree lanes that live outside the repo root', () => {
tree([project('app', '#7bc86c', { lanes: ['/Users/s/app-worktrees/feature-x'], root: '/Users/s/app' })])

expect(projectColorForCwd($projectColorIndex.get(), '/Users/s/app-worktrees/feature-x/src')).toBe('#7bc86c')
})

it('skips colorless and archived projects', () => {
tree([
project('plain', null, { root: '/Users/s/plain' }),
project('gone', '#333333', { root: '/Users/s/gone' }, true)
])

const index = $projectColorIndex.get()

expect(index).toHaveLength(0)
expect(projectColorForCwd(index, '/Users/s/plain/x')).toBeNull()
expect(projectColorForCwd(index, '/Users/s/gone/x')).toBeNull()
})

it('never matches a sibling directory that shares the prefix string', () => {
tree([project('work', '#4a9eff', { root: '/Users/s/work' })])

// `/Users/s/work-adjacent` starts with the same characters but is not
// under `/Users/s/work` — segment-aware matching must reject it.
expect(projectColorForCwd($projectColorIndex.get(), '/Users/s/work-adjacent/x')).toBeNull()
})

it('handles empty cwd without matching anything', () => {
tree([project('work', '#4a9eff', { root: '/Users/s/work' })])

expect(projectColorForCwd($projectColorIndex.get(), '')).toBeNull()
expect(projectColorForCwd($projectColorIndex.get(), null)).toBeNull()
expect(projectColorForCwd($projectColorIndex.get(), undefined)).toBeNull()
})
})
63 changes: 62 additions & 1 deletion apps/desktop/src/store/projects.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { atom } from 'nanostores'
import { atom, computed } from 'nanostores'

import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
import type { HermesGitBaseBranch, HermesGitBranch } from '@/global'
Expand Down Expand Up @@ -180,6 +180,67 @@ export function projectIdForCwd(cwd: string): null | string {
return best
}

// ── Project colors on session rows ───────────────────────────────────────────
// cwd → owning project's user-set color, for tinting a session's idle dot.
// Same longest-prefix walk as projectIdForCwd, but resolving to the color in
// one pass (project id → color would walk the tree twice), and keyed by the
// tree atom so rows re-tint when a color is edited or the tree refreshes.
//
// VISUAL-ONLY, and deliberately weaker than status: the tint applies to the
// IDLE dot alone. Working/needs-input/unread/background states keep their
// semantic colors — "this session needs you" must never lose to "this session
// is blue" (see #50718 for the adjacent attention-signal work).

function buildCwdColorIndex(tree: SidebarProjectTree[]): Array<{ color: string; len: number; path: string }> {
const index: Array<{ color: string; len: number; path: string }> = []

for (const project of tree) {
const color = (project.color || '').trim()

if (!color || project.archived) {
continue
}

const paths = [project.path, ...project.repos.flatMap(repo => [repo.path, ...repo.groups.map(group => group.path)])]

for (const path of paths) {
const p = (path || '').trim()

if (p) {
index.push({ color, len: p.length, path: p })
}
}
}

// Longest prefix first, so the first hit wins the lookup below.
return index.sort((a, b) => b.len - a.len)
}

/** Sorted (longest-first) path→color index derived from the live tree. */
export const $projectColorIndex = computed($projectTree, buildCwdColorIndex)

/** The owning project's color for a session cwd, else null. Pure lookup —
* callers pass `$projectColorIndex` through `useStore` so rows subscribe to
* one derived atom instead of the whole tree. */
export function projectColorForCwd(
index: ReadonlyArray<{ color: string; len: number; path: string }>,
cwd: null | string | undefined
): null | string {
const target = (cwd || '').trim()

if (!target) {
return null
}

for (const entry of index) {
if (underPath(entry.path, target)) {
return entry.color
}
}

return null
}

// The active session's agent relocated itself (created/entered another repo or
// worktree via the terminal — backend re-anchors its cwd and emits session.info).
// Re-pull projects + tree so a freshly created/auto project and the relocated
Expand Down
Loading