diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 941cb6012091..cb63ba62605a 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -24,6 +24,7 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", + environmentIconColors: {}, favorites: [], fontFamilyCode: "", fontFamilyComposer: "", @@ -37,6 +38,7 @@ const clientSettings: ClientSettings = { glassOpacity: 80, planModeEnabled: false, showSkillsInSlashMenu: false, + showLocalEnvironmentIcon: false, providerModelPreferences: {}, roundedProjectIcons: false, sidebarAutoSettleAfterDays: 3, diff --git a/apps/desktop/src/settings/LastCodeSettingsImport.test.ts b/apps/desktop/src/settings/LastCodeSettingsImport.test.ts index 7d6d0110a3e7..0d4a992a97de 100644 --- a/apps/desktop/src/settings/LastCodeSettingsImport.test.ts +++ b/apps/desktop/src/settings/LastCodeSettingsImport.test.ts @@ -105,6 +105,8 @@ describe("LastCodeSettingsImport", () => { ...DEFAULT_CLIENT_SETTINGS, legacySidebarScale: 75, roundedProjectIcons: true, + environmentIconColors: { primary: "#2563eb", remote: "#7c3aed" }, + showLocalEnvironmentIcon: true, favorites: [{ provider: lastCodeCustom, model: "lastcode-model" }], providerModelPreferences: { [lastCodeCustom]: { hiddenModels: [], modelOrder: ["lastcode-model"] }, @@ -215,6 +217,11 @@ describe("LastCodeSettingsImport", () => { assert.equal(importedClient.fontSizeInterface, 17); assert.equal(importedClient.legacySidebarScale, 75); assert.equal(importedClient.roundedProjectIcons, true); + assert.deepEqual(importedClient.environmentIconColors, { + primary: "#2563eb", + remote: "#7c3aed", + }); + assert.equal(importedClient.showLocalEnvironmentIcon, true); assert.deepEqual(importedClient.favorites, [ { provider: "lastcode_custom", model: "lastcode-model" }, { provider: "codex", model: "gpt-source" }, diff --git a/apps/desktop/src/settings/LastCodeSettingsImport.ts b/apps/desktop/src/settings/LastCodeSettingsImport.ts index 1c608a382724..d21cfdfbd12f 100644 --- a/apps/desktop/src/settings/LastCodeSettingsImport.ts +++ b/apps/desktop/src/settings/LastCodeSettingsImport.ts @@ -236,8 +236,10 @@ function mergeClientSettings(sourceRaw: string, destinationRaw: string | null): ...source, favorites, providerModelPreferences, + environmentIconColors: destination.environmentIconColors, legacySidebarScale: destination.legacySidebarScale, roundedProjectIcons: destination.roundedProjectIcons, + showLocalEnvironmentIcon: destination.showLocalEnvironmentIcon, })}\n`; } diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 248f7747c2f6..95f2dbc9adcf 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2,8 +2,6 @@ import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, - CloudIcon, - ContainerIcon, FolderPlusIcon, Globe2Icon, LoaderIcon, @@ -52,6 +50,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, + type EnvironmentId, ProjectId, type ScopedThreadRef, type ResolvedKeybindingsConfig, @@ -75,6 +74,7 @@ import { useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, + type EnvironmentIconColor, type LegacySidebarScale, type SidebarProjectSortOrder, type SidebarThreadPreviewCount, @@ -127,6 +127,12 @@ import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; import { legacySidebarScaleStyle } from "../legacySidebarScale"; +import { + EnvironmentIcon, + legacyThreadEnvironmentPresentation, + projectEnvironmentIconEntries, + resolveEnvironmentIconColor, +} from "../environmentIcons"; import { useThreadActions } from "../hooks/useThreadActions"; import { projectEnvironment } from "../state/projects"; @@ -334,6 +340,8 @@ function buildThreadJumpLabelMap(input: { interface SidebarThreadRowProps { thread: SidebarThreadSummary; + showLocalEnvironmentIcon: boolean; + configuredEnvironmentIconColor: EnvironmentIconColor | undefined; projectCwd: string | null; providerEntriesByEnvironmentId: ReadonlyMap>; orderedProjectThreadKeys: readonly string[]; @@ -437,15 +445,24 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const remoteEnvLabel = environment?.label ?? null; // A desktop-local secondary backend (e.g. the WSL backend) shows up as a // bearer environment whose connection id is prefixed "local:". It runs on the - // user's own machine, so the cloud icon is misleading — label it "Local" and - // suppress the cloud icon (the project header already shows a container icon + // user's own machine, so a remote-server icon is misleading — label it "Local" and + // suppress the row icon (the project header already shows a container icon // for desktop-local projects, see sidebarProjectGrouping). const isDesktopLocalThread = environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); - const showsRemoteThreadIcon = isRemoteThread && !isDesktopLocalThread; - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) - : null; + const environmentPresentation = legacyThreadEnvironmentPresentation({ + isPrimary: !isRemoteThread, + isDesktopLocal: isDesktopLocalThread, + showLocalEnvironmentIcon: props.showLocalEnvironmentIcon, + environmentLabel: remoteEnvLabel, + }); + const showsThreadEnvironmentIcon = environmentPresentation.showRowIcon; + const threadEnvironmentLabel = environmentPresentation.hoverLabel; + const threadEnvironmentIconKind = environmentPresentation.kind; + const environmentIconColor = resolveEnvironmentIconColor( + props.configuredEnvironmentIconColor, + environment !== null && !isDesktopLocalThread, + ); // 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. @@ -534,6 +551,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr - {showsRemoteThreadIcon && ( - - + {showsThreadEnvironmentIcon ? ( + + + } + > + - } - > - - - {threadEnvironmentLabel} - - )} + + {threadEnvironmentLabel} + + ) : null} + {isConfirmingArchive ? ( @@ -216,7 +218,11 @@ function ProviderCustomColorPicker(props: { sideOffset={6} className="overflow-hidden rounded-md p-0 [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0" > - + ); @@ -226,10 +232,20 @@ export function ProviderAccentColorPicker(props: { readonly displayName: string; readonly value: string | undefined; readonly onCommit: (value: string) => void; + readonly label?: string; + readonly defaultOptionLabel?: string; readonly description?: string; readonly commitDelayMs?: number; }) { - const { commitDelayMs = 0, description, displayName, onCommit, value } = props; + const { + commitDelayMs = 0, + defaultOptionLabel, + description, + displayName, + label = "Accent color", + onCommit, + value, + } = props; const [optimisticValue, setOptimisticValue] = useState(() => value ?? ""); const commitTimeoutRef = useRef | null>(null); const pendingCommitRef = useRef(null); @@ -297,14 +313,27 @@ export function ProviderAccentColorPicker(props: { return (
- Accent color + {label}
+ {defaultOptionLabel ? ( + + {defaultOptionLabel ? null : ( + + )}
{description ? {description} : null}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index ee31b9728e80..f59bb20b8cdd 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -265,6 +265,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Rounded project icons", to: "/settings/lastcode", }, + { + id: "environment-icons", + title: "Environment icons", + to: "/settings/lastcode", + }, { id: "import-t3-settings", title: "Import settings from T3 Code", diff --git a/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx b/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx index dbf259ee4031..53b814e48df6 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx @@ -1,4 +1,5 @@ -import { CircleAlertIcon, GitBranchIcon, ServerIcon, TerminalIcon } from "lucide-react"; +import { CircleAlertIcon, GitBranchIcon, TerminalIcon } from "lucide-react"; +import type { EnvironmentIconColor } from "@t3tools/contracts/settings"; import type { ProviderInstanceEntry } from "../../providerInstances"; import type { SidebarThreadSummary } from "../../types"; @@ -6,6 +7,7 @@ import { cn } from "~/lib/utils"; import { ProjectFavicon } from "../ProjectFavicon"; import type { TerminalStatusIndicator } from "../ThreadStatusIndicators"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { EnvironmentIcon } from "../../environmentIcons"; export interface SidebarThreadHoverContentProps { thread: SidebarThreadSummary; @@ -13,6 +15,8 @@ export interface SidebarThreadHoverContentProps { projectCwd: string | null; projectFaviconPath: string | null; environmentLabel: string | null; + environmentIconKind?: "monitor" | "server"; + environmentIconColor?: EnvironmentIconColor | undefined; providerEntry: ProviderInstanceEntry | null; showInstanceBadge: boolean; modelInstanceId: string; @@ -53,7 +57,12 @@ export function SidebarThreadHoverContent(props: SidebarThreadHoverContentProps) ) : null} {props.environmentLabel ? (
- +
{props.environmentLabel}
) : null} diff --git a/apps/web/src/environmentIcons.test.tsx b/apps/web/src/environmentIcons.test.tsx new file mode 100644 index 000000000000..c02110cc458d --- /dev/null +++ b/apps/web/src/environmentIcons.test.tsx @@ -0,0 +1,139 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { + EnvironmentIcon, + environmentIconKind, + formatLocalEnvironmentLabel, + legacyThreadEnvironmentPresentation, + normalizeEnvironmentIconColor, + projectEnvironmentIconEntries, + resolveEnvironmentIconColor, + showV2ThreadCardEnvironmentIcon, + updateEnvironmentIconColors, +} from "./environmentIcons"; + +const local = EnvironmentId.make("local"); +const buildbox = EnvironmentId.make("buildbox"); +const production = EnvironmentId.make("production"); +const wsl = EnvironmentId.make("wsl"); + +describe("environment icon preferences", () => { + it("normalizes custom colors and removes an entry for Default", () => { + expect(normalizeEnvironmentIconColor(" #7C3AED ")).toBe("#7c3aed"); + expect(normalizeEnvironmentIconColor("purple")).toBeUndefined(); + expect(updateEnvironmentIconColors({}, buildbox, "#2563EB")).toEqual({ + buildbox: "#2563eb", + }); + expect(updateEnvironmentIconColors({ buildbox: "#2563eb" }, buildbox, "")).toEqual({}); + }); + + it("uses absolute Monitor and Server identity", () => { + expect(environmentIconKind(local, local)).toBe("monitor"); + expect(environmentIconKind(buildbox, local)).toBe("server"); + expect(environmentIconKind(local, buildbox)).toBe("server"); + }); + + it("falls back to Default for a deleted or unknown environment", () => { + expect(resolveEnvironmentIconColor("#2563eb", true)).toBe("#2563eb"); + expect(resolveEnvironmentIconColor("#2563eb", false)).toBeUndefined(); + }); + + it("defines Legacy row slots and hover lines for local, remote, and desktop-local threads", () => { + expect( + legacyThreadEnvironmentPresentation({ + isPrimary: true, + isDesktopLocal: false, + showLocalEnvironmentIcon: false, + environmentLabel: "Airy", + }), + ).toEqual({ kind: "monitor", showRowIcon: false, hoverLabel: null }); + expect( + legacyThreadEnvironmentPresentation({ + isPrimary: true, + isDesktopLocal: false, + showLocalEnvironmentIcon: true, + environmentLabel: "Airy", + }), + ).toEqual({ kind: "monitor", showRowIcon: true, hoverLabel: "Airy (local)" }); + expect( + legacyThreadEnvironmentPresentation({ + isPrimary: false, + isDesktopLocal: false, + showLocalEnvironmentIcon: false, + environmentLabel: "Buildbox", + }), + ).toEqual({ kind: "server", showRowIcon: true, hoverLabel: "Buildbox" }); + }); + + it("shows the V2 local card icon only after opt-in while always showing remotes", () => { + expect(showV2ThreadCardEnvironmentIcon(true, false)).toBe(false); + expect(showV2ThreadCardEnvironmentIcon(true, true)).toBe(true); + expect(showV2ThreadCardEnvironmentIcon(false, false)).toBe(true); + }); + + it("formats the resolved local label with a safe fallback", () => { + expect(formatLocalEnvironmentLabel("Airy")).toBe("Airy (local)"); + expect(formatLocalEnvironmentLabel(" ")).toBe("Local (local)"); + }); +}); + +describe("project environment icons", () => { + const member = (environmentId: EnvironmentId, environmentLabel: string | null) => ({ + environmentId, + environmentLabel, + }); + + it("hides a local-only icon by default and shows it after opt-in", () => { + const base = { + members: [member(local, "Airy")], + primaryEnvironmentId: local, + desktopLocalEnvironmentIds: new Set(), + }; + expect(projectEnvironmentIconEntries({ ...base, showLocalEnvironmentIcon: false })).toEqual([]); + expect(projectEnvironmentIconEntries({ ...base, showLocalEnvironmentIcon: true })).toEqual([ + { environmentId: local, kind: "monitor", label: "Airy (local)" }, + ]); + }); + + it("shows each unique mixed environment with local first even when local is hidden", () => { + expect( + projectEnvironmentIconEntries({ + members: [ + member(buildbox, "Buildbox"), + member(local, "Airy"), + member(buildbox, "Buildbox"), + member(production, "Production"), + member(wsl, "Ubuntu"), + ], + primaryEnvironmentId: local, + desktopLocalEnvironmentIds: new Set([wsl]), + showLocalEnvironmentIcon: false, + }), + ).toEqual([ + { environmentId: local, kind: "monitor", label: "Airy (local)" }, + { environmentId: buildbox, kind: "server", label: "Buildbox" }, + { environmentId: production, kind: "server", label: "Production" }, + { environmentId: wsl, kind: "container", label: "Ubuntu" }, + ]); + }); +}); + +describe("EnvironmentIcon", () => { + it("keeps semantic context styling for Default", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("text-muted-foreground/40"); + expect(markup).not.toContain("style="); + }); + + it("uses an explicit color without the contextual opacity class", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("color:#2563eb"); + expect(markup).not.toContain("text-sidebar-muted-foreground/70"); + }); +}); diff --git a/apps/web/src/environmentIcons.tsx b/apps/web/src/environmentIcons.tsx new file mode 100644 index 000000000000..4e6745900cfe --- /dev/null +++ b/apps/web/src/environmentIcons.tsx @@ -0,0 +1,158 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentIconColor } from "@t3tools/contracts/settings"; +import { ContainerIcon, MonitorIcon, ServerIcon } from "lucide-react"; +import type { CSSProperties } from "react"; + +import { cn } from "./lib/utils"; + +export type EnvironmentIconKind = "container" | "monitor" | "server"; +export type EnvironmentIconContext = "hover" | "legacy-row" | "project" | "settings" | "v2-row"; + +const DEFAULT_CONTEXT_CLASS: Record = { + hover: "text-muted-foreground", + "legacy-row": "text-muted-foreground/40", + project: "text-icon-muted", + settings: "text-muted-foreground", + "v2-row": "text-sidebar-muted-foreground/70", +}; + +export function normalizeEnvironmentIconColor(value: string): EnvironmentIconColor | undefined { + const normalized = value.trim().toLowerCase(); + return /^#[\da-f]{6}$/.test(normalized) ? (normalized as EnvironmentIconColor) : undefined; +} + +export function updateEnvironmentIconColors( + colors: Readonly>, + environmentId: EnvironmentId, + value: string, +): Record { + const next = { ...colors }; + const normalized = normalizeEnvironmentIconColor(value); + if (normalized === undefined) { + delete next[environmentId]; + } else { + next[environmentId] = normalized; + } + return next; +} + +export function formatLocalEnvironmentLabel(label: string | null | undefined): string { + const normalized = label?.trim(); + return `${normalized && normalized.length > 0 ? normalized : "Local"} (local)`; +} + +export function environmentIconKind( + environmentId: EnvironmentId, + primaryEnvironmentId: EnvironmentId | null, +): "monitor" | "server" { + return environmentId === primaryEnvironmentId ? "monitor" : "server"; +} + +export function resolveEnvironmentIconColor( + color: EnvironmentIconColor | undefined, + isKnownEnvironment: boolean, +): EnvironmentIconColor | undefined { + return isKnownEnvironment ? color : undefined; +} + +export function legacyThreadEnvironmentPresentation(input: { + readonly isPrimary: boolean; + readonly isDesktopLocal: boolean; + readonly showLocalEnvironmentIcon: boolean; + readonly environmentLabel: string | null | undefined; +}) { + const kind = input.isPrimary ? ("monitor" as const) : ("server" as const); + return { + kind, + showRowIcon: input.isPrimary ? input.showLocalEnvironmentIcon : !input.isDesktopLocal, + hoverLabel: input.isPrimary + ? input.showLocalEnvironmentIcon + ? formatLocalEnvironmentLabel(input.environmentLabel) + : null + : (input.environmentLabel ?? (input.isDesktopLocal ? "Local" : "Remote")), + }; +} + +export function showV2ThreadCardEnvironmentIcon( + isPrimary: boolean, + showLocalEnvironmentIcon: boolean, +): boolean { + return !isPrimary || showLocalEnvironmentIcon; +} + +export interface ProjectEnvironmentIconEntry { + readonly environmentId: EnvironmentId; + readonly kind: EnvironmentIconKind; + readonly label: string; +} + +export function projectEnvironmentIconEntries(input: { + readonly members: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly environmentLabel: string | null; + }>; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly desktopLocalEnvironmentIds: ReadonlySet; + readonly showLocalEnvironmentIcon: boolean; +}): ProjectEnvironmentIconEntry[] { + const uniqueMembers = input.members.filter( + (member, index, members) => + members.findIndex((candidate) => candidate.environmentId === member.environmentId) === index, + ); + const orderedMembers = [ + ...uniqueMembers.filter((member) => member.environmentId === input.primaryEnvironmentId), + ...uniqueMembers.filter((member) => member.environmentId !== input.primaryEnvironmentId), + ]; + const isMixed = uniqueMembers.length > 1; + + return orderedMembers.flatMap((member): ProjectEnvironmentIconEntry[] => { + if (member.environmentId === input.primaryEnvironmentId) { + if (!input.showLocalEnvironmentIcon && !isMixed) return []; + return [ + { + environmentId: member.environmentId, + kind: "monitor", + label: formatLocalEnvironmentLabel(member.environmentLabel), + }, + ]; + } + if (input.desktopLocalEnvironmentIds.has(member.environmentId)) { + return [ + { + environmentId: member.environmentId, + kind: "container", + label: member.environmentLabel ?? "Local sandbox", + }, + ]; + } + return [ + { + environmentId: member.environmentId, + kind: "server", + label: member.environmentLabel ?? "Remote", + }, + ]; + }); +} + +export function EnvironmentIcon(props: { + readonly kind: EnvironmentIconKind; + readonly context: EnvironmentIconContext; + readonly color?: EnvironmentIconColor | undefined; + readonly className?: string | undefined; + readonly style?: CSSProperties | undefined; + readonly "aria-hidden"?: boolean | undefined; +}) { + const Icon = + props.kind === "monitor" ? MonitorIcon : props.kind === "server" ? ServerIcon : ContainerIcon; + return ( + + ); +} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 243f86bd4b2a..4a86d647afda 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -55,7 +55,7 @@ be selected again. The option is hidden when the connected environment needs a s ## Legacy sidebar scale in LastCode When the legacy sidebar is enabled, LastCode can make its project and thread rows more compact. -Open **Settings -> LastCode** and adjust **Scale legacy sidebar** from 50% through 100%. The 75% +Open **Settings -> LastCode -> Appearance** and adjust **Scale legacy sidebar** from 50% through 100%. The 75% mark is labeled as a useful compact reference point. The default is 100%, and your selection is stored locally and retained when LastCode restarts. @@ -64,3 +64,16 @@ status icons, remote cloud indicators, and relative timestamps at their standard LastCode header, Search field, Projects heading, drafts, status notices, and footer also stay at their standard size. On desktop, **View -> Actual Size**, **Zoom In**, and **Zoom Out** continue to zoom the whole application and compose with the legacy sidebar scale. + +## Environment icons in LastCode + +Open **Settings -> LastCode -> Environments** to choose the icon color for the primary machine and +each saved remote environment. **Default** preserves the semantic icon treatment for each surface; +a custom color is shown at full strength in sidebar rows, project headings, and thread details. The +icon beside each environment name previews the selection. + +Remote environments use a Server icon. The primary machine uses a Monitor icon, which can be shown +or hidden in thread cards and legacy thread rows with **Show local icon**. Legacy rows reserve the +same icon space either way so their columns stay aligned. Mixed legacy project groups always show +one icon for every environment in the group, including the primary machine, with duplicate +environments collapsed to one icon. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b18daa08d3b6..23b585778464 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -85,6 +85,33 @@ describe("ClientSettings environment identification", () => { }); }); +describe("ClientSettings environment icons", () => { + it("defaults to semantic colors with the local icon hidden", () => { + const settings = decodeClientSettings({}); + expect(settings.environmentIconColors).toEqual({}); + expect(settings.showLocalEnvironmentIcon).toBe(false); + }); + + it("accepts per-environment hex colors and the local icon opt-in", () => { + const input = { + environmentIconColors: { primary: "#2563eb", remote: "#7C3AED" }, + showLocalEnvironmentIcon: true, + }; + expect(decodeClientSettings(input)).toMatchObject(input); + expect(decodeClientSettingsPatch(input)).toEqual(input); + }); + + it.each(["blue", "#123", "#12345678", "#gg0000"])( + "rejects an invalid environment icon color: %s", + (color) => { + expect(() => decodeClientSettings({ environmentIconColors: { primary: color } })).toThrow(); + expect(() => + decodeClientSettingsPatch({ environmentIconColors: { primary: color } }), + ).toThrow(); + }, + ); +}); + describe("ClientSettings sidebar", () => { it("defaults to the current sidebar with automatic merge and inactivity settling", () => { const settings = decodeClientSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7ef788d4e30f..d65f63759974 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 { ThreadEnvMode } from "./environment.ts"; import { DEFAULT_TEXT_GENERATION_MODEL, @@ -135,6 +135,9 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +export const EnvironmentIconColor = TrimmedNonEmptyString.check(Schema.isPattern(/^#[\da-f]{6}$/i)); +export type EnvironmentIconColor = typeof EnvironmentIconColor.Type; + /** * A user-chosen font family (a single name or a comma-separated list). Empty * means "use the app default"; clients compose their own fallback stacks. @@ -185,6 +188,9 @@ export const ClientSettingsSchema = Schema.Struct({ environmentIdentificationMode: EnvironmentIdentificationMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)), ), + environmentIconColors: Schema.Record(EnvironmentId, EnvironmentIconColor).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), @@ -246,6 +252,7 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_LEGACY_SIDEBAR_SCALE)), ), roundedProjectIcons: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + showLocalEnvironmentIcon: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -897,6 +904,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), + environmentIconColors: Schema.optionalKey(Schema.Record(EnvironmentId, EnvironmentIconColor)), glassOpacity: Schema.optionalKey(GlassOpacity), fontSizeInterface: Schema.optionalKey(InterfaceFontSize), fontSizePrompt: Schema.optionalKey(PromptFontSize), @@ -933,6 +941,7 @@ export const ClientSettingsPatch = Schema.Struct({ legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarScale: Schema.optionalKey(LegacySidebarScale), roundedProjectIcons: Schema.optionalKey(Schema.Boolean), + showLocalEnvironmentIcon: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),