From 08d8f88b9d2d0c60a2819718cbf562d6911c0cd0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 17:00:51 -0700 Subject: [PATCH 1/2] feat: default sidebar v2 on for nightly and dev builds Sidebar v2 stays off by default in production, but nightly and dev builds now opt in automatically so the beta gets exercised where regressions are cheap. Web/desktop resolve the default from `APP_STAGE_LABEL`, which already covers hosted nightly, packaged nightly desktop, and local dev. Mobile resolves from the app variant, where `development` and `preview` are the nightly equivalents. Client settings persist as a whole blob, so a stored `sidebarV2Enabled: false` could not be told apart from "never touched it". Adds a companion `sidebarV2ConfiguredByUser` flag, mirroring `updateChannelConfiguredByUser`, so the channel default only applies until the user picks a side. Mobile preferences persist sparse patches, so `undefined` already carries that meaning there and needs no new field. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/DesktopClientSettings.test.ts | 1 + apps/mobile/src/features/home/HomeHeader.tsx | 24 +++++++----------- apps/mobile/src/features/home/HomeScreen.tsx | 5 ++-- .../features/settings/SettingsRouteScreen.tsx | 6 ++--- .../threads/ThreadNavigationSidebar.tsx | 8 ++---- .../src/features/threads/threadListV2.test.ts | 16 ++++++++++++ .../src/features/threads/threadListV2.ts | 21 ++++++++++++++++ .../threads/use-thread-list-v2-enabled.ts | 25 +++++++++++++++++++ .../src/persistence/mobile-preferences.ts | 3 ++- apps/web/src/branding.logic.ts | 13 ++++++++++ apps/web/src/branding.test.ts | 11 ++++++++ apps/web/src/components/AppSidebarLayout.tsx | 4 +-- .../components/settings/BetaSettingsPanel.tsx | 17 ++++++++++--- apps/web/src/hooks/useSettings.ts | 17 +++++++++++++ apps/web/src/routes/_chat.tsx | 4 +-- packages/contracts/src/settings.test.ts | 16 ++++++++++++ packages/contracts/src/settings.ts | 7 ++++++ 17 files changed, 162 insertions(+), 36 deletions(-) create mode 100644 apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts 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..784ea28d10b 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,21 @@ 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, appVariant })).toBe(true); + }); + + it.each(["production", undefined])("defaults off for the %s variant", (appVariant) => { + expect(resolveThreadListV2Enabled({ preference: undefined, appVariant })).toBe(false); + }); + + it("prefers an explicit device choice over the variant default", () => { + expect(resolveThreadListV2Enabled({ preference: false, appVariant: "preview" })).toBe(false); + expect(resolveThreadListV2Enabled({ preference: true, appVariant: "production" })).toBe(true); + }); +}); + 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..843b3576062 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -18,6 +18,27 @@ 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". + */ +export function resolveThreadListV2Enabled(input: { + readonly preference: boolean | undefined; + readonly appVariant: unknown; +}): boolean { + 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..40bca052a90 --- /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); + return resolveThreadListV2Enabled({ + preference: AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.threadListV2Enabled + : undefined, + 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..b34d7fdffb4 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -11,6 +11,19 @@ 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"; +} + 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..40a88b9e499 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { resolveServerBackedAppDisplayName, resolveServerBackedAppStageLabel, + resolveSidebarV2Default, } from "./branding.logic"; const originalWindow = globalThis.window; @@ -114,3 +115,13 @@ 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); + }); +}); 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..9866e5b875d 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 { resolveSidebarV2Default } from "~/branding.logic"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; @@ -218,6 +220,21 @@ 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`. + */ +export function useSidebarV2Enabled(): boolean { + return useClientSettings((settings) => + settings.sidebarV2ConfiguredByUser + ? settings.sidebarV2Enabled + : resolveSidebarV2Default(APP_STAGE_LABEL), + ); +} + /** 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..7a50e632d5f 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -56,6 +56,22 @@ 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("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), }); From 6713291c20ba767afbab53ae0616c1e0304f13d4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 17:34:43 -0700 Subject: [PATCH 2/2] fix: preserve legacy sidebar v2 opt-in and gate on hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught two real regressions in the per-channel default. Legacy opt-in was silently discarded: users who had turned sidebar v2 on before `sidebarV2ConfiguredByUser` existed have `sidebarV2Enabled: true` with no companion flag, so they fell back to the channel default and lost the beta on production. `true` was never the schema default, so it can only come from the toggle — treat it as an explicit choice, matching how `normalizeDesktopSettingsDocument` reads a legacy stored `updateChannel`. Both platforms also resolved against pre-load state, mounting one sidebar and swapping it a tick later. Web now gates on client-settings hydration and mobile on the preferences atom having loaded; both hold v1 until then. The web flash predates this PR — the pre-hydration snapshot was always `false`, so opted-in users already saw a v1→v2 flip. Resolution moves into `resolveSidebarV2Enabled` alongside the mobile counterpart, so both are covered by pure tests. Also adds patch-decoder coverage for the field the beta toggle actually writes. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/features/threads/threadListV2.test.ts | 34 ++++++++-- .../src/features/threads/threadListV2.ts | 10 +++ .../threads/use-thread-list-v2-enabled.ts | 6 +- apps/web/src/branding.logic.ts | 32 ++++++++++ apps/web/src/branding.test.ts | 62 +++++++++++++++++++ apps/web/src/hooks/useSettings.ts | 21 +++++-- packages/contracts/src/settings.test.ts | 9 +++ 7 files changed, 162 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 784ea28d10b..b3e9f73bfe1 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -41,16 +41,42 @@ 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, appVariant })).toBe(true); + expect( + resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }), + ).toBe(true); }); it.each(["production", undefined])("defaults off for the %s variant", (appVariant) => { - expect(resolveThreadListV2Enabled({ preference: undefined, appVariant })).toBe(false); + expect( + resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }), + ).toBe(false); }); it("prefers an explicit device choice over the variant default", () => { - expect(resolveThreadListV2Enabled({ preference: false, appVariant: "preview" })).toBe(false); - expect(resolveThreadListV2Enabled({ preference: true, appVariant: "production" })).toBe(true); + 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); }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 843b3576062..62c0b39aeb8 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -31,11 +31,21 @@ export function resolveThreadListV2Default(appVariant: unknown): boolean { * 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); } 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 index 40bca052a90..bb03b5aa9ad 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -16,10 +16,10 @@ import { resolveThreadListV2Enabled } from "./threadListV2"; */ export function useThreadListV2Enabled(): boolean { const preferencesResult = useAtomValue(mobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferencesResult); return resolveThreadListV2Enabled({ - preference: AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.threadListV2Enabled - : undefined, + preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined, + preferencesLoaded: loaded, appVariant: Constants.expoConfig?.extra?.appVariant, }); } diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index b34d7fdffb4..06d663ca0b4 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -24,6 +24,38 @@ export function resolveSidebarV2Default(stageLabel: string): boolean { 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 40a88b9e499..e517d40b04f 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -3,6 +3,7 @@ import { resolveServerBackedAppDisplayName, resolveServerBackedAppStageLabel, resolveSidebarV2Default, + resolveSidebarV2Enabled, } from "./branding.logic"; const originalWindow = globalThis.window; @@ -125,3 +126,64 @@ describe("resolveSidebarV2Default", () => { 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/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 9866e5b875d..5ff60804514 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -25,7 +25,7 @@ import { } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { APP_STAGE_LABEL } from "~/branding"; -import { resolveSidebarV2Default } from "~/branding.logic"; +import { resolveSidebarV2Enabled } from "~/branding.logic"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; @@ -226,12 +226,23 @@ export function useClientSettings( * 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 { - return useClientSettings((settings) => - settings.sidebarV2ConfiguredByUser - ? settings.sidebarV2Enabled - : resolveSidebarV2Default(APP_STAGE_LABEL), + 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], ); } diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 7a50e632d5f..b25a0456721 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -72,6 +72,15 @@ describe("ClientSettings sidebar v2", () => { 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,