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 = {
1818 confirmThreadDelete: false,
1919 dismissedProviderUpdateNotificationKeys: [],
2020 diffIgnoreWhitespace: true,
21 environmentIdentificationMode: "artwork",
2122 favorites: [],
2223 glassOpacity: 80,
2324 providerModelPreferences: {},
apps/web/src/components/AppSidebarLayout.tsx+5-2
......@@ -14,7 +14,7 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage";
1414import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
1515import { cn, isMacPlatform } from "../lib/utils";
1616import { primaryServerKeybindingsAtom } from "../state/server";
17import { useSidebarV2Enabled } from "../hooks/useSettings";
17import { useEnvironmentIdentificationMode, useSidebarV2Enabled } from "../hooks/useSettings";
1818import ThreadSidebar from "./Sidebar";
1919import ThreadSidebarV2 from "./SidebarV2";
2020import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
......@@ -62,7 +62,10 @@ function SidebarControl() {
6262 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
6363 const { toggleSidebar } = useSidebar();
6464 const isSidebarVisible = useSidebarVisibility();
65 const stageBackdropVariant = useSidebarStageBackdropVariant();
65 const environmentIdentificationMode = useEnvironmentIdentificationMode();
66 const stageBackdropVariant = useSidebarStageBackdropVariant(
67 environmentIdentificationMode === "artwork",
68 );
6669 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle");
6770
6871 useEffect(() => {
apps/web/src/components/SidebarStageBackdrop.test.tsx+20-1
......@@ -1,9 +1,28 @@
11import { describe, expect, it } from "vite-plus/test";
22import { renderToStaticMarkup } from "react-dom/server";
33
4import { StageBackdropArt, StageBackdropButtonArt } from "./SidebarStageBackdrop";
4import {
5 resolveEnvironmentIdentificationPillLabel,
6 resolveSidebarStageBackdropVariant,
7 StageBackdropArt,
8 StageBackdropButtonArt,
9} from "./SidebarStageBackdrop";
510
611describe("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
726 it.each(["nightly", "dev"] as const)(
827 "uses unique SVG definition ids when %s artwork is rendered more than once",
928 (variant) => {
apps/web/src/components/SidebarStageBackdrop.tsx+21-7
......@@ -6,6 +6,7 @@ import { resolveServerBackedAppStageLabel } from "../branding.logic";
66import { primaryServerConfigAtom } from "../state/server";
77
88export type SidebarStageBackdropVariant = "nightly" | "dev";
9export type EnvironmentIdentificationPillLabel = "Dev" | "Nightly";
910
1011// A wide viewBox keeps the 96-unit art height at a fixed scale while sidebar resizing reveals
1112// more horizontal canvas instead of zooming the scene.
......@@ -13,23 +14,36 @@ const STAGE_BACKDROP_VIEW_BOX = "0 0 8192 96";
1314
1415export function resolveSidebarStageBackdropVariant(
1516 stageLabel: string,
17 enabled = true,
1618): SidebarStageBackdropVariant | null {
19 if (!enabled) return null;
1720 const normalized = stageLabel.trim().toLowerCase();
1821 if (normalized === "nightly") return "nightly";
1922 if (normalized === "dev") return "dev";
2023 return null;
2124}
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 {
2436 const primaryServerVersion =
2537 useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
2638
27 return resolveSidebarStageBackdropVariant(
28 resolveServerBackedAppStageLabel({
29 primaryServerVersion,
30 fallbackStageLabel: APP_STAGE_LABEL,
31 }),
32 );
39 return resolveServerBackedAppStageLabel({
40 primaryServerVersion,
41 fallbackStageLabel: APP_STAGE_LABEL,
42 });
43}
44
45export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBackdropVariant | null {
46 return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled);
3347}
3448
3549/** 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 @@
11import { memo, type PointerEventHandler } from "react";
22import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
3import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";
34import { cn } from "~/lib/utils";
45import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
56import { Button } from "../ui/button";
......@@ -72,7 +73,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
7273 const pointerFocusProps = preserveComposerFocusOnPointerDown
7374 ? { onPointerDown: preventPointerFocus }
7475 : undefined;
75 const stageBackdropVariant = useSidebarStageBackdropVariant();
76 const environmentIdentificationMode = useEnvironmentIdentificationMode();
77 const stageBackdropVariant = useSidebarStageBackdropVariant(
78 environmentIdentificationMode === "artwork",
79 );
7680
7781 if (pendingAction) {
7882 return (
apps/web/src/components/settings/SettingsPanels.tsx+63
......@@ -21,7 +21,9 @@ import {
2121 squashAtomCommandFailure,
2222} from "@t3tools/client-runtime/state/runtime";
2323import {
24 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
2425 DEFAULT_UNIFIED_SETTINGS,
26 type EnvironmentIdentificationMode,
2527 MAX_GLASS_OPACITY,
2628 MIN_GLASS_OPACITY,
2729} from "@t3tools/contracts/settings";
......@@ -40,6 +42,10 @@ import {
4042} from "../../components/desktopUpdate.logic";
4143import { ProviderModelPicker } from "../chat/ProviderModelPicker";
4244import { TraitsPicker } from "../chat/TraitsPicker";
45import {
46 resolveEnvironmentIdentificationPillLabel,
47 useEnvironmentStageLabel,
48} from "../SidebarStageBackdrop";
4349import { isElectron } from "../../env";
4450import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
4551import { useTheme } from "../../hooks/useTheme";
......@@ -114,6 +120,12 @@ const THEME_OPTIONS = [
114120 },
115121] as const;
116122
123const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
124 artwork: "Artwork",
125 pill: "Version pill",
126 none: "None",
127};
128
117129const TIMESTAMP_FORMAT_LABELS = {
118130 locale: "System default",
119131 "12-hour": "12-hour",
......@@ -401,6 +413,10 @@ export function useSettingsRestore(onRestored?: () => void) {
401413 () => [
402414 ...(theme !== "system" ? ["Theme"] : []),
403415 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
416 ...(settings.environmentIdentificationMode !==
417 DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
418 ? ["Environment identification"]
419 : []),
404420 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
405421 ? ["Time format"]
406422 : []),
......@@ -456,6 +472,7 @@ export function useSettingsRestore(onRestored?: () => void) {
456472 settings.defaultThreadEnvMode,
457473 settings.newWorktreesStartFromOrigin,
458474 settings.diffIgnoreWhitespace,
475 settings.environmentIdentificationMode,
459476 settings.glassOpacity,
460477 settings.automaticGitFetchInterval,
461478 settings.enableAssistantStreaming,
......@@ -483,6 +500,7 @@ export function useSettingsRestore(onRestored?: () => void) {
483500 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
484501 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
485502 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
503 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
486504 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
487505 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
488506 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
......@@ -510,6 +528,9 @@ export function GeneralSettingsPanel() {
510528 const { theme, setTheme } = useTheme();
511529 const settings = usePrimarySettings();
512530 const updateSettings = useUpdatePrimarySettings();
531 const environmentStageLabel = useEnvironmentStageLabel();
532 const showEnvironmentIdentification =
533 resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;
513534 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
514535 readLastEnabledProjectGroupingMode(),
515536 );
......@@ -634,6 +655,48 @@ export function GeneralSettingsPanel() {
634655 }
635656 />
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
637700 <SettingsRow
638701 title="Project Grouping"
639702 description="Combine matching repositories across environments."
apps/web/src/components/sidebar/SidebarChrome.tsx+28-17
......@@ -1,13 +1,16 @@
1import { useAtomValue } from "@effect/atom-react";
21import { SettingsIcon } from "lucide-react";
32import { memo, useCallback } from "react";
43import { Link, useNavigate } from "@tanstack/react-router";
54
6import { APP_STAGE_LABEL } from "../../branding";
5import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
76import { cn } from "../../lib/utils";
8import { primaryServerConfigAtom } from "../../state/server";
9import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic";
10import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
7import {
8 resolveEnvironmentIdentificationPillLabel,
9 resolveSidebarStageBackdropVariant,
10 SidebarStageBackdrop,
11 useEnvironmentStageLabel,
12} from "../SidebarStageBackdrop";
13import { Badge } from "../ui/badge";
1114import {
1215 SidebarFooter,
1316 SidebarHeader,
......@@ -25,8 +28,16 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
2528}: {
2629 isElectron: boolean;
2730}) {
28 const stageLabel = useSidebarStageLabel();
29 const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel);
31 const stageLabel = useEnvironmentStageLabel();
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
3142 return (
3243 <SidebarHeader
......@@ -44,6 +55,16 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
4455 )}
4556 />
4657 <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}
4768 </SidebarHeader>
4869 );
4970});
......@@ -71,16 +92,6 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
7192 );
7293}
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
8495function T3Wordmark() {
8596 return (
8697 <svg
apps/web/src/hooks/useSettings.test.ts+12-1
......@@ -6,7 +6,18 @@ import {
66import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
77import { 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
1122describe("mergeEnvironmentSettings", () => {
1223 it("combines the selected environment's server settings with client preferences", () => {
apps/web/src/hooks/useSettings.ts+15
......@@ -21,6 +21,7 @@ import {
2121 type ClientSettingsPatch,
2222 type ClientSettings,
2323 DEFAULT_CLIENT_SETTINGS,
24 type EnvironmentIdentificationMode,
2425 type UnifiedSettings,
2526} from "@t3tools/contracts/settings";
2627import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
......@@ -222,6 +223,20 @@ export function useClientSettings<T = ClientSettings>(
222223 return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);
223224}
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
225240/**
226241 * Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user
227242 * 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", () => {
4949 });
5050});
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
5270describe("ClientSettings sidebar v2", () => {
5371 it("defaults the beta off with a three-day auto-settle threshold", () => {
5472 const settings = decodeClientSettings({});
packages/contracts/src/settings.ts+7
......@@ -58,6 +58,9 @@ export const GlassOpacity = Schema.Int.check(
5858);
5959export type GlassOpacity = typeof GlassOpacity.Type;
6060export 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
6265export const ClientSettingsSchema = Schema.Struct({
6366 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
......@@ -67,6 +70,9 @@ export const ClientSettingsSchema = Schema.Struct({
6770 Schema.withDecodingDefault(Effect.succeed([])),
6871 ),
6972 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
73 environmentIdentificationMode: EnvironmentIdentificationMode.pipe(
74 Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)),
75 ),
7076 glassOpacity: GlassOpacity.pipe(
7177 Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)),
7278 ),
......@@ -611,6 +617,7 @@ export const ClientSettingsPatch = Schema.Struct({
611617 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
612618 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
613619 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
620 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
614621 glassOpacity: Schema.optionalKey(GlassOpacity),
615622 favorites: Schema.optionalKey(
616623 Schema.Array(