Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = {
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
environmentIdentificationMode: "artwork",
favorites: [],
glassOpacity: 80,
providerModelPreferences: {},
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(() => {
Expand Down
21 changes: 20 additions & 1 deletion apps/web/src/components/SidebarStageBackdrop.test.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
28 changes: 21 additions & 7 deletions apps/web/src/components/SidebarStageBackdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,44 @@ 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.
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/`. */
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -114,6 +120,12 @@ const THEME_OPTIONS = [
},
] as const;

const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
artwork: "Artwork",
pill: "Version pill",
none: "None",
};

const TIMESTAMP_FORMAT_LABELS = {
locale: "System default",
"12-hour": "12-hour",
Expand Down Expand Up @@ -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"]
: []),
Expand Down Expand Up @@ -456,6 +472,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
settings.diffIgnoreWhitespace,
settings.environmentIdentificationMode,
settings.glassOpacity,
settings.automaticGitFetchInterval,
settings.enableAssistantStreaming,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<SidebarProjectGroupingMode>(
readLastEnabledProjectGroupingMode(),
);
Expand Down Expand Up @@ -634,6 +655,48 @@ export function GeneralSettingsPanel() {
}
/>

{showEnvironmentIdentification ? (
<SettingsRow
title="Environment identification"
description="Choose how Dev and Nightly environments are identified."
resetAction={
settings.environmentIdentificationMode !== DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE ? (
<SettingResetButton
label="environment identification"
onClick={() =>
updateSettings({
environmentIdentificationMode: DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
})
}
/>
) : null
}
control={
<Select
value={settings.environmentIdentificationMode}
onValueChange={(value) => {
if (value === "artwork" || value === "pill" || value === "none") {
updateSettings({ environmentIdentificationMode: value });
}
}}
>
<SelectTrigger className="w-full sm:w-40" aria-label="Environment identification">
<SelectValue>
{ENVIRONMENT_IDENTIFICATION_LABELS[settings.environmentIdentificationMode]}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{Object.entries(ENVIRONMENT_IDENTIFICATION_LABELS).map(([value, label]) => (
<SelectItem hideIndicator key={value} value={value}>
{label}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>
) : null}

<SettingsRow
title="Project Grouping"
description="Combine matching repositories across environments."
Expand Down
45 changes: 28 additions & 17 deletions apps/web/src/components/sidebar/SidebarChrome.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { useAtomValue } from "@effect/atom-react";
import { SettingsIcon } from "lucide-react";
import { memo, useCallback } from "react";
import { Link, useNavigate } from "@tanstack/react-router";

import { APP_STAGE_LABEL } from "../../branding";
import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
import { cn } from "../../lib/utils";
import { primaryServerConfigAtom } from "../../state/server";
import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic";
import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
import {
resolveEnvironmentIdentificationPillLabel,
resolveSidebarStageBackdropVariant,
SidebarStageBackdrop,
useEnvironmentStageLabel,
} from "../SidebarStageBackdrop";
import { Badge } from "../ui/badge";
import {
SidebarFooter,
SidebarHeader,
Expand All @@ -25,8 +28,16 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
}: {
isElectron: boolean;
}) {
const stageLabel = useSidebarStageLabel();
const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel);
const stageLabel = useEnvironmentStageLabel();
const environmentIdentificationMode = useEnvironmentIdentificationMode();
const backdropVariant = resolveSidebarStageBackdropVariant(
stageLabel,
environmentIdentificationMode === "artwork",
);
const pillLabel =
environmentIdentificationMode === "pill"
? resolveEnvironmentIdentificationPillLabel(stageLabel)
: null;
Comment thread
maxktz marked this conversation as resolved.

return (
<SidebarHeader
Expand All @@ -44,6 +55,16 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
)}
/>
<SidebarBrand onBackdrop={backdropVariant !== null} />
{pillLabel ? (
<Badge
className="relative z-10 ml-1 rounded-full px-1.5 text-muted-foreground"
data-environment-identification="pill"
size="sm"
variant="secondary"
>
{pillLabel}
</Badge>
) : null}
</SidebarHeader>
);
});
Expand Down Expand Up @@ -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 (
<svg
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/hooks/useSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@ import {
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import { describe, expect, it } from "vite-plus/test";

import { mergeEnvironmentSettings } from "./useSettings";
import { mergeEnvironmentSettings, resolveEnvironmentIdentificationMode } from "./useSettings";

describe("resolveEnvironmentIdentificationMode", () => {
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", () => {
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -222,6 +223,20 @@ export function useClientSettings<T = ClientSettings>(
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
Expand Down
18 changes: 18 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down
Loading
Loading