From 5d89349ccdaceeaa1554fed40993835763de4069 Mon Sep 17 00:00:00 2001 From: "afzal.hossain" Date: Tue, 28 Apr 2026 15:33:53 +0200 Subject: [PATCH 1/4] Add sidebar project colors setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in "Sidebar project colors" client setting that tints each visible project group with a muted hue and renders a clickable color dot in the project header for picking a custom color. - New `SidebarProjectColor` literal in `@t3tools/contracts/settings`, plus `sidebarProjectColorizing` toggle and `sidebarProjectColorOverrides` record on `ClientSettings` (and the patch shape). - `sidebarProjectColors.ts` owns the palette → Tailwind class mapping, the FNV-1a auto-color hash, and `buildSidebarProjectColorMap`, which spreads auto-colors across the palette so adjacent projects don't share a hue until the palette is exhausted (with explicit overrides claiming their slot first). - Sidebar renders the row tint on the group `
  • `, hides the vertical guide line under the thread list while colorizing is on, and adds uniform padding to the colored block. - The color dot uses a transparent interior with a saturated hue-matched ring so it visually shows the row's color while only the border carries the identity. Click to open a swatch popover; "Auto" clears the override. - Restore Defaults clears both the toggle and any saved per-project picks. Co-Authored-By: Claude Opus 4.7 --- .../settings/DesktopClientSettings.test.ts | 4 + apps/web/src/components/Sidebar.tsx | 298 ++++++++++++++++-- .../components/settings/SettingsPanels.tsx | 36 +++ apps/web/src/localApi.test.ts | 8 + apps/web/src/sidebarProjectColors.test.ts | 151 +++++++++ apps/web/src/sidebarProjectColors.ts | 231 ++++++++++++++ packages/contracts/src/settings.ts | 26 ++ 7 files changed, 720 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/sidebarProjectColors.test.ts create mode 100644 apps/web/src/sidebarProjectColors.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..4c9e99e6de1 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -24,6 +24,10 @@ const clientSettings: ClientSettings = { sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", }, + sidebarProjectColorizing: false, + sidebarProjectColorOverrides: { + "environment-1:/tmp/project-a": "indigo", + }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e8c5bbe0b11..82efc6d485f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -56,6 +56,7 @@ import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/ import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, + type SidebarProjectColor, type SidebarProjectSortOrder, type SidebarThreadPreviewCount, type SidebarThreadSortOrder, @@ -64,7 +65,7 @@ import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform, newCommandId } from "../lib/utils"; +import { cn, isMacPlatform, newCommandId } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, @@ -125,6 +126,7 @@ import { Input } from "./ui/input"; import { Menu, MenuGroup, + MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, @@ -197,6 +199,12 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; +import { + buildSidebarProjectColorMap, + EMPTY_SIDEBAR_PROJECT_COLOR_MAP, + SIDEBAR_PROJECT_COLOR_PALETTE, + type SidebarProjectColorIdentity, +} from "../sidebarProjectColors"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -246,6 +254,109 @@ function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): strin } } +interface ProjectColorDotProps { + identity: SidebarProjectColorIdentity; + projectName: string; + onSelect: (color: SidebarProjectColor | null) => void; + /** + * Extra classes for the trigger itself — typically the absolute positioning + * applied by the parent. Putting positioning on the trigger directly (rather + * than a wrapping div) keeps this dot's box dimensions identical to the + * sibling new-thread button so they line up vertically. + */ + className?: string; +} + +/** + * Always-visible color dot rendered to the left of the per-project new-thread + * button when "Sidebar project colors" is enabled. Clicking opens a palette + * menu; selecting a swatch persists the override; selecting "Auto" clears it. + */ +const ProjectColorDot = memo(function ProjectColorDot({ + identity, + projectName, + onSelect, + className, +}: ProjectColorDotProps) { + return ( + + + { + // The wrapping project header treats unhandled pointerdown as + // the start of a row drag. Stop the event so opening the + // picker doesn't also begin a sortable drag. + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + }} + /> + } + > + + + {identity.override + ? `Color: ${identity.palette.label}` + : `Color: ${identity.palette.label} (auto)`} + + + + +
    + Project color +
    + onSelect(null)} + className={cn( + "min-h-7 py-1 sm:text-xs", + identity.override === undefined && "font-medium", + )} + > + + + {SIDEBAR_PROJECT_COLOR_PALETTE.map((entry) => { + const isSelected = identity.override === entry.key; + return ( + onSelect(entry.key)} + className={cn("min-h-7 py-1 sm:text-xs", isSelected && "font-medium")} + > + + ); + })} +
    +
    +
    + ); +}); + function buildThreadJumpLabelMap(input: { keybindings: ReturnType; platform: string; @@ -763,6 +874,12 @@ interface SidebarProjectThreadListProps { openPrLink: (event: React.MouseEvent, prUrl: string) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; + /** + * When true, hides the vertical guide line on the left of the thread list. + * Used to declutter the sidebar when project tints provide their own + * grouping cue. + */ + hideThreadListBorder: boolean; } const SidebarProjectThreadList = memo(function SidebarProjectThreadList( @@ -802,6 +919,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( openPrLink, expandThreadListForProject, collapseThreadListForProject, + hideThreadListBorder, } = props; const showMoreButtonRender = useMemo(() =>