-
+
{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(