diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f882..a740fbafeee 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -30,6 +30,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, sidebarV2Enabled: false, + sidebarV2ConfiguredByUser: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 36cead9f8cc..4265107912b 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,7 +1,5 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; -import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; @@ -12,7 +10,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; import { useThemeColor } from "../../lib/useThemeColor"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; @@ -59,21 +57,14 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } -/** Thread List v2 lays the list out in fixed creation order, so the - sort/group filter controls would be silently ignored — hide them and - key the "customized" icon state off the environment filter alone. */ -function useThreadListV2FilterGate() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - return ( - AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true - ); -} - function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); - const threadListV2Enabled = useThreadListV2FilterGate(); + // Thread List v2 lays the list out in fixed creation order, so the + // sort/group filter controls would be silently ignored — hide them and + // key the "customized" icon state off the environment filter alone. + const threadListV2Enabled = useThreadListV2Enabled(); const hasCustomListOptions = threadListV2Enabled ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null : hasCustomHomeListOptions(props); @@ -291,7 +282,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useThemeColor("--color-icon"); - const threadListV2Enabled = useThreadListV2FilterGate(); + // Thread List v2 lays the list out in fixed creation order, so the + // sort/group filter controls would be silently ignored — hide them and + // key the "customized" icon state off the environment filter alone. + const threadListV2Enabled = useThreadListV2Enabled(); const hasCustomListOptions = threadListV2Enabled ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null : hasCustomHomeListOptions(props); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 54e242c2166..22c0a31bda2 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -27,6 +27,7 @@ import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -181,9 +182,7 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + const threadListV2Enabled = useThreadListV2Enabled(); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 4c33675f57f..476efe40155 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -37,6 +37,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -546,11 +547,8 @@ function GeneralSettingsSection() { * the counterpart of web's Settings → Beta backed by mobile preferences. */ function BetaSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.threadListV2Enabled === true - : false; + const threadListV2Enabled = useThreadListV2Enabled(); return ( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 799d969da3e..ea8d4f07955 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -6,7 +6,6 @@ import type { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; @@ -25,7 +24,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -196,10 +195,7 @@ function ThreadNavigationSidebarPane( const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index e62b9ceda34..b3e9f73bfe1 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadListV2Items, + resolveThreadListV2Enabled, resolveThreadListV2Status, sortThreadsForListV2, } from "./threadListV2"; @@ -38,6 +39,47 @@ function makeThread( const NOW = "2026-06-02T00:00:00.000Z"; +describe("resolveThreadListV2Enabled", () => { + it.each(["development", "preview"])("defaults on for the %s variant", (appVariant) => { + expect( + resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }), + ).toBe(true); + }); + + it.each(["production", undefined])("defaults off for the %s variant", (appVariant) => { + expect( + resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }), + ).toBe(false); + }); + + it("prefers an explicit device choice over the variant default", () => { + expect( + resolveThreadListV2Enabled({ + preference: false, + preferencesLoaded: true, + appVariant: "preview", + }), + ).toBe(false); + expect( + resolveThreadListV2Enabled({ + preference: true, + preferencesLoaded: true, + appVariant: "production", + }), + ).toBe(true); + }); + + it("holds v1 while preferences are still loading so the list does not remount", () => { + expect( + resolveThreadListV2Enabled({ + preference: undefined, + preferencesLoaded: false, + appVariant: "development", + }), + ).toBe(false); + }); +}); + describe("resolveThreadListV2Status", () => { it("prioritizes approval over a running session", () => { const thread = makeThread({ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index efa68153a3f..62c0b39aeb8 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -18,6 +18,37 @@ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | " export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; +/** + * Whether Thread List v2 is on by default for an app variant. The `development` + * and `preview` variants are mobile's nightly equivalents and opt in; + * `production` stays on v1. Counterpart of web's `resolveSidebarV2Default`. + */ +export function resolveThreadListV2Default(appVariant: unknown): boolean { + return appVariant === "development" || appVariant === "preview"; +} + +/** + * Resolved Thread List v2 state: the device-local preference if the user has + * set one, otherwise the default for this app variant. Preferences persist as + * sparse patches, so `undefined` genuinely means "never chosen". + * + * `preferencesLoaded` guards the startup window: preferences load + * asynchronously, and treating "still loading" as "never chosen" would mount + * v2 on a development build and then flip to v1 once a stored opt-out arrives, + * remounting the whole list. While loading, hold v1 — the state both variants + * already start from. + */ +export function resolveThreadListV2Enabled(input: { + readonly preference: boolean | undefined; + readonly preferencesLoaded: boolean; + readonly appVariant: unknown; +}): boolean { + if (!input.preferencesLoaded) { + return false; + } + return input.preference ?? resolveThreadListV2Default(input.appVariant); +} + export function resolveThreadListV2Status( thread: Pick, ): ThreadListV2Status { diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts new file mode 100644 index 00000000000..bb03b5aa9ad --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -0,0 +1,25 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import Constants from "expo-constants"; + +import { mobilePreferencesAtom } from "../../state/preferences"; +import { resolveThreadListV2Enabled } from "./threadListV2"; + +/** + * Resolved Thread List v2 state: the device-local preference if the user has + * set one, otherwise the default for this app variant (on for development and + * preview, off for production). Every consumer must read through this rather + * than the raw preference, which is undefined until explicitly chosen. + * + * Kept out of `state/preferences.ts` so that module stays importable from node + * test environments, which have no `__DEV__` for expo-constants. + */ +export function useThreadListV2Enabled(): boolean { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferencesResult); + return resolveThreadListV2Enabled({ + preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined, + preferencesLoaded: loaded, + appVariant: Constants.expoConfig?.extra?.appVariant, + }); +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 4e576bb2fe1..bbcf4131f31 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -26,7 +26,8 @@ export interface Preferences { /** * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no * client-settings sync, so the flat v2 thread list is opted into per - * device. + * device. Undefined means the user has never chosen, in which case the app + * variant decides — see `resolveThreadListV2Enabled`. */ readonly threadListV2Enabled?: boolean; } diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 056fbb76e6a..06d663ca0b4 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -11,6 +11,51 @@ export function formatAppDisplayName(input: { return `${input.baseName} (${input.stageLabel})`; } +/** + * Whether the sidebar v2 beta is on by default for a build stage. + * + * Nightly and local dev opt in; Alpha and Latest stay on v1. This is resolved + * from the client's own stage label rather than the connected server's version: + * v2 only exists in the client, so a stable client on a nightly server has + * nothing to turn on. + */ +export function resolveSidebarV2Default(stageLabel: string): boolean { + const stage = stageLabel.trim().toLowerCase(); + return stage === "nightly" || stage === "dev"; +} + +/** + * Resolved sidebar v2 state: an explicit choice if the user has made one, + * otherwise the default for this build stage. + * + * A stored `enabled: true` counts as an explicit choice even without the + * companion flag. `true` was never the schema default, so it can only have come + * from the Settings → Beta toggle — settings written before that flag existed + * would otherwise lose the opt-in and drop such users back to v1 on production. + * Mirrors how `normalizeDesktopSettingsDocument` treats a legacy stored + * `updateChannel: "nightly"` as user-configured. + * + * `settingsHydrated` guards the startup window: client settings load + * asynchronously and the pre-hydration snapshot is just the schema defaults, so + * resolving against it would mount one sidebar and swap it out a tick later, + * remounting the tree. While hydrating, hold v1 — where both paths already + * start. + */ +export function resolveSidebarV2Enabled(input: { + readonly enabled: boolean; + readonly configuredByUser: boolean; + readonly settingsHydrated: boolean; + readonly stageLabel: string; +}): boolean { + if (!input.settingsHydrated) { + return false; + } + + return input.configuredByUser || input.enabled + ? input.enabled + : resolveSidebarV2Default(input.stageLabel); +} + export function resolveServerBackedAppStageLabel(input: { readonly primaryServerVersion: string | null | undefined; readonly fallbackStageLabel: string; diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index e1c87bcf059..e517d40b04f 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { resolveServerBackedAppDisplayName, resolveServerBackedAppStageLabel, + resolveSidebarV2Default, + resolveSidebarV2Enabled, } from "./branding.logic"; const originalWindow = globalThis.window; @@ -114,3 +116,74 @@ describe("branding logic", () => { ).toBe("T3 Code (Alpha)"); }); }); + +describe("resolveSidebarV2Default", () => { + it.each(["Nightly", "Dev", "nightly", " dev "])("enables the beta for %s builds", (stage) => { + expect(resolveSidebarV2Default(stage)).toBe(true); + }); + + it.each(["Alpha", "Latest", ""])("leaves the beta off for %s builds", (stage) => { + expect(resolveSidebarV2Default(stage)).toBe(false); + }); +}); + +describe("resolveSidebarV2Enabled", () => { + const hydrated = { settingsHydrated: true } as const; + + it.each(["Alpha", "Latest"])( + "keeps a legacy opt-in on %s builds even without the companion flag", + (stageLabel) => { + // `true` was never the schema default, so it can only be an explicit + // opt-in from settings written before `sidebarV2ConfiguredByUser` existed. + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: true, + configuredByUser: false, + stageLabel, + }), + ).toBe(true); + }, + ); + + it("applies the stage default when the beta was never enabled or configured", () => { + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: false, + stageLabel: "Nightly", + }), + ).toBe(true); + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: false, + stageLabel: "Latest", + }), + ).toBe(false); + }); + + it("honors an explicit opt-out over the stage default", () => { + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: true, + stageLabel: "Nightly", + }), + ).toBe(false); + }); + + it("holds v1 until settings hydrate so the sidebar does not remount", () => { + expect( + resolveSidebarV2Enabled({ + enabled: true, + configuredByUser: true, + settingsHydrated: false, + stageLabel: "Nightly", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 3a70390d0c4..5ff6281f35a 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -8,7 +8,7 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useClientSettings } from "../hooks/useSettings"; +import { useSidebarV2Enabled } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; import ThreadSidebarV2 from "./SidebarV2"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; @@ -100,7 +100,7 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); - const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarV2Enabled = useSidebarV2Enabled(); // Settings routes render the settings nav, which lives in the v1 component // and is identical for both sidebars — so v1 stays mounted there. const pathname = useLocation({ select: (location) => location.pathname }); diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 942bd374d91..5a1ac036077 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -1,6 +1,10 @@ import { useEffect, useState } from "react"; -import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { + useClientSettings, + useSidebarV2Enabled, + useUpdateClientSettings, +} from "../../hooks/useSettings"; import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; @@ -51,7 +55,7 @@ function AutoSettleDaysInput({ } export function BetaSettingsPanel() { - const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarV2Enabled = useSidebarV2Enabled(); const sidebarAutoSettleAfterDays = useClientSettings( (settings) => settings.sidebarAutoSettleAfterDays, ); @@ -66,7 +70,14 @@ export function BetaSettingsPanel() { control={ updateSettings({ sidebarV2Enabled: Boolean(checked) })} + // Touching the switch pins the choice, so a nightly build that + // defaults v2 on does not flip it back after the user opts out. + onCheckedChange={(checked) => + updateSettings({ + sidebarV2Enabled: Boolean(checked), + sidebarV2ConfiguredByUser: true, + }) + } aria-label="Enable the sidebar v2 beta" /> } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 514484d896a..5ff60804514 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -24,6 +24,8 @@ import { type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { APP_STAGE_LABEL } from "~/branding"; +import { resolveSidebarV2Enabled } from "~/branding.logic"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; @@ -218,6 +220,32 @@ export function useClientSettings( return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]); } +/** + * 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 + * dev, off for production). Every consumer must read through this rather than + * `settings.sidebarV2Enabled`, which is only meaningful alongside + * `sidebarV2ConfiguredByUser`. + * + * Held at v1 until client settings hydrate. The pre-hydration snapshot is just + * the schema defaults, so resolving against it would mount one sidebar and then + * swap it out once persisted settings land — remounting the whole tree. + */ +export function useSidebarV2Enabled(): boolean { + const settingsHydrated = useClientSettingsHydrated(); + const settings = useClientSettingsValue(); + return useMemo( + () => + resolveSidebarV2Enabled({ + enabled: settings.sidebarV2Enabled, + configuredByUser: settings.sidebarV2ConfiguredByUser, + settingsHydrated, + stageLabel: APP_STAGE_LABEL, + }), + [settings.sidebarV2Enabled, settings.sidebarV2ConfiguredByUser, settingsHydrated], + ); +} + /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d3cf003d99c..75c517dc33f 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,7 +3,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; @@ -28,7 +28,7 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarV2Enabled = useSidebarV2Enabled(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e8eaf723e17..b25a0456721 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -56,6 +56,31 @@ describe("ClientSettings sidebar v2", () => { expect(settings.sidebarAutoSettleAfterDays).toBe(3); }); + it("treats settings written before the beta had a per-channel default as unconfigured", () => { + // The stored blob always carries `sidebarV2Enabled`, so only the companion + // flag can distinguish "user opted out" from "never touched it". + expect(decodeClientSettings({ sidebarV2Enabled: false }).sidebarV2ConfiguredByUser).toBe(false); + expect(decodeClientSettings({ sidebarV2Enabled: true }).sidebarV2ConfiguredByUser).toBe(false); + }); + + it("preserves an explicit beta choice", () => { + const settings = decodeClientSettings({ + sidebarV2Enabled: false, + sidebarV2ConfiguredByUser: true, + }); + expect(settings.sidebarV2Enabled).toBe(false); + expect(settings.sidebarV2ConfiguredByUser).toBe(true); + }); + + it("carries an explicit beta opt-out through the patch the beta toggle writes", () => { + const patch = decodeClientSettingsPatch({ + sidebarV2Enabled: false, + sidebarV2ConfiguredByUser: true, + }); + expect(patch.sidebarV2Enabled).toBe(false); + expect(patch.sidebarV2ConfiguredByUser).toBe(true); + }); + it("allows auto-settle by inactivity to be disabled", () => { expect( decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 06f7de3db67..35269a14d2f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -115,6 +115,12 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Whether `sidebarV2Enabled` reflects an explicit choice in Settings → Beta. + // Client settings persist as a whole blob, so every user who has ever touched + // any setting already has `sidebarV2Enabled: false` stored — without this bit + // there is no way to tell that apart from "left alone", and a channel-derived + // default could never reach them. Mirrors `updateChannelConfiguredByUser`. + sidebarV2ConfiguredByUser: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -604,6 +610,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), + sidebarV2ConfiguredByUser: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), });