diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f882..b3b0b0e4cb1 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +import { ClientSettingsSchema, EnvironmentId, type ClientSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -18,6 +18,9 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, + environmentAccentColors: { + [EnvironmentId.make("environment-1")]: "#2563eb", + }, favorites: [], glassOpacity: 80, providerModelPreferences: {}, diff --git a/apps/web/src/accentColors.ts b/apps/web/src/accentColors.ts new file mode 100644 index 00000000000..72b60975ac0 --- /dev/null +++ b/apps/web/src/accentColors.ts @@ -0,0 +1,26 @@ +/** + * Shared accent-color primitives. + * + * Accent colors are user-chosen hex strings used to tell otherwise identical + * UI affordances apart — provider instances in picker rails, environments in + * the sidebar. They are always stored normalized so rendering can treat a + * present value as directly usable in CSS. + */ + +export const ACCENT_COLOR_SWATCHES = [ + "#2563eb", + "#16a34a", + "#ea580c", + "#dc2626", + "#7c3aed", + "#0891b2", +] as const; + +export const FALLBACK_ACCENT_COLOR = ACCENT_COLOR_SWATCHES[0]; + +/** Accept only `#rrggbb`; anything else (including "") reads as "no color". */ +export function normalizeAccentColor(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined; +} diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index e4ed54758ff..036a0e31f44 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -2,6 +2,12 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { CloudIcon, MonitorIcon } from "lucide-react"; import { memo, useMemo } from "react"; +import { + environmentAccentStyle, + resolveEnvironmentAccentColor, + useEnvironmentAccentColors, + type EnvironmentAccentColors, +} from "../environmentAccentColors"; import type { EnvironmentOption } from "./BranchToolbar.logic"; import { Select, @@ -22,12 +28,25 @@ interface BranchToolbarEnvironmentSelectorProps { onEnvironmentChange?: (environmentId: EnvironmentId) => void; } +function EnvironmentIcon({ + accentColors, + environment, +}: { + readonly accentColors: EnvironmentAccentColors; + readonly environment: EnvironmentOption | null; +}) { + const accentColor = resolveEnvironmentAccentColor(accentColors, environment?.environmentId); + const Icon = environment?.isPrimary ? MonitorIcon : CloudIcon; + return ; +} + export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ envLocked, environmentId, availableEnvironments, onEnvironmentChange, }: BranchToolbarEnvironmentSelectorProps) { + const accentColors = useEnvironmentAccentColors(); const activeEnvironment = useMemo(() => { return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null; }, [availableEnvironments, environmentId]); @@ -44,11 +63,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir if (envLocked || onEnvironmentChange === undefined) { return ( - {activeEnvironment?.isPrimary ? ( - - ) : ( - - )} + {activeEnvironment?.label ?? "Run on"} ); @@ -67,11 +82,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir className="min-w-0 max-w-full font-medium" aria-label="Run on" > - {activeEnvironment?.isPrimary ? ( - - ) : ( - - )} + @@ -80,11 +91,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir {availableEnvironments.map((env) => ( - {env.isPrimary ? ( - - ) : ( - - )} + {env.label} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b05b3a39d90..bd0f49bbfef 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -191,6 +191,12 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { + environmentAccentStyle, + resolveSharedEnvironmentAccentColor, + useEnvironmentAccentColor, + useEnvironmentAccentColors, +} from "~/environmentAccentColors"; import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, @@ -396,6 +402,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) : null; + const threadEnvironmentAccentColor = useEnvironmentAccentColor(thread.environmentId); // For grouped projects, the thread may belong to a different environment // than the representative project. Look up the thread's own project cwd // so git status (and thus PR detection) queries the correct path. @@ -835,7 +842,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr /> } > - + {threadEnvironmentLabel} @@ -1100,6 +1110,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec (settings) => settings.confirmThreadArchive, ); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const environmentAccentColors = useEnvironmentAccentColors(); + const remoteEnvironmentAccentColor = resolveSharedEnvironmentAccentColor( + environmentAccentColors, + project.remoteEnvironmentIds, + ); + const remoteEnvironmentAccentStyle = environmentAccentStyle(remoteEnvironmentAccentColor); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); @@ -2287,9 +2303,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } > {project.allRemoteMembersAreDesktopLocal ? ( - + ) : ( - + )} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ed9eadd907b..a6cdc931783 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -12,7 +12,11 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { + EnvironmentId, + ScopedThreadRef, + SidebarProjectGroupingMode, +} from "@t3tools/contracts"; import { AlarmClockIcon, AlarmClockOffIcon, @@ -86,6 +90,7 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; +import { environmentAccentStyle, useEnvironmentAccentColor } from "../environmentAccentColors"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; @@ -216,6 +221,17 @@ function WorkingDuration(props: { startedAt: string | null }) { return {formatWorkingDurationLabel(Date.now() - startedMs)}; } +/** Server glyph tinted with the environment's accent color, when one is set. */ +function EnvironmentServerIcon({ environmentId }: { readonly environmentId: EnvironmentId }) { + const accentColor = useEnvironmentAccentColor(environmentId); + return ( + + ); +} + function SidebarV2ThreadTooltip({ thread, projectTitle, @@ -261,7 +277,7 @@ function SidebarV2ThreadTooltip({ ) : null} {environmentLabel ? (
- +
{environmentLabel}
) : null} @@ -525,6 +541,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const isRemote = props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + const environmentAccentColor = useEnvironmentAccentColor(thread.environmentId); const detailsTooltip = ( {isRemote ? ( - + ) : null} @@ -2590,7 +2610,7 @@ export default function SidebarV2() { />
- +

{member.environmentLabel ?? "Current environment"}

diff --git a/apps/web/src/components/settings/ProviderAccentColorPicker.tsx b/apps/web/src/components/settings/AccentColorPicker.tsx similarity index 89% rename from apps/web/src/components/settings/ProviderAccentColorPicker.tsx rename to apps/web/src/components/settings/AccentColorPicker.tsx index d352257257a..7d6c6538fb9 100644 --- a/apps/web/src/components/settings/ProviderAccentColorPicker.tsx +++ b/apps/web/src/components/settings/AccentColorPicker.tsx @@ -6,26 +6,19 @@ import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } import { ColorSelector } from "../color-selector"; import { Button } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { normalizeProviderAccentColor } from "../../providerInstances"; +import { + ACCENT_COLOR_SWATCHES, + FALLBACK_ACCENT_COLOR, + normalizeAccentColor, +} from "../../accentColors"; import { cn } from "../../lib/utils"; -const PROVIDER_ACCENT_SWATCHES = [ - "#2563eb", - "#16a34a", - "#ea580c", - "#dc2626", - "#7c3aed", - "#0891b2", -] as const; - -const FALLBACK_ACCENT_COLOR = PROVIDER_ACCENT_SWATCHES[0]; - function clamp(value: number, min = 0, max = 1) { return Math.min(max, Math.max(min, value)); } function hexToHsv(hex: string) { - const normalized = normalizeProviderAccentColor(hex) ?? FALLBACK_ACCENT_COLOR; + const normalized = normalizeAccentColor(hex) ?? FALLBACK_ACCENT_COLOR; const numeric = Number.parseInt(normalized.slice(1), 16); const red = ((numeric >> 16) & 255) / 255; const green = ((numeric >> 8) & 255) / 255; @@ -80,7 +73,7 @@ function hsvToHex(hue: number, saturation: number, value: number) { .join("")}`; } -function ProviderCustomColorPanel(props: { +function CustomColorPanel(props: { readonly value: string; readonly onCommit: (value: string) => void; }) { @@ -178,13 +171,13 @@ function ProviderCustomColorPanel(props: { ); } -function ProviderCustomColorPicker(props: { +function CustomColorPicker(props: { readonly displayName: string; readonly value: string | undefined; readonly selected: boolean; readonly onCommit: (value: string) => void; }) { - const normalized = normalizeProviderAccentColor(props.value) ?? FALLBACK_ACCENT_COLOR; + const normalized = normalizeAccentColor(props.value) ?? FALLBACK_ACCENT_COLOR; return ( @@ -216,20 +209,30 @@ function ProviderCustomColorPicker(props: { sideOffset={6} className="overflow-hidden rounded-md p-0 [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0" > - + ); } -export function ProviderAccentColorPicker(props: { +export function AccentColorPicker(props: { + /** Name of the thing being colored; used to disambiguate the aria labels. */ readonly displayName: string; readonly value: string | undefined; readonly onCommit: (value: string) => void; + /** Heading above the swatches. Pass `null` where the surrounding UI already names it. */ + readonly label?: string | null; readonly description?: string; readonly commitDelayMs?: number; }) { - const { commitDelayMs = 0, description, displayName, onCommit, value } = props; + const { + commitDelayMs = 0, + description, + displayName, + label = "Accent color", + onCommit, + value, + } = props; const [optimisticValue, setOptimisticValue] = useState(() => value ?? ""); const commitTimeoutRef = useRef | null>(null); const pendingCommitRef = useRef(null); @@ -258,7 +261,7 @@ export function ProviderAccentColorPicker(props: { const commitAccentColor = useCallback( (value: string) => { - const normalizedValue = normalizeProviderAccentColor(value) ?? ""; + const normalizedValue = normalizeAccentColor(value) ?? ""; setOptimisticValue(normalizedValue); if (commitDelayMs <= 0) { @@ -287,19 +290,19 @@ export function ProviderAccentColorPicker(props: { [commitDelayMs, onCommit], ); - const normalized = normalizeProviderAccentColor(optimisticValue); + const normalized = normalizeAccentColor(optimisticValue); const selectedValue = normalized && - PROVIDER_ACCENT_SWATCHES.includes(normalized as (typeof PROVIDER_ACCENT_SWATCHES)[number]) + ACCENT_COLOR_SWATCHES.includes(normalized as (typeof ACCENT_COLOR_SWATCHES)[number]) ? normalized : ""; const customSelected = Boolean(normalized && selectedValue === ""); return (
- Accent color + {label === null ? null : {label}}
-
+ {isWslEnvironment ? ( ); + const primaryAccentColorRow = + primaryEnvironmentId === null ? null : ( + + } + /> + ); + return ( {canManageLocalBackend ? ( @@ -3003,6 +3019,7 @@ export function ConnectionsSettings() { } /> ) : null} + {primaryAccentColorRow} {desktopBridge ? ( <> {renderNetworkAccessRow()} @@ -3311,6 +3328,7 @@ export function ConnectionsSettings() { title="Administrative access" description="Pairing links and client-session management require the access:write scope for this backend." /> + {primaryAccentColorRow} )} diff --git a/apps/web/src/components/settings/EnvironmentAccentColorControl.tsx b/apps/web/src/components/settings/EnvironmentAccentColorControl.tsx new file mode 100644 index 00000000000..15c431ec2bd --- /dev/null +++ b/apps/web/src/components/settings/EnvironmentAccentColorControl.tsx @@ -0,0 +1,62 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { PaletteIcon } from "lucide-react"; +import { memo, useCallback } from "react"; + +import { + useEnvironmentAccentColor, + useSetEnvironmentAccentColor, +} from "../../environmentAccentColors"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { AccentColorPicker } from "./AccentColorPicker"; + +/** + * Compact accent-color control for one environment: a swatch that opens the + * shared picker. Environment rows are dense and there can be many of them, so + * the swatch row itself lives in a popover instead of expanding every row. + */ +export const EnvironmentAccentColorControl = memo(function EnvironmentAccentColorControl({ + environmentId, + label, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; +}) { + const accentColor = useEnvironmentAccentColor(environmentId); + const setEnvironmentAccentColor = useSetEnvironmentAccentColor(); + const handleCommit = useCallback( + (value: string) => { + setEnvironmentAccentColor(environmentId, value); + }, + [environmentId, setEnvironmentAccentColor], + ); + + return ( + + + {accentColor ? null : } + + } + /> + + + + + ); +}); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 2a691943df4..065827e9364 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -41,7 +41,7 @@ import type { DriverOption } from "./providerDriverMeta"; import { ProviderSettingsForm } from "./ProviderSettingsForm"; import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; -import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; +import { AccentColorPicker } from "./AccentColorPicker"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { getProviderVersionAdvisoryPresentation, @@ -748,7 +748,7 @@ export function ProviderInstanceCard({
- { + it("returns the stored color for an environment", () => { + expect(resolveEnvironmentAccentColor({ [local]: "#2563eb" }, local)).toBe("#2563eb"); + }); + + it("returns undefined for an environment with no color", () => { + expect(resolveEnvironmentAccentColor({ [local]: "#2563eb" }, remote)).toBeUndefined(); + }); + + it("returns undefined without an environment or a settings entry", () => { + expect(resolveEnvironmentAccentColor(undefined, local)).toBeUndefined(); + expect(resolveEnvironmentAccentColor({ [local]: "#2563eb" }, null)).toBeUndefined(); + }); + + it("ignores values that are not usable hex colors", () => { + expect(resolveEnvironmentAccentColor({ [local]: "rebeccapurple" }, local)).toBeUndefined(); + expect(resolveEnvironmentAccentColor({ [local]: "#25f" }, local)).toBeUndefined(); + }); +}); + +describe("resolveSharedEnvironmentAccentColor", () => { + it("returns the color shared by every environment", () => { + expect( + resolveSharedEnvironmentAccentColor({ [local]: "#2563eb", [remote]: "#2563eb" }, [ + local, + remote, + ]), + ).toBe("#2563eb"); + }); + + it("returns undefined when the environments disagree", () => { + expect( + resolveSharedEnvironmentAccentColor({ [local]: "#2563eb", [remote]: "#16a34a" }, [ + local, + remote, + ]), + ).toBeUndefined(); + }); + + it("returns undefined when only some environments are colored", () => { + expect( + resolveSharedEnvironmentAccentColor({ [local]: "#2563eb" }, [local, other]), + ).toBeUndefined(); + }); + + it("returns undefined for an empty environment set", () => { + expect(resolveSharedEnvironmentAccentColor({ [local]: "#2563eb" }, [])).toBeUndefined(); + }); +}); + +describe("environmentAccentStyle", () => { + it("tints the glyph's fill by default", () => { + expect(environmentAccentStyle("#2563eb")).toEqual({ color: "#2563eb" }); + }); + + it("tints stroke-drawn glyphs through the stroke property", () => { + expect(environmentAccentStyle("#2563eb", "stroke")).toEqual({ stroke: "#2563eb" }); + }); + + it("leaves the default treatment in place for an uncolored environment", () => { + expect(environmentAccentStyle(undefined)).toBeUndefined(); + expect(environmentAccentStyle(undefined, "stroke")).toBeUndefined(); + }); +}); + +describe("applyEnvironmentAccentColor", () => { + it("sets a color without disturbing the other environments", () => { + expect(applyEnvironmentAccentColor({ [local]: "#2563eb" }, remote, "#16a34a")).toEqual({ + [local]: "#2563eb", + [remote]: "#16a34a", + }); + }); + + it("replaces an existing color", () => { + expect(applyEnvironmentAccentColor({ [local]: "#2563eb" }, local, "#dc2626")).toEqual({ + [local]: "#dc2626", + }); + }); + + it("removes the key when the color is cleared", () => { + expect( + applyEnvironmentAccentColor({ [local]: "#2563eb", [remote]: "#16a34a" }, local, ""), + ).toEqual({ [remote]: "#16a34a" }); + expect(applyEnvironmentAccentColor({ [local]: "#2563eb" }, local, undefined)).toEqual({}); + }); + + it("drops values that are not usable hex colors instead of storing them", () => { + expect(applyEnvironmentAccentColor({ [local]: "#2563eb" }, local, "not-a-color")).toEqual({}); + }); + + it("does not mutate the settings it was given", () => { + const accentColors = { [local]: "#2563eb" }; + applyEnvironmentAccentColor(accentColors, local, undefined); + expect(accentColors).toEqual({ [local]: "#2563eb" }); + }); +}); diff --git a/apps/web/src/environmentAccentColors.ts b/apps/web/src/environmentAccentColors.ts new file mode 100644 index 00000000000..147c892789c --- /dev/null +++ b/apps/web/src/environmentAccentColors.ts @@ -0,0 +1,121 @@ +/** + * Per-environment accent colors. + * + * Every environment indicator in the app (the sidebar cloud/server glyphs, the + * "Run on" selector, the project remote badge) is otherwise identical across + * environments, so a user running threads on several machines cannot tell at a + * glance *which* one a thread belongs to without hovering. Assigning a color + * per environment differentiates those existing glyphs without adding any + * text, which matters because sidebar rows have no horizontal room to spare. + * + * The mapping lives in client settings rather than server settings: it is a + * property of how *this* client distinguishes its environments, it must stay + * readable while an environment is disconnected, and no single backend should + * dictate it to the others. + * + * @module environmentAccentColors + */ +import type { ClientSettings, EnvironmentId } from "@t3tools/contracts"; +import { useCallback, type CSSProperties } from "react"; + +import { normalizeAccentColor } from "./accentColors"; +import { useClientSettings, useUpdateClientSettingsWith } from "./hooks/useSettings"; + +export type EnvironmentAccentColors = ClientSettings["environmentAccentColors"]; + +/** + * Read one environment's color, dropping anything that is not a usable hex + * value so callers can pass the result straight to CSS. + */ +export function resolveEnvironmentAccentColor( + accentColors: EnvironmentAccentColors | undefined, + environmentId: EnvironmentId | null | undefined, +): string | undefined { + if (environmentId === null || environmentId === undefined) return undefined; + return normalizeAccentColor(accentColors?.[environmentId]); +} + +/** + * Resolve the shared color for a set of environments: a mixed or uncolored set + * has no single color to show, so indicators that stand for several + * environments at once (a grouped project's remote badge) stay neutral. + */ +export function resolveSharedEnvironmentAccentColor( + accentColors: EnvironmentAccentColors | undefined, + environmentIds: readonly EnvironmentId[], +): string | undefined { + const colors = new Set( + environmentIds.map((environmentId) => + resolveEnvironmentAccentColor(accentColors, environmentId), + ), + ); + const [color] = [...colors]; + return colors.size === 1 ? color : undefined; +} + +/** + * Set or clear one environment's color, leaving the rest of the map untouched. + * Clearing removes the key so the settings blob does not accumulate empties. + */ +export function applyEnvironmentAccentColor( + accentColors: EnvironmentAccentColors | undefined, + environmentId: EnvironmentId, + color: string | undefined, +): Record { + const next: Record = { ...accentColors }; + const normalized = normalizeAccentColor(color); + if (normalized === undefined) { + delete next[environmentId]; + } else { + next[environmentId] = normalized; + } + return next; +} + +/** + * Inline style that tints an environment glyph, or `undefined` to leave the + * glyph's default muted treatment in place. Returned as an inline style rather + * than a class so it can override the utility class already on the element; + * icons drawn with a stroke need `stroke` instead of `color`. + */ +export function environmentAccentStyle( + accentColor: string | undefined, + property: "color" | "stroke" = "color", +): CSSProperties | undefined { + if (accentColor === undefined) return undefined; + return property === "stroke" ? { stroke: accentColor } : { color: accentColor }; +} + +export function useEnvironmentAccentColors(): EnvironmentAccentColors { + return useClientSettings((settings) => settings.environmentAccentColors); +} + +export function useEnvironmentAccentColor( + environmentId: EnvironmentId | null | undefined, +): string | undefined { + const accentColors = useEnvironmentAccentColors(); + return resolveEnvironmentAccentColor(accentColors, environmentId); +} + +export function useSetEnvironmentAccentColor(): ( + environmentId: EnvironmentId, + color: string | undefined, +) => void { + // Derived from the settings in effect at call time, not at render time: the + // picker commits on a delay, so two environments recolored in quick + // succession would otherwise both start from the same stale map and the + // second write would drop the first environment's color. + const updateSettings = useUpdateClientSettingsWith(); + return useCallback( + (environmentId, color) => { + updateSettings((settings) => ({ + environmentAccentColors: applyEnvironmentAccentColor( + settings.environmentAccentColors, + environmentId, + color, + ), + })); + }, + [updateSettings], + ); +} diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 17d86ca0912..1f4a77f7e68 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -281,6 +281,25 @@ describe("environment grouping", () => { ); }); + it("reports the remote environments backing a group's remote labels", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const [group] = buildSidebarProjectSnapshots({ + projects: [primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === remoteEnvironmentId ? "ryzen-shine" : null, + }); + + expect(group?.remoteEnvironmentIds).toEqual([remoteEnvironmentId]); + expect(group?.remoteEnvironmentLabels).toEqual(["ryzen-shine"]); + }); + it("builds one picker entry per logical project and targets the preferred environment", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index fc0563b4ac6..a59aa28d486 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -142,6 +142,19 @@ function persistClientSettings(settings: ClientSettings): void { }); } +function updateClientSettings( + deriveSettings: (settings: ClientSettings) => ClientSettings, +): void { + if (!clientSettingsHydrated) { + void hydrateClientSettings().then(() => { + updateClientSettings(deriveSettings); + }); + return; + } + + persistClientSettings(deriveSettings(getClientSettingsSnapshot())); +} + // ── Key sets for routing patches ───────────────────────────────────── const SERVER_SETTINGS_KEYS = new Set(Struct.keys(ServerSettings.fields)); @@ -261,10 +274,10 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { } if (Object.keys(clientPatch).length > 0) { - persistClientSettings({ - ...getClientSettingsSnapshot(), + updateClientSettings((settings) => ({ + ...settings, ...clientPatch, - }); + })); } }, [environmentId, persistServerSettings], @@ -283,10 +296,28 @@ export function useUpdatePrimarySettings() { export function useUpdateClientSettings() { return useCallback((patch: ClientSettingsPatch) => { - persistClientSettings({ - ...getClientSettingsSnapshot(), + updateClientSettings((settings) => ({ + ...settings, ...patch, - }); + })); + }, []); +} + +/** + * Client-settings updater whose patch is derived from the settings in effect at + * call time rather than at render time. + * + * Use it whenever the new value is computed from the old one — merging one key + * into a record, toggling a flag. Deriving from a render-time snapshot loses + * writes when two updates run before React re-renders, because both start from + * the same stale value and the second overwrites the first. + */ +export function useUpdateClientSettingsWith() { + return useCallback((derivePatch: (settings: ClientSettings) => ClientSettingsPatch) => { + updateClientSettings((settings) => ({ + ...settings, + ...derivePatch(settings), + })); }, []); } diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 337e68d44d0..23c7d9963ce 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -25,6 +25,7 @@ import { type ServerProviderState, } from "@t3tools/contracts"; +import { normalizeAccentColor } from "./accentColors"; import { formatProviderDriverKindLabel } from "./providerModels"; /** @@ -110,9 +111,7 @@ function driverKindLabel(driverKind: ProviderDriverKind): string { } export function normalizeProviderAccentColor(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) return undefined; - return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined; + return normalizeAccentColor(value); } /** diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 32299b565f4..0c4603b0d10 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -29,6 +29,10 @@ export interface SidebarProjectSnapshot extends Project { memberProjects: readonly SidebarProjectGroupMember[]; memberProjectRefs: readonly ScopedProjectRef[]; remoteEnvironmentLabels: readonly string[]; + // Environments backing `remoteEnvironmentLabels`, in the same order. The + // header badge stands for all of them at once, so it can only take on an + // accent color when they agree on one. + remoteEnvironmentIds: readonly EnvironmentId[]; } export interface SidebarProjectPickerEntry { @@ -191,6 +195,11 @@ export function buildSidebarProjectSnapshots(input: { const remoteEnvironmentLabels = remoteMembers .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) .filter((label, index, labels) => labels.indexOf(label) === index); + const remoteEnvironmentIds = remoteMembers + .map((member) => member.environmentId) + .filter( + (environmentId, index, environmentIds) => environmentIds.indexOf(environmentId) === index, + ); const isDesktopLocal = input.isDesktopLocalEnvironment ?? (() => false); const allRemoteMembersAreDesktopLocal = remoteMembers.length > 0 && @@ -213,6 +222,7 @@ export function buildSidebarProjectSnapshots(input: { memberProjects: members, memberProjectRefs: projectRefsByLogicalKey.get(logicalKey) ?? [], remoteEnvironmentLabels, + remoteEnvironmentIds, }); } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4c6306514f6..fcadde41481 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { EnvironmentId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { DEFAULT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; @@ -67,6 +67,14 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Per-environment accent colors, keyed by environment id. Client-local + // rather than server-backed: the color identifies an environment from *this* + // client's point of view, so it has to be readable even while that + // environment is disconnected, and it should not be dictated to other + // clients by whichever backend happens to be primary. + environmentAccentColors: Schema.Record(EnvironmentId, TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), @@ -627,6 +635,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + environmentAccentColors: Schema.optionalKey(Schema.Record(EnvironmentId, TrimmedNonEmptyString)), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey(