authorgravatar for me@maxkatz.meMax Katz <me@maxkatz.me> 2026-07-28 13:17:45+04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-28 11:17:45+02:00
log362f12792254335bfe2a015787d7cbbaa214654e
treee1871e0fd30bf60cd335184098a130b2f1455113
parent9cf9fc9c5c0f48d42f407f86cf83d0f04fbdf5a8
signature Signed by PGP key B5690EEEBB952194

feat(web): add maria's sidebar header artwork toggle (#4652)


11 files changed, 195 insertions(+), 29 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
...@@ -18,6 +18,7 @@ const clientSettings: ClientSettings = {...@@ -18,6 +18,7 @@ const clientSettings: ClientSettings = {
18 confirmThreadDelete: false,18 confirmThreadDelete: false,
19 dismissedProviderUpdateNotificationKeys: [],19 dismissedProviderUpdateNotificationKeys: [],
20 diffIgnoreWhitespace: true,20 diffIgnoreWhitespace: true,
21 environmentIdentificationMode: "artwork",
21 favorites: [],22 favorites: [],
22 glassOpacity: 80,23 glassOpacity: 80,
23 providerModelPreferences: {},24 providerModelPreferences: {},
apps/web/src/components/AppSidebarLayout.tsx+5-2
...@@ -14,7 +14,7 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage";...@@ -14,7 +14,7 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage";
14import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";14import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
15import { cn, isMacPlatform } from "../lib/utils";15import { cn, isMacPlatform } from "../lib/utils";
16import { primaryServerKeybindingsAtom } from "../state/server";16import { primaryServerKeybindingsAtom } from "../state/server";
17import { useSidebarV2Enabled } from "../hooks/useSettings";17import { useEnvironmentIdentificationMode, useSidebarV2Enabled } from "../hooks/useSettings";
18import ThreadSidebar from "./Sidebar";18import ThreadSidebar from "./Sidebar";
19import ThreadSidebarV2 from "./SidebarV2";19import ThreadSidebarV2 from "./SidebarV2";
20import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";20import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
...@@ -62,7 +62,10 @@ function SidebarControl() {...@@ -62,7 +62,10 @@ function SidebarControl() {
62 const keybindings = useAtomValue(primaryServerKeybindingsAtom);62 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
63 const { toggleSidebar } = useSidebar();63 const { toggleSidebar } = useSidebar();
64 const isSidebarVisible = useSidebarVisibility();64 const isSidebarVisible = useSidebarVisibility();
65 const stageBackdropVariant = useSidebarStageBackdropVariant();65 const environmentIdentificationMode = useEnvironmentIdentificationMode();
66 const stageBackdropVariant = useSidebarStageBackdropVariant(
67 environmentIdentificationMode === "artwork",
68 );
66 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle");69 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle");
6770
68 useEffect(() => {71 useEffect(() => {
apps/web/src/components/SidebarStageBackdrop.test.tsx+20-1
...@@ -1,9 +1,28 @@...@@ -1,9 +1,28 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { renderToStaticMarkup } from "react-dom/server";2import { renderToStaticMarkup } from "react-dom/server";
33
4import { StageBackdropArt, StageBackdropButtonArt } from "./SidebarStageBackdrop";4import {
5 resolveEnvironmentIdentificationPillLabel,
6 resolveSidebarStageBackdropVariant,
7 StageBackdropArt,
8 StageBackdropButtonArt,
9} from "./SidebarStageBackdrop";
510
6describe("SidebarStageBackdrop", () => {11describe("SidebarStageBackdrop", () => {
12 it("resolves stage artwork only when enabled", () => {
13 expect(resolveSidebarStageBackdropVariant("Dev")).toBe("dev");
14 expect(resolveSidebarStageBackdropVariant("Nightly")).toBe("nightly");
15 expect(resolveSidebarStageBackdropVariant("Dev", false)).toBeNull();
16 expect(resolveSidebarStageBackdropVariant("Alpha")).toBeNull();
17 });
18
19 it("resolves supported environment pill labels", () => {
20 expect(resolveEnvironmentIdentificationPillLabel("Dev")).toBe("Dev");
21 expect(resolveEnvironmentIdentificationPillLabel("nightly")).toBe("Nightly");
22 expect(resolveEnvironmentIdentificationPillLabel("Latest")).toBeNull();
23 expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull();
24 });
25
7 it.each(["nightly", "dev"] as const)(26 it.each(["nightly", "dev"] as const)(
8 "uses unique SVG definition ids when %s artwork is rendered more than once",27 "uses unique SVG definition ids when %s artwork is rendered more than once",
9 (variant) => {28 (variant) => {
apps/web/src/components/SidebarStageBackdrop.tsx+21-7
...@@ -6,6 +6,7 @@ import { resolveServerBackedAppStageLabel } from "../branding.logic";...@@ -6,6 +6,7 @@ import { resolveServerBackedAppStageLabel } from "../branding.logic";
6import { primaryServerConfigAtom } from "../state/server";6import { primaryServerConfigAtom } from "../state/server";
77
8export type SidebarStageBackdropVariant = "nightly" | "dev";8export type SidebarStageBackdropVariant = "nightly" | "dev";
9export type EnvironmentIdentificationPillLabel = "Dev" | "Nightly";
910
10// A wide viewBox keeps the 96-unit art height at a fixed scale while sidebar resizing reveals11// A wide viewBox keeps the 96-unit art height at a fixed scale while sidebar resizing reveals
11// more horizontal canvas instead of zooming the scene.12// more horizontal canvas instead of zooming the scene.
...@@ -13,23 +14,36 @@ const STAGE_BACKDROP_VIEW_BOX = "0 0 8192 96";...@@ -13,23 +14,36 @@ const STAGE_BACKDROP_VIEW_BOX = "0 0 8192 96";
1314
14export function resolveSidebarStageBackdropVariant(15export function resolveSidebarStageBackdropVariant(
15 stageLabel: string,16 stageLabel: string,
17 enabled = true,
16): SidebarStageBackdropVariant | null {18): SidebarStageBackdropVariant | null {
19 if (!enabled) return null;
17 const normalized = stageLabel.trim().toLowerCase();20 const normalized = stageLabel.trim().toLowerCase();
18 if (normalized === "nightly") return "nightly";21 if (normalized === "nightly") return "nightly";
19 if (normalized === "dev") return "dev";22 if (normalized === "dev") return "dev";
20 return null;23 return null;
21}24}
2225
23export function useSidebarStageBackdropVariant(): SidebarStageBackdropVariant | null {26export function resolveEnvironmentIdentificationPillLabel(
27 stageLabel: string,
28): EnvironmentIdentificationPillLabel | null {
29 const normalized = stageLabel.trim().toLowerCase();
30 if (normalized === "dev") return "Dev";
31 if (normalized === "nightly") return "Nightly";
32 return null;
33}
34
35export function useEnvironmentStageLabel(): string {
24 const primaryServerVersion =36 const primaryServerVersion =
25 useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;37 useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
2638
27 return resolveSidebarStageBackdropVariant(39 return resolveServerBackedAppStageLabel({
28 resolveServerBackedAppStageLabel({40 primaryServerVersion,
29 primaryServerVersion,41 fallbackStageLabel: APP_STAGE_LABEL,
30 fallbackStageLabel: APP_STAGE_LABEL,42 });
31 }),43}
32 );44
45export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBackdropVariant | null {
46 return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled);
33}47}
3448
35/** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. */49/** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. */
apps/web/src/components/chat/ComposerPrimaryActions.tsx+5-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { memo, type PointerEventHandler } from "react";1import { memo, type PointerEventHandler } from "react";
2import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";2import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
3import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";
3import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
4import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";5import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
5import { Button } from "../ui/button";6import { Button } from "../ui/button";
...@@ -72,7 +73,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -72,7 +73,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
72 const pointerFocusProps = preserveComposerFocusOnPointerDown73 const pointerFocusProps = preserveComposerFocusOnPointerDown
73 ? { onPointerDown: preventPointerFocus }74 ? { onPointerDown: preventPointerFocus }
74 : undefined;75 : undefined;
75 const stageBackdropVariant = useSidebarStageBackdropVariant();76 const environmentIdentificationMode = useEnvironmentIdentificationMode();
77 const stageBackdropVariant = useSidebarStageBackdropVariant(
78 environmentIdentificationMode === "artwork",
79 );
7680
77 if (pendingAction) {81 if (pendingAction) {
78 return (82 return (
apps/web/src/components/settings/SettingsPanels.tsx+63
...@@ -21,7 +21,9 @@ import {...@@ -21,7 +21,9 @@ import {
21 squashAtomCommandFailure,21 squashAtomCommandFailure,
22} from "@t3tools/client-runtime/state/runtime";22} from "@t3tools/client-runtime/state/runtime";
23import {23import {
24 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
24 DEFAULT_UNIFIED_SETTINGS,25 DEFAULT_UNIFIED_SETTINGS,
26 type EnvironmentIdentificationMode,
25 MAX_GLASS_OPACITY,27 MAX_GLASS_OPACITY,
26 MIN_GLASS_OPACITY,28 MIN_GLASS_OPACITY,
27} from "@t3tools/contracts/settings";29} from "@t3tools/contracts/settings";
...@@ -40,6 +42,10 @@ import {...@@ -40,6 +42,10 @@ import {
40} from "../../components/desktopUpdate.logic";42} from "../../components/desktopUpdate.logic";
41import { ProviderModelPicker } from "../chat/ProviderModelPicker";43import { ProviderModelPicker } from "../chat/ProviderModelPicker";
42import { TraitsPicker } from "../chat/TraitsPicker";44import { TraitsPicker } from "../chat/TraitsPicker";
45import {
46 resolveEnvironmentIdentificationPillLabel,
47 useEnvironmentStageLabel,
48} from "../SidebarStageBackdrop";
43import { isElectron } from "../../env";49import { isElectron } from "../../env";
44import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";50import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
45import { useTheme } from "../../hooks/useTheme";51import { useTheme } from "../../hooks/useTheme";
...@@ -114,6 +120,12 @@ const THEME_OPTIONS = [...@@ -114,6 +120,12 @@ const THEME_OPTIONS = [
114 },120 },
115] as const;121] as const;
116122
123const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
124 artwork: "Artwork",
125 pill: "Version pill",
126 none: "None",
127};
128
117const TIMESTAMP_FORMAT_LABELS = {129const TIMESTAMP_FORMAT_LABELS = {
118 locale: "System default",130 locale: "System default",
119 "12-hour": "12-hour",131 "12-hour": "12-hour",
...@@ -401,6 +413,10 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -401,6 +413,10 @@ export function useSettingsRestore(onRestored?: () => void) {
401 () => [413 () => [
402 ...(theme !== "system" ? ["Theme"] : []),414 ...(theme !== "system" ? ["Theme"] : []),
403 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),415 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
416 ...(settings.environmentIdentificationMode !==
417 DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
418 ? ["Environment identification"]
419 : []),
404 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat420 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
405 ? ["Time format"]421 ? ["Time format"]
406 : []),422 : []),
...@@ -456,6 +472,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -456,6 +472,7 @@ export function useSettingsRestore(onRestored?: () => void) {
456 settings.defaultThreadEnvMode,472 settings.defaultThreadEnvMode,
457 settings.newWorktreesStartFromOrigin,473 settings.newWorktreesStartFromOrigin,
458 settings.diffIgnoreWhitespace,474 settings.diffIgnoreWhitespace,
475 settings.environmentIdentificationMode,
459 settings.glassOpacity,476 settings.glassOpacity,
460 settings.automaticGitFetchInterval,477 settings.automaticGitFetchInterval,
461 settings.enableAssistantStreaming,478 settings.enableAssistantStreaming,
...@@ -483,6 +500,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -483,6 +500,7 @@ export function useSettingsRestore(onRestored?: () => void) {
483 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,500 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
484 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,501 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
485 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,502 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
503 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
486 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,504 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
487 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,505 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
488 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,506 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
...@@ -510,6 +528,9 @@ export function GeneralSettingsPanel() {...@@ -510,6 +528,9 @@ export function GeneralSettingsPanel() {
510 const { theme, setTheme } = useTheme();528 const { theme, setTheme } = useTheme();
511 const settings = usePrimarySettings();529 const settings = usePrimarySettings();
512 const updateSettings = useUpdatePrimarySettings();530 const updateSettings = useUpdatePrimarySettings();
531 const environmentStageLabel = useEnvironmentStageLabel();
532 const showEnvironmentIdentification =
533 resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;
513 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(534 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
514 readLastEnabledProjectGroupingMode(),535 readLastEnabledProjectGroupingMode(),
515 );536 );
...@@ -634,6 +655,48 @@ export function GeneralSettingsPanel() {...@@ -634,6 +655,48 @@ export function GeneralSettingsPanel() {
634 }655 }
635 />656 />
636657
658 {showEnvironmentIdentification ? (
659 <SettingsRow
660 title="Environment identification"
661 description="Choose how Dev and Nightly environments are identified."
662 resetAction={
663 settings.environmentIdentificationMode !== DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE ? (
664 <SettingResetButton
665 label="environment identification"
666 onClick={() =>
667 updateSettings({
668 environmentIdentificationMode: DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
669 })
670 }
671 />
672 ) : null
673 }
674 control={
675 <Select
676 value={settings.environmentIdentificationMode}
677 onValueChange={(value) => {
678 if (value === "artwork" || value === "pill" || value === "none") {
679 updateSettings({ environmentIdentificationMode: value });
680 }
681 }}
682 >
683 <SelectTrigger className="w-full sm:w-40" aria-label="Environment identification">
684 <SelectValue>
685 {ENVIRONMENT_IDENTIFICATION_LABELS[settings.environmentIdentificationMode]}
686 </SelectValue>
687 </SelectTrigger>
688 <SelectPopup align="end" alignItemWithTrigger={false}>
689 {Object.entries(ENVIRONMENT_IDENTIFICATION_LABELS).map(([value, label]) => (
690 <SelectItem hideIndicator key={value} value={value}>
691 {label}
692 </SelectItem>
693 ))}
694 </SelectPopup>
695 </Select>
696 }
697 />
698 ) : null}
699
637 <SettingsRow700 <SettingsRow
638 title="Project Grouping"701 title="Project Grouping"
639 description="Combine matching repositories across environments."702 description="Combine matching repositories across environments."
apps/web/src/components/sidebar/SidebarChrome.tsx+28-17
...@@ -1,13 +1,16 @@...@@ -1,13 +1,16 @@
1import { useAtomValue } from "@effect/atom-react";
2import { SettingsIcon } from "lucide-react";1import { SettingsIcon } from "lucide-react";
3import { memo, useCallback } from "react";2import { memo, useCallback } from "react";
4import { Link, useNavigate } from "@tanstack/react-router";3import { Link, useNavigate } from "@tanstack/react-router";
54
6import { APP_STAGE_LABEL } from "../../branding";5import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
7import { cn } from "../../lib/utils";6import { cn } from "../../lib/utils";
8import { primaryServerConfigAtom } from "../../state/server";7import {
9import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic";8 resolveEnvironmentIdentificationPillLabel,
10import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "../SidebarStageBackdrop";9 resolveSidebarStageBackdropVariant,
10 SidebarStageBackdrop,
11 useEnvironmentStageLabel,
12} from "../SidebarStageBackdrop";
13import { Badge } from "../ui/badge";
11import {14import {
12 SidebarFooter,15 SidebarFooter,
13 SidebarHeader,16 SidebarHeader,
...@@ -25,8 +28,16 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -25,8 +28,16 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
25}: {28}: {
26 isElectron: boolean;29 isElectron: boolean;
27}) {30}) {
28 const stageLabel = useSidebarStageLabel();31 const stageLabel = useEnvironmentStageLabel();
29 const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel);32 const environmentIdentificationMode = useEnvironmentIdentificationMode();
33 const backdropVariant = resolveSidebarStageBackdropVariant(
34 stageLabel,
35 environmentIdentificationMode === "artwork",
36 );
37 const pillLabel =
38 environmentIdentificationMode === "pill"
39 ? resolveEnvironmentIdentificationPillLabel(stageLabel)
40 : null;
3041
31 return (42 return (
32 <SidebarHeader43 <SidebarHeader
...@@ -44,6 +55,16 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -44,6 +55,16 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
44 )}55 )}
45 />56 />
46 <SidebarBrand onBackdrop={backdropVariant !== null} />57 <SidebarBrand onBackdrop={backdropVariant !== null} />
58 {pillLabel ? (
59 <Badge
60 className="relative z-10 ml-1 rounded-full px-1.5 text-muted-foreground"
61 data-environment-identification="pill"
62 size="sm"
63 variant="secondary"
64 >
65 {pillLabel}
66 </Badge>
67 ) : null}
47 </SidebarHeader>68 </SidebarHeader>
48 );69 );
49});70});
...@@ -71,16 +92,6 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {...@@ -71,16 +92,6 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
71 );92 );
72}93}
7394
74function useSidebarStageLabel() {
75 const primaryServerVersion =
76 useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
77
78 return resolveSidebarStageBadgeLabel({
79 primaryServerVersion,
80 fallbackStageLabel: APP_STAGE_LABEL,
81 });
82}
83
84function T3Wordmark() {95function T3Wordmark() {
85 return (96 return (
86 <svg97 <svg
apps/web/src/hooks/useSettings.test.ts+12-1
...@@ -6,7 +6,18 @@ import {...@@ -6,7 +6,18 @@ import {
6import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";6import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
7import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
88
9import { mergeEnvironmentSettings } from "./useSettings";9import { mergeEnvironmentSettings, resolveEnvironmentIdentificationMode } from "./useSettings";
10
11describe("resolveEnvironmentIdentificationMode", () => {
12 it("keeps identification hidden until client settings hydrate", () => {
13 expect(resolveEnvironmentIdentificationMode({ mode: "artwork", settingsHydrated: false })).toBe(
14 "none",
15 );
16 expect(resolveEnvironmentIdentificationMode({ mode: "pill", settingsHydrated: true })).toBe(
17 "pill",
18 );
19 });
20});
1021
11describe("mergeEnvironmentSettings", () => {22describe("mergeEnvironmentSettings", () => {
12 it("combines the selected environment's server settings with client preferences", () => {23 it("combines the selected environment's server settings with client preferences", () => {
apps/web/src/hooks/useSettings.ts+15
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21 type ClientSettingsPatch,21 type ClientSettingsPatch,
22 type ClientSettings,22 type ClientSettings,
23 DEFAULT_CLIENT_SETTINGS,23 DEFAULT_CLIENT_SETTINGS,
24 type EnvironmentIdentificationMode,
24 type UnifiedSettings,25 type UnifiedSettings,
25} from "@t3tools/contracts/settings";26} from "@t3tools/contracts/settings";
26import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";27import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
...@@ -222,6 +223,20 @@ export function useClientSettings<T = ClientSettings>(...@@ -222,6 +223,20 @@ export function useClientSettings<T = ClientSettings>(
222 return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);223 return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);
223}224}
224225
226export function resolveEnvironmentIdentificationMode(input: {
227 mode: EnvironmentIdentificationMode;
228 settingsHydrated: boolean;
229}): EnvironmentIdentificationMode {
230 // Avoid briefly rendering the default artwork before a persisted pill/none choice loads.
231 return input.settingsHydrated ? input.mode : "none";
232}
233
234export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode {
235 const settingsHydrated = useClientSettingsHydrated();
236 const mode = useClientSettingsValue().environmentIdentificationMode;
237 return resolveEnvironmentIdentificationMode({ mode, settingsHydrated });
238}
239
225/**240/**
226 * Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user241 * Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user
227 * has made one, otherwise the default for this build stage (on for nightly and242 * has made one, otherwise the default for this build stage (on for nightly and
packages/contracts/src/settings.test.ts+18
...@@ -49,6 +49,24 @@ describe("ClientSettings glass opacity", () => {...@@ -49,6 +49,24 @@ describe("ClientSettings glass opacity", () => {
49 });49 });
50});50});
5151
52describe("ClientSettings environment identification", () => {
53 it("defaults to artwork and accepts each presentation mode", () => {
54 expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork");
55
56 for (const mode of ["artwork", "pill", "none"] as const) {
57 expect(
58 decodeClientSettingsPatch({ environmentIdentificationMode: mode })
59 .environmentIdentificationMode,
60 ).toBe(mode);
61 }
62 });
63
64 it("rejects unsupported presentation modes", () => {
65 expect(() => decodeClientSettings({ environmentIdentificationMode: "badge" })).toThrow();
66 expect(() => decodeClientSettingsPatch({ environmentIdentificationMode: "badge" })).toThrow();
67 });
68});
69
52describe("ClientSettings sidebar v2", () => {70describe("ClientSettings sidebar v2", () => {
53 it("defaults the beta off with a three-day auto-settle threshold", () => {71 it("defaults the beta off with a three-day auto-settle threshold", () => {
54 const settings = decodeClientSettings({});72 const settings = decodeClientSettings({});
packages/contracts/src/settings.ts+7
...@@ -58,6 +58,9 @@ export const GlassOpacity = Schema.Int.check(...@@ -58,6 +58,9 @@ export const GlassOpacity = Schema.Int.check(
58);58);
59export type GlassOpacity = typeof GlassOpacity.Type;59export type GlassOpacity = typeof GlassOpacity.Type;
60export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80;60export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80;
61export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]);
62export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type;
63export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork";
6164
62export const ClientSettingsSchema = Schema.Struct({65export const ClientSettingsSchema = Schema.Struct({
63 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),66 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
...@@ -67,6 +70,9 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -67,6 +70,9 @@ export const ClientSettingsSchema = Schema.Struct({
67 Schema.withDecodingDefault(Effect.succeed([])),70 Schema.withDecodingDefault(Effect.succeed([])),
68 ),71 ),
69 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),72 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
73 environmentIdentificationMode: EnvironmentIdentificationMode.pipe(
74 Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)),
75 ),
70 glassOpacity: GlassOpacity.pipe(76 glassOpacity: GlassOpacity.pipe(
71 Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)),77 Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)),
72 ),78 ),
...@@ -611,6 +617,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -611,6 +617,7 @@ export const ClientSettingsPatch = Schema.Struct({
611 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),617 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
612 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),618 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
613 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),619 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
620 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
614 glassOpacity: Schema.optionalKey(GlassOpacity),621 glassOpacity: Schema.optionalKey(GlassOpacity),
615 favorites: Schema.optionalKey(622 favorites: Schema.optionalKey(
616 Schema.Array(623 Schema.Array(