Skip to content

Commit fb66267

Browse files
committed
feat(web): add environment identification modes
1 parent 932ce67 commit fb66267

10 files changed

Lines changed: 150 additions & 60 deletions

File tree

‎apps/desktop/src/settings/DesktopClientSettings.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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: {},
@@ -27,7 +28,6 @@ const clientSettings: ClientSettings = {
2728
"environment-1:/tmp/project-a": "separate",
2829
},
2930
sidebarProjectSortOrder: "manual",
30-
sidebarStageArtworkEnabled: true,
3131
sidebarThreadSortOrder: "created_at",
3232
sidebarThreadPreviewCount: 6,
3333
sidebarV2Enabled: false,

‎apps/web/src/components/AppSidebarLayout.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ function SidebarControl() {
4747
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
4848
const { toggleSidebar } = useSidebar();
4949
const isSidebarVisible = useSidebarVisibility();
50-
const stageBackdropVariant = useSidebarStageBackdropVariant();
51-
const sidebarStageArtworkEnabled = useClientSettings(
52-
(settings) => settings.sidebarStageArtworkEnabled,
50+
const environmentIdentificationMode = useClientSettings(
51+
(settings) => settings.environmentIdentificationMode,
52+
);
53+
const stageBackdropVariant = useSidebarStageBackdropVariant(
54+
environmentIdentificationMode === "artwork",
5355
);
5456
const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle");
5557

@@ -86,7 +88,6 @@ function SidebarControl() {
8688
className={cn(
8789
"pointer-events-auto",
8890
isSidebarVisible &&
89-
sidebarStageArtworkEnabled &&
9091
stageBackdropVariant &&
9192
"[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!",
9293
)}

‎apps/web/src/components/SidebarStageBackdrop.test.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22
import { renderToStaticMarkup } from "react-dom/server";
33

44
import {
5+
resolveEnvironmentIdentificationPillLabel,
56
resolveSidebarStageBackdropVariant,
67
StageBackdropArt,
78
StageBackdropButtonArt,
@@ -15,6 +16,13 @@ describe("SidebarStageBackdrop", () => {
1516
expect(resolveSidebarStageBackdropVariant("Alpha")).toBeNull();
1617
});
1718

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+
1826
it.each(["nightly", "dev"] as const)(
1927
"uses unique SVG definition ids when %s artwork is rendered more than once",
2028
(variant) => {

‎apps/web/src/components/SidebarStageBackdrop.tsx‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { resolveServerBackedAppStageLabel } from "../branding.logic";
66
import { primaryServerConfigAtom } from "../state/server";
77

88
export type SidebarStageBackdropVariant = "nightly" | "dev";
9+
export 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.
@@ -22,16 +23,27 @@ export function resolveSidebarStageBackdropVariant(
2223
return null;
2324
}
2425

25-
export function useSidebarStageBackdropVariant(): SidebarStageBackdropVariant | null {
26+
export 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+
35+
export function useEnvironmentStageLabel(): string {
2636
const primaryServerVersion =
2737
useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
2838

29-
return resolveSidebarStageBackdropVariant(
30-
resolveServerBackedAppStageLabel({
31-
primaryServerVersion,
32-
fallbackStageLabel: APP_STAGE_LABEL,
33-
}),
34-
);
39+
return resolveServerBackedAppStageLabel({
40+
primaryServerVersion,
41+
fallbackStageLabel: APP_STAGE_LABEL,
42+
});
43+
}
44+
45+
export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBackdropVariant | null {
46+
return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled);
3547
}
3648

3749
/** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. */

‎apps/web/src/components/chat/ComposerPrimaryActions.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { memo, type PointerEventHandler } from "react";
22
import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
3+
import { useClientSettings } from "~/hooks/useSettings";
34
import { cn } from "~/lib/utils";
45
import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
56
import { Button } from "../ui/button";
@@ -72,7 +73,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
7273
const pointerFocusProps = preserveComposerFocusOnPointerDown
7374
? { onPointerDown: preventPointerFocus }
7475
: undefined;
75-
const stageBackdropVariant = useSidebarStageBackdropVariant();
76+
const environmentIdentificationMode = useClientSettings(
77+
(settings) => settings.environmentIdentificationMode,
78+
);
79+
const stageBackdropVariant = useSidebarStageBackdropVariant(
80+
environmentIdentificationMode === "artwork",
81+
);
7682

7783
if (pendingAction) {
7884
return (

‎apps/web/src/components/settings/BetaSettingsPanel.tsx‎

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useEffect, useState } from "react";
22

33
import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings";
4-
import { useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
54
import { Input } from "../ui/input";
65
import { Switch } from "../ui/switch";
76
import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
@@ -52,10 +51,6 @@ function AutoSettleDaysInput({
5251
}
5352

5453
export function BetaSettingsPanel() {
55-
const sidebarStageBackdropVariant = useSidebarStageBackdropVariant();
56-
const sidebarStageArtworkEnabled = useClientSettings(
57-
(settings) => settings.sidebarStageArtworkEnabled,
58-
);
5954
const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
6055
const sidebarAutoSettleAfterDays = useClientSettings(
6156
(settings) => settings.sidebarAutoSettleAfterDays,
@@ -65,21 +60,6 @@ export function BetaSettingsPanel() {
6560
return (
6661
<SettingsPageContainer>
6762
<SettingsSection title="Beta features">
68-
{sidebarStageBackdropVariant ? (
69-
<SettingsRow
70-
title="Sidebar header artwork"
71-
description="Show channel-specific artwork behind the sidebar header in Dev and Nightly builds."
72-
control={
73-
<Switch
74-
checked={sidebarStageArtworkEnabled}
75-
onCheckedChange={(checked) =>
76-
updateSettings({ sidebarStageArtworkEnabled: Boolean(checked) })
77-
}
78-
aria-label="Show sidebar header artwork"
79-
/>
80-
}
81-
/>
82-
) : null}
8363
<SettingsRow
8464
title="Sidebar v2"
8565
description="One flat thread list in creation order. Active work renders as rich cards; settled threads collapse to compact rows. Settling requires an up-to-date server — on older servers threads simply stay active. Switch back any time."

‎apps/web/src/components/settings/SettingsPanels.tsx‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import {
2121
squashAtomCommandFailure,
2222
} from "@t3tools/client-runtime/state/runtime";
2323
import {
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";
4143
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
4244
import { TraitsPicker } from "../chat/TraitsPicker";
45+
import {
46+
resolveEnvironmentIdentificationPillLabel,
47+
useEnvironmentStageLabel,
48+
} from "../SidebarStageBackdrop";
4349
import { isElectron } from "../../env";
4450
import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
4551
import { useTheme } from "../../hooks/useTheme";
@@ -114,6 +120,12 @@ const THEME_OPTIONS = [
114120
},
115121
] as const;
116122

123+
const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
124+
artwork: "Artwork",
125+
pill: "Version pill",
126+
none: "None",
127+
};
128+
117129
const 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‎

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import { useAtomValue } from "@effect/atom-react";
21
import { SettingsIcon } from "lucide-react";
32
import { memo, useCallback } from "react";
43
import { Link, useNavigate } from "@tanstack/react-router";
54

6-
import { APP_STAGE_LABEL } from "../../branding";
75
import { useClientSettings } from "../../hooks/useSettings";
86
import { cn } from "../../lib/utils";
9-
import { primaryServerConfigAtom } from "../../state/server";
10-
import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic";
11-
import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
7+
import {
8+
resolveEnvironmentIdentificationPillLabel,
9+
resolveSidebarStageBackdropVariant,
10+
SidebarStageBackdrop,
11+
useEnvironmentStageLabel,
12+
} from "../SidebarStageBackdrop";
13+
import { Badge } from "../ui/badge";
1214
import {
1315
SidebarFooter,
1416
SidebarHeader,
@@ -26,14 +28,18 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
2628
}: {
2729
isElectron: boolean;
2830
}) {
29-
const stageLabel = useSidebarStageLabel();
30-
const sidebarStageArtworkEnabled = useClientSettings(
31-
(settings) => settings.sidebarStageArtworkEnabled,
31+
const stageLabel = useEnvironmentStageLabel();
32+
const environmentIdentificationMode = useClientSettings(
33+
(settings) => settings.environmentIdentificationMode,
3234
);
3335
const backdropVariant = resolveSidebarStageBackdropVariant(
3436
stageLabel,
35-
sidebarStageArtworkEnabled,
37+
environmentIdentificationMode === "artwork",
3638
);
39+
const pillLabel =
40+
environmentIdentificationMode === "pill"
41+
? resolveEnvironmentIdentificationPillLabel(stageLabel)
42+
: null;
3743

3844
return (
3945
<SidebarHeader
@@ -51,6 +57,16 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
5157
)}
5258
/>
5359
<SidebarBrand onBackdrop={backdropVariant !== null} />
60+
{pillLabel ? (
61+
<Badge
62+
className="relative z-10 ml-1 rounded-full px-1.5 text-muted-foreground"
63+
data-environment-identification="pill"
64+
size="sm"
65+
variant="secondary"
66+
>
67+
{pillLabel}
68+
</Badge>
69+
) : null}
5470
</SidebarHeader>
5571
);
5672
});
@@ -78,16 +94,6 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
7894
);
7995
}
8096

81-
function useSidebarStageLabel() {
82-
const primaryServerVersion =
83-
useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
84-
85-
return resolveSidebarStageBadgeLabel({
86-
primaryServerVersion,
87-
fallbackStageLabel: APP_STAGE_LABEL,
88-
});
89-
}
90-
9197
function T3Wordmark() {
9298
return (
9399
<svg

‎packages/contracts/src/settings.test.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,21 @@ describe("ClientSettings glass opacity", () => {
4949
});
5050
});
5151

52-
describe("ClientSettings sidebar stage artwork", () => {
53-
it("defaults the artwork on and accepts opt-out patches", () => {
54-
expect(decodeClientSettings({}).sidebarStageArtworkEnabled).toBe(true);
55-
expect(
56-
decodeClientSettingsPatch({ sidebarStageArtworkEnabled: false }).sidebarStageArtworkEnabled,
57-
).toBe(false);
52+
describe("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();
5867
});
5968
});
6069

0 commit comments

Comments
 (0)