diff --git a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx index f77b55dac6a4..8c4d9c769390 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx @@ -25,7 +25,7 @@ import { openProjectRename, revealPath, setActiveProject, - updateProject + setProjectAppearance } from '@/store/projects' import type { SidebarProjectTree } from './workspace-groups' @@ -111,6 +111,26 @@ export function ProjectMenu({ } } + // Appearance writes route through the adopt-aware helper: an auto project is + // materialized on its first change (its id then changes), so close the picker + // when that happens to avoid a second write double-creating from a stale node. + const applyAppearance = (patch: { color?: null | string; icon?: null | string }) => { + void setProjectAppearance(project, patch).then(adopted => { + if (adopted) { + setAppearanceOpen(false) + } + }) + } + + // Set color / pick an icon — shown for explicit projects and for auto ones + // (where selecting adopts the repo as a real project so the look sticks). + const appearanceItem = ( + setAppearanceOpen(true)}> + + {p.menuAppearance} + + ) + const trigger = ( @@ -147,16 +167,23 @@ export function ProjectMenu({ onCloseAutoFocus={event => event.preventDefault()} sideOffset={6} > - {!project.isAuto && ( + {project.isAuto ? ( + // Inherited (auto) repos can still be themed — the change adopts the + // repo as a real project. Rename / add-folder / set-active stay out + // until then (they need the materialized record). + project.path ? ( + <> + {appearanceItem} + + + ) : null + ) : ( <> openProjectRename(target)}> {p.menuRename} - setAppearanceOpen(true)}> - - {p.menuAppearance} - + {appearanceItem} openProjectAddFolder(target)}> {p.menuAddFolder} @@ -200,7 +227,7 @@ export function ProjectMenu({ void updateProject(project.id, { color })} + onChange={color => applyAppearance({ color })} swatches={PROFILE_SWATCHES} value={project.color ?? null} /> @@ -215,7 +242,7 @@ export function ProjectMenu({ 'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)', project.icon === name && 'bg-(--ui-control-active-background) text-foreground' )} - onClick={() => void updateProject(project.id, { icon: project.icon === name ? null : name })} + onClick={() => applyAppearance({ icon: project.icon === name ? null : name })} style={project.icon === name && project.color ? { color: project.color } : undefined} type="button" > diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index e5ac6c04c1b1..61ae3319148c 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 @@ -242,11 +246,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 ? ( @@ -276,11 +281,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 ( @@ -289,7 +296,7 @@ function SessionRowLeadDot({ {branchStem} ) : null} - + ) } @@ -350,9 +357,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 ( +