diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index a740fbafeee..8d76ea83a33 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, + environmentIdentificationMode: "artwork", favorites: [], glassOpacity: 80, providerModelPreferences: {}, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 90a45e8e25f..5c6acd62aea 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,7 +14,7 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useSidebarV2Enabled } from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode, useSidebarV2Enabled } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; import ThreadSidebarV2 from "./SidebarV2"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; @@ -62,7 +62,10 @@ function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); const isSidebarVisible = useSidebarVisibility(); - const stageBackdropVariant = useSidebarStageBackdropVariant(); + const environmentIdentificationMode = useEnvironmentIdentificationMode(); + const stageBackdropVariant = useSidebarStageBackdropVariant( + environmentIdentificationMode === "artwork", + ); const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); useEffect(() => { diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx index 41aee21e327..114fd5f9241 100644 --- a/apps/web/src/components/SidebarStageBackdrop.test.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.test.tsx @@ -1,9 +1,28 @@ import { describe, expect, it } from "vite-plus/test"; import { renderToStaticMarkup } from "react-dom/server"; -import { StageBackdropArt, StageBackdropButtonArt } from "./SidebarStageBackdrop"; +import { + resolveEnvironmentIdentificationPillLabel, + resolveSidebarStageBackdropVariant, + StageBackdropArt, + StageBackdropButtonArt, +} from "./SidebarStageBackdrop"; describe("SidebarStageBackdrop", () => { + it("resolves stage artwork only when enabled", () => { + expect(resolveSidebarStageBackdropVariant("Dev")).toBe("dev"); + expect(resolveSidebarStageBackdropVariant("Nightly")).toBe("nightly"); + expect(resolveSidebarStageBackdropVariant("Dev", false)).toBeNull(); + expect(resolveSidebarStageBackdropVariant("Alpha")).toBeNull(); + }); + + it("resolves supported environment pill labels", () => { + expect(resolveEnvironmentIdentificationPillLabel("Dev")).toBe("Dev"); + expect(resolveEnvironmentIdentificationPillLabel("nightly")).toBe("Nightly"); + expect(resolveEnvironmentIdentificationPillLabel("Latest")).toBeNull(); + expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull(); + }); + it.each(["nightly", "dev"] as const)( "uses unique SVG definition ids when %s artwork is rendered more than once", (variant) => { diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index ba3de64de15..9fb448e940d 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -6,6 +6,7 @@ import { resolveServerBackedAppStageLabel } from "../branding.logic"; import { primaryServerConfigAtom } from "../state/server"; export type SidebarStageBackdropVariant = "nightly" | "dev"; +export type EnvironmentIdentificationPillLabel = "Dev" | "Nightly"; // A wide viewBox keeps the 96-unit art height at a fixed scale while sidebar resizing reveals // more horizontal canvas instead of zooming the scene. @@ -13,23 +14,36 @@ const STAGE_BACKDROP_VIEW_BOX = "0 0 8192 96"; export function resolveSidebarStageBackdropVariant( stageLabel: string, + enabled = true, ): SidebarStageBackdropVariant | null { + if (!enabled) return null; const normalized = stageLabel.trim().toLowerCase(); if (normalized === "nightly") return "nightly"; if (normalized === "dev") return "dev"; return null; } -export function useSidebarStageBackdropVariant(): SidebarStageBackdropVariant | null { +export function resolveEnvironmentIdentificationPillLabel( + stageLabel: string, +): EnvironmentIdentificationPillLabel | null { + const normalized = stageLabel.trim().toLowerCase(); + if (normalized === "dev") return "Dev"; + if (normalized === "nightly") return "Nightly"; + return null; +} + +export function useEnvironmentStageLabel(): string { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - return resolveSidebarStageBackdropVariant( - resolveServerBackedAppStageLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }), - ); + return resolveServerBackedAppStageLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); +} + +export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBackdropVariant | null { + return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled); } /** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. */ diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 3e664e226b7..19c20e2abf2 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,5 +1,6 @@ import { memo, type PointerEventHandler } from "react"; import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; +import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; import { Button } from "../ui/button"; @@ -72,7 +73,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } : undefined; - const stageBackdropVariant = useSidebarStageBackdropVariant(); + const environmentIdentificationMode = useEnvironmentIdentificationMode(); + const stageBackdropVariant = useSidebarStageBackdropVariant( + environmentIdentificationMode === "artwork", + ); if (pendingAction) { return ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 310cdd5164c..fd70a0d6cf9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -21,7 +21,9 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { + DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, DEFAULT_UNIFIED_SETTINGS, + type EnvironmentIdentificationMode, MAX_GLASS_OPACITY, MIN_GLASS_OPACITY, } from "@t3tools/contracts/settings"; @@ -40,6 +42,10 @@ import { } from "../../components/desktopUpdate.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; +import { + resolveEnvironmentIdentificationPillLabel, + useEnvironmentStageLabel, +} from "../SidebarStageBackdrop"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; @@ -114,6 +120,12 @@ const THEME_OPTIONS = [ }, ] as const; +const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { + artwork: "Artwork", + pill: "Version pill", + none: "None", +}; + const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", @@ -401,6 +413,10 @@ export function useSettingsRestore(onRestored?: () => void) { () => [ ...(theme !== "system" ? ["Theme"] : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), + ...(settings.environmentIdentificationMode !== + DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode + ? ["Environment identification"] + : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -456,6 +472,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, + settings.environmentIdentificationMode, settings.glassOpacity, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, @@ -483,6 +500,7 @@ export function useSettingsRestore(onRestored?: () => void) { timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, + environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, @@ -510,6 +528,9 @@ export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); + const environmentStageLabel = useEnvironmentStageLabel(); + const showEnvironmentIdentification = + resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), ); @@ -634,6 +655,48 @@ export function GeneralSettingsPanel() { } /> + {showEnvironmentIdentification ? ( + + updateSettings({ + environmentIdentificationMode: DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, + }) + } + /> + ) : null + } + control={ + + } + /> + ) : null} + + {pillLabel ? ( + + {pillLabel} + + ) : null} ); }); @@ -71,16 +92,6 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { ); } -function useSidebarStageLabel() { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - - return resolveSidebarStageBadgeLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }); -} - function T3Wordmark() { return ( { + it("keeps identification hidden until client settings hydrate", () => { + expect(resolveEnvironmentIdentificationMode({ mode: "artwork", settingsHydrated: false })).toBe( + "none", + ); + expect(resolveEnvironmentIdentificationMode({ mode: "pill", settingsHydrated: true })).toBe( + "pill", + ); + }); +}); describe("mergeEnvironmentSettings", () => { it("combines the selected environment's server settings with client preferences", () => { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 9f78e8a8527..739f6cfcea5 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -21,6 +21,7 @@ import { type ClientSettingsPatch, type ClientSettings, DEFAULT_CLIENT_SETTINGS, + type EnvironmentIdentificationMode, type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; @@ -222,6 +223,20 @@ export function useClientSettings( return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]); } +export function resolveEnvironmentIdentificationMode(input: { + mode: EnvironmentIdentificationMode; + settingsHydrated: boolean; +}): EnvironmentIdentificationMode { + // Avoid briefly rendering the default artwork before a persisted pill/none choice loads. + return input.settingsHydrated ? input.mode : "none"; +} + +export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode { + const settingsHydrated = useClientSettingsHydrated(); + const mode = useClientSettingsValue().environmentIdentificationMode; + return resolveEnvironmentIdentificationMode({ mode, settingsHydrated }); +} + /** * Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user * has made one, otherwise the default for this build stage (on for nightly and diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ed864344e35..2bc61d72f21 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -49,6 +49,24 @@ describe("ClientSettings glass opacity", () => { }); }); +describe("ClientSettings environment identification", () => { + it("defaults to artwork and accepts each presentation mode", () => { + expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork"); + + for (const mode of ["artwork", "pill", "none"] as const) { + expect( + decodeClientSettingsPatch({ environmentIdentificationMode: mode }) + .environmentIdentificationMode, + ).toBe(mode); + } + }); + + it("rejects unsupported presentation modes", () => { + expect(() => decodeClientSettings({ environmentIdentificationMode: "badge" })).toThrow(); + expect(() => decodeClientSettingsPatch({ environmentIdentificationMode: "badge" })).toThrow(); + }); +}); + describe("ClientSettings sidebar v2", () => { it("defaults the beta off with a three-day auto-settle threshold", () => { const settings = decodeClientSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8266ad3337f..b97d4681270 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -58,6 +58,9 @@ export const GlassOpacity = Schema.Int.check( ); export type GlassOpacity = typeof GlassOpacity.Type; export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; +export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); +export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; +export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -67,6 +70,9 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + environmentIdentificationMode: EnvironmentIdentificationMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)), + ), glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), @@ -611,6 +617,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), + environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), favorites: Schema.optionalKey( Schema.Array(