Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
9 changes: 7 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 { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings";
import ThreadSidebar from "./Sidebar";
import ThreadSidebarV2 from "./SidebarV2";
import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
Expand Down Expand Up @@ -62,7 +62,12 @@ function SidebarControl() {
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const { toggleSidebar } = useSidebar();
const isSidebarVisible = useSidebarVisibility();
const stageBackdropVariant = useSidebarStageBackdropVariant();
const environmentIdentificationMode = useClientSettings(
(settings) => settings.environmentIdentificationMode,
);
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
8 changes: 7 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 { useClientSettings } 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,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
const pointerFocusProps = preserveComposerFocusOnPointerDown
? { onPointerDown: preventPointerFocus }
: undefined;
const stageBackdropVariant = useSidebarStageBackdropVariant();
const environmentIdentificationMode = useClientSettings(
(settings) => settings.environmentIdentificationMode,
);
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
47 changes: 30 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 { useClientSettings } 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,18 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
}: {
isElectron: boolean;
}) {
const stageLabel = useSidebarStageLabel();
const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel);
const stageLabel = useEnvironmentStageLabel();
const environmentIdentificationMode = useClientSettings(
(settings) => settings.environmentIdentificationMode,
);
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 +57,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 +94,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
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
7 changes: 7 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
Expand All @@ -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)),
),
Expand Down Expand Up @@ -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(
Expand Down
Loading