authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-27 21:41:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 21:41:08-07:00
logc13a021e432e94728fe88ff983fb9601c6d6b943
treeb86efc9ca8421c1c662fe19a630a3604deb9661b
parent476d69cd1d5d89b4aad77df8fc01c71e34af930c
signature Signed by PGP key B5690EEEBB952194

feat: default sidebar v2 on for nightly and dev builds (#4491)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

17 files changed, 312 insertions(+), 36 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
...@@ -30,6 +30,7 @@ const clientSettings: ClientSettings = {...@@ -30,6 +30,7 @@ const clientSettings: ClientSettings = {
30 sidebarThreadSortOrder: "created_at",30 sidebarThreadSortOrder: "created_at",
31 sidebarThreadPreviewCount: 6,31 sidebarThreadPreviewCount: 6,
32 sidebarV2Enabled: false,32 sidebarV2Enabled: false,
33 sidebarV2ConfiguredByUser: false,
33 timestampFormat: "24-hour",34 timestampFormat: "24-hour",
34 wordWrap: true,35 wordWrap: true,
35};36};
apps/mobile/src/features/home/HomeHeader.tsx+9-15
...@@ -1,7 +1,5 @@...@@ -1,7 +1,5 @@
1import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";1import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";
2import type { MenuAction } from "@react-native-menu/menu";2import type { MenuAction } from "@react-native-menu/menu";
3import { useAtomValue } from "@effect/atom-react";
4import { AsyncResult } from "effect/unstable/reactivity";
5import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";3import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
6import { useCallback, useMemo, useRef } from "react";4import { useCallback, useMemo, useRef } from "react";
7import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native";5import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native";
...@@ -12,7 +10,7 @@ import { ControlPillMenu } from "../../components/ControlPill";...@@ -12,7 +10,7 @@ import { ControlPillMenu } from "../../components/ControlPill";
12import { SymbolView } from "../../components/AppSymbol";10import { SymbolView } from "../../components/AppSymbol";
13import { T3Wordmark } from "../../components/T3Wordmark";11import { T3Wordmark } from "../../components/T3Wordmark";
14import { useThemeColor } from "../../lib/useThemeColor";12import { useThemeColor } from "../../lib/useThemeColor";
15import { mobilePreferencesAtom } from "../../state/preferences";13import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
16import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";14import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
17import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";15import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
18import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";16import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
...@@ -59,21 +57,14 @@ function checkedMenuState(checked: boolean) {...@@ -59,21 +57,14 @@ function checkedMenuState(checked: boolean) {
59 return checked ? ("on" as const) : undefined;57 return checked ? ("on" as const) : undefined;
60}58}
6159
62/** Thread List v2 lays the list out in fixed creation order, so the
63 sort/group filter controls would be silently ignored — hide them and
64 key the "customized" icon state off the environment filter alone. */
65function useThreadListV2FilterGate() {
66 const preferencesResult = useAtomValue(mobilePreferencesAtom);
67 return (
68 AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true
69 );
70}
71
72function AndroidHomeHeader(props: HomeHeaderProps) {60function AndroidHomeHeader(props: HomeHeaderProps) {
73 const insets = useSafeAreaInsets();61 const insets = useSafeAreaInsets();
74 const iconColor = useThemeColor("--color-icon");62 const iconColor = useThemeColor("--color-icon");
75 const mutedColor = useThemeColor("--color-foreground-muted");63 const mutedColor = useThemeColor("--color-foreground-muted");
76 const threadListV2Enabled = useThreadListV2FilterGate();64 // Thread List v2 lays the list out in fixed creation order, so the
65 // sort/group filter controls would be silently ignored — hide them and
66 // key the "customized" icon state off the environment filter alone.
67 const threadListV2Enabled = useThreadListV2Enabled();
77 const hasCustomListOptions = threadListV2Enabled68 const hasCustomListOptions = threadListV2Enabled
78 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null69 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
79 : hasCustomHomeListOptions(props);70 : hasCustomHomeListOptions(props);
...@@ -291,7 +282,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) {...@@ -291,7 +282,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
291function IosHomeHeader(props: HomeHeaderProps) {282function IosHomeHeader(props: HomeHeaderProps) {
292 const searchBarRef = useRef<SearchBarCommands>(null);283 const searchBarRef = useRef<SearchBarCommands>(null);
293 const iconColor = useThemeColor("--color-icon");284 const iconColor = useThemeColor("--color-icon");
294 const threadListV2Enabled = useThreadListV2FilterGate();285 // Thread List v2 lays the list out in fixed creation order, so the
286 // sort/group filter controls would be silently ignored — hide them and
287 // key the "customized" icon state off the environment filter alone.
288 const threadListV2Enabled = useThreadListV2Enabled();
295 const hasCustomListOptions = threadListV2Enabled289 const hasCustomListOptions = threadListV2Enabled
296 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null290 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
297 : hasCustomHomeListOptions(props);291 : hasCustomHomeListOptions(props);
apps/mobile/src/features/home/HomeScreen.tsx+2-3
...@@ -27,6 +27,7 @@ import type { SavedRemoteConnection } from "../../lib/connection";...@@ -27,6 +27,7 @@ import type { SavedRemoteConnection } from "../../lib/connection";
27import { scopedProjectKey } from "../../lib/scopedEntities";27import { scopedProjectKey } from "../../lib/scopedEntities";
28import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";28import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
29import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";29import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
30import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
30import { environmentServerConfigsAtom } from "../../state/server";31import { environmentServerConfigsAtom } from "../../state/server";
31import type { PendingNewTask } from "../../state/use-pending-new-tasks";32import type { PendingNewTask } from "../../state/use-pending-new-tasks";
32import {33import {
...@@ -181,9 +182,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -181,9 +182,7 @@ export function HomeScreen(props: HomeScreenProps) {
181 ReadonlyMap<string, HomeGroupDisplayState>182 ReadonlyMap<string, HomeGroupDisplayState>
182 >(() => new Map());183 >(() => new Map());
183 const preferencesResult = useAtomValue(mobilePreferencesAtom);184 const preferencesResult = useAtomValue(mobilePreferencesAtom);
184 const threadListV2Enabled =185 const threadListV2Enabled = useThreadListV2Enabled();
185 AsyncResult.isSuccess(preferencesResult) &&
186 preferencesResult.value.threadListV2Enabled === true;
187 const savePreferences = useAtomSet(updateMobilePreferencesAtom);186 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
188 const openSwipeableRef = useRef<SwipeableMethods | null>(null);187 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
189 const listRef = useRef<LegendListRef | null>(null);188 const listRef = useRef<LegendListRef | null>(null);
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+2-4
...@@ -46,6 +46,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";...@@ -46,6 +46,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
46import { runtime } from "../../lib/runtime";46import { runtime } from "../../lib/runtime";
47import { useThemeColor } from "../../lib/useThemeColor";47import { useThemeColor } from "../../lib/useThemeColor";
48import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";48import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
49import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
49import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";50import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
50import { SettingsRow } from "./components/SettingsRow";51import { SettingsRow } from "./components/SettingsRow";
51import { SettingsSection } from "./components/SettingsSection";52import { SettingsSection } from "./components/SettingsSection";
...@@ -555,11 +556,8 @@ function GeneralSettingsSection() {...@@ -555,11 +556,8 @@ function GeneralSettingsSection() {
555 * the counterpart of web's Settings → Beta backed by mobile preferences.556 * the counterpart of web's Settings → Beta backed by mobile preferences.
556 */557 */
557function BetaSettingsSection() {558function BetaSettingsSection() {
558 const preferencesResult = useAtomValue(mobilePreferencesAtom);
559 const savePreferences = useAtomSet(updateMobilePreferencesAtom);559 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
560 const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult)560 const threadListV2Enabled = useThreadListV2Enabled();
561 ? preferencesResult.value.threadListV2Enabled === true
562 : false;
563561
564 return (562 return (
565 <View className="gap-3">563 <View className="gap-3">
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+2-6
...@@ -6,7 +6,6 @@ import type {...@@ -6,7 +6,6 @@ import type {
6import { LegendList } from "@legendapp/list/react-native";6import { LegendList } from "@legendapp/list/react-native";
7import type { MenuAction } from "@react-native-menu/menu";7import type { MenuAction } from "@react-native-menu/menu";
8import { useAtomValue } from "@effect/atom-react";8import { useAtomValue } from "@effect/atom-react";
9import { AsyncResult } from "effect/unstable/reactivity";
10import type { EnvironmentId } from "@t3tools/contracts";9import type { EnvironmentId } from "@t3tools/contracts";
11import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";10import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
12import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";11import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
...@@ -25,7 +24,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";...@@ -25,7 +24,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
25import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";24import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
26import { useThemeColor } from "../../lib/useThemeColor";25import { useThemeColor } from "../../lib/useThemeColor";
27import { useProjects, useThreadShells } from "../../state/entities";26import { useProjects, useThreadShells } from "../../state/entities";
28import { mobilePreferencesAtom } from "../../state/preferences";27import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
29import { environmentServerConfigsAtom } from "../../state/server";28import { environmentServerConfigsAtom } from "../../state/server";
30import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks";29import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks";
31import { useWorkspaceState } from "../../state/workspace";30import { useWorkspaceState } from "../../state/workspace";
...@@ -196,10 +195,7 @@ function ThreadNavigationSidebarPane(...@@ -196,10 +195,7 @@ function ThreadNavigationSidebarPane(
196 const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);195 const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);
197 const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =196 const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
198 useThreadListActions();197 useThreadListActions();
199 const preferencesResult = useAtomValue(mobilePreferencesAtom);198 const threadListV2Enabled = useThreadListV2Enabled();
200 const threadListV2Enabled =
201 AsyncResult.isSuccess(preferencesResult) &&
202 preferencesResult.value.threadListV2Enabled === true;
203 const pendingTasks = usePendingNewTasks();199 const pendingTasks = usePendingNewTasks();
204 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();200 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
205 const environments = useMemo(201 const environments = useMemo(
apps/mobile/src/features/threads/threadListV2.test.ts+42
...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
44
5import {5import {
6 buildThreadListV2Items,6 buildThreadListV2Items,
7 resolveThreadListV2Enabled,
7 resolveThreadListV2Status,8 resolveThreadListV2Status,
8 sortThreadsForListV2,9 sortThreadsForListV2,
9} from "./threadListV2";10} from "./threadListV2";
...@@ -38,6 +39,47 @@ function makeThread(...@@ -38,6 +39,47 @@ function makeThread(
3839
39const NOW = "2026-06-02T00:00:00.000Z";40const NOW = "2026-06-02T00:00:00.000Z";
4041
42describe("resolveThreadListV2Enabled", () => {
43 it.each(["development", "preview"])("defaults on for the %s variant", (appVariant) => {
44 expect(
45 resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }),
46 ).toBe(true);
47 });
48
49 it.each(["production", undefined])("defaults off for the %s variant", (appVariant) => {
50 expect(
51 resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }),
52 ).toBe(false);
53 });
54
55 it("prefers an explicit device choice over the variant default", () => {
56 expect(
57 resolveThreadListV2Enabled({
58 preference: false,
59 preferencesLoaded: true,
60 appVariant: "preview",
61 }),
62 ).toBe(false);
63 expect(
64 resolveThreadListV2Enabled({
65 preference: true,
66 preferencesLoaded: true,
67 appVariant: "production",
68 }),
69 ).toBe(true);
70 });
71
72 it("holds v1 while preferences are still loading so the list does not remount", () => {
73 expect(
74 resolveThreadListV2Enabled({
75 preference: undefined,
76 preferencesLoaded: false,
77 appVariant: "development",
78 }),
79 ).toBe(false);
80 });
81});
82
41describe("resolveThreadListV2Status", () => {83describe("resolveThreadListV2Status", () => {
42 it("prioritizes approval over a running session", () => {84 it("prioritizes approval over a running session", () => {
43 const thread = makeThread({85 const thread = makeThread({
apps/mobile/src/features/threads/threadListV2.ts+31
...@@ -18,6 +18,37 @@ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "...@@ -18,6 +18,37 @@ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "
18export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;18export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
19export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;19export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
2020
21/**
22 * Whether Thread List v2 is on by default for an app variant. The `development`
23 * and `preview` variants are mobile's nightly equivalents and opt in;
24 * `production` stays on v1. Counterpart of web's `resolveSidebarV2Default`.
25 */
26export function resolveThreadListV2Default(appVariant: unknown): boolean {
27 return appVariant === "development" || appVariant === "preview";
28}
29
30/**
31 * Resolved Thread List v2 state: the device-local preference if the user has
32 * set one, otherwise the default for this app variant. Preferences persist as
33 * sparse patches, so `undefined` genuinely means "never chosen".
34 *
35 * `preferencesLoaded` guards the startup window: preferences load
36 * asynchronously, and treating "still loading" as "never chosen" would mount
37 * v2 on a development build and then flip to v1 once a stored opt-out arrives,
38 * remounting the whole list. While loading, hold v1 — the state both variants
39 * already start from.
40 */
41export function resolveThreadListV2Enabled(input: {
42 readonly preference: boolean | undefined;
43 readonly preferencesLoaded: boolean;
44 readonly appVariant: unknown;
45}): boolean {
46 if (!input.preferencesLoaded) {
47 return false;
48 }
49 return input.preference ?? resolveThreadListV2Default(input.appVariant);
50}
51
21export function resolveThreadListV2Status(52export function resolveThreadListV2Status(
22 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,53 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
23): ThreadListV2Status {54): ThreadListV2Status {
apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts created+25
...@@ -0,0 +1,25 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/unstable/reactivity";
3import Constants from "expo-constants";
4
5import { mobilePreferencesAtom } from "../../state/preferences";
6import { resolveThreadListV2Enabled } from "./threadListV2";
7
8/**
9 * Resolved Thread List v2 state: the device-local preference if the user has
10 * set one, otherwise the default for this app variant (on for development and
11 * preview, off for production). Every consumer must read through this rather
12 * than the raw preference, which is undefined until explicitly chosen.
13 *
14 * Kept out of `state/preferences.ts` so that module stays importable from node
15 * test environments, which have no `__DEV__` for expo-constants.
16 */
17export function useThreadListV2Enabled(): boolean {
18 const preferencesResult = useAtomValue(mobilePreferencesAtom);
19 const loaded = AsyncResult.isSuccess(preferencesResult);
20 return resolveThreadListV2Enabled({
21 preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined,
22 preferencesLoaded: loaded,
23 appVariant: Constants.expoConfig?.extra?.appVariant,
24 });
25}
apps/mobile/src/persistence/mobile-preferences.ts+2-1
...@@ -26,7 +26,8 @@ export interface Preferences {...@@ -26,7 +26,8 @@ export interface Preferences {
26 /**26 /**
27 * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no27 * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no
28 * client-settings sync, so the flat v2 thread list is opted into per28 * client-settings sync, so the flat v2 thread list is opted into per
29 * device.29 * device. Undefined means the user has never chosen, in which case the app
30 * variant decides — see `resolveThreadListV2Enabled`.
30 */31 */
31 readonly threadListV2Enabled?: boolean;32 readonly threadListV2Enabled?: boolean;
32}33}
apps/web/src/branding.logic.ts+45
...@@ -11,6 +11,51 @@ export function formatAppDisplayName(input: {...@@ -11,6 +11,51 @@ export function formatAppDisplayName(input: {
11 return `${input.baseName} (${input.stageLabel})`;11 return `${input.baseName} (${input.stageLabel})`;
12}12}
1313
14/**
15 * Whether the sidebar v2 beta is on by default for a build stage.
16 *
17 * Nightly and local dev opt in; Alpha and Latest stay on v1. This is resolved
18 * from the client's own stage label rather than the connected server's version:
19 * v2 only exists in the client, so a stable client on a nightly server has
20 * nothing to turn on.
21 */
22export function resolveSidebarV2Default(stageLabel: string): boolean {
23 const stage = stageLabel.trim().toLowerCase();
24 return stage === "nightly" || stage === "dev";
25}
26
27/**
28 * Resolved sidebar v2 state: an explicit choice if the user has made one,
29 * otherwise the default for this build stage.
30 *
31 * A stored `enabled: true` counts as an explicit choice even without the
32 * companion flag. `true` was never the schema default, so it can only have come
33 * from the Settings → Beta toggle — settings written before that flag existed
34 * would otherwise lose the opt-in and drop such users back to v1 on production.
35 * Mirrors how `normalizeDesktopSettingsDocument` treats a legacy stored
36 * `updateChannel: "nightly"` as user-configured.
37 *
38 * `settingsHydrated` guards the startup window: client settings load
39 * asynchronously and the pre-hydration snapshot is just the schema defaults, so
40 * resolving against it would mount one sidebar and swap it out a tick later,
41 * remounting the tree. While hydrating, hold v1 — where both paths already
42 * start.
43 */
44export function resolveSidebarV2Enabled(input: {
45 readonly enabled: boolean;
46 readonly configuredByUser: boolean;
47 readonly settingsHydrated: boolean;
48 readonly stageLabel: string;
49}): boolean {
50 if (!input.settingsHydrated) {
51 return false;
52 }
53
54 return input.configuredByUser || input.enabled
55 ? input.enabled
56 : resolveSidebarV2Default(input.stageLabel);
57}
58
14export function resolveServerBackedAppStageLabel(input: {59export function resolveServerBackedAppStageLabel(input: {
15 readonly primaryServerVersion: string | null | undefined;60 readonly primaryServerVersion: string | null | undefined;
16 readonly fallbackStageLabel: string;61 readonly fallbackStageLabel: string;
apps/web/src/branding.test.ts+73
...@@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";...@@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2import {2import {
3 resolveServerBackedAppDisplayName,3 resolveServerBackedAppDisplayName,
4 resolveServerBackedAppStageLabel,4 resolveServerBackedAppStageLabel,
5 resolveSidebarV2Default,
6 resolveSidebarV2Enabled,
5} from "./branding.logic";7} from "./branding.logic";
68
7const originalWindow = globalThis.window;9const originalWindow = globalThis.window;
...@@ -114,3 +116,74 @@ describe("branding logic", () => {...@@ -114,3 +116,74 @@ describe("branding logic", () => {
114 ).toBe("T3 Code (Alpha)");116 ).toBe("T3 Code (Alpha)");
115 });117 });
116});118});
119
120describe("resolveSidebarV2Default", () => {
121 it.each(["Nightly", "Dev", "nightly", " dev "])("enables the beta for %s builds", (stage) => {
122 expect(resolveSidebarV2Default(stage)).toBe(true);
123 });
124
125 it.each(["Alpha", "Latest", ""])("leaves the beta off for %s builds", (stage) => {
126 expect(resolveSidebarV2Default(stage)).toBe(false);
127 });
128});
129
130describe("resolveSidebarV2Enabled", () => {
131 const hydrated = { settingsHydrated: true } as const;
132
133 it.each(["Alpha", "Latest"])(
134 "keeps a legacy opt-in on %s builds even without the companion flag",
135 (stageLabel) => {
136 // `true` was never the schema default, so it can only be an explicit
137 // opt-in from settings written before `sidebarV2ConfiguredByUser` existed.
138 expect(
139 resolveSidebarV2Enabled({
140 ...hydrated,
141 enabled: true,
142 configuredByUser: false,
143 stageLabel,
144 }),
145 ).toBe(true);
146 },
147 );
148
149 it("applies the stage default when the beta was never enabled or configured", () => {
150 expect(
151 resolveSidebarV2Enabled({
152 ...hydrated,
153 enabled: false,
154 configuredByUser: false,
155 stageLabel: "Nightly",
156 }),
157 ).toBe(true);
158 expect(
159 resolveSidebarV2Enabled({
160 ...hydrated,
161 enabled: false,
162 configuredByUser: false,
163 stageLabel: "Latest",
164 }),
165 ).toBe(false);
166 });
167
168 it("honors an explicit opt-out over the stage default", () => {
169 expect(
170 resolveSidebarV2Enabled({
171 ...hydrated,
172 enabled: false,
173 configuredByUser: true,
174 stageLabel: "Nightly",
175 }),
176 ).toBe(false);
177 });
178
179 it("holds v1 until settings hydrate so the sidebar does not remount", () => {
180 expect(
181 resolveSidebarV2Enabled({
182 enabled: true,
183 configuredByUser: true,
184 settingsHydrated: false,
185 stageLabel: "Nightly",
186 }),
187 ).toBe(false);
188 });
189});
apps/web/src/components/AppSidebarLayout.tsx+2-2
...@@ -14,7 +14,7 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage";...@@ -14,7 +14,7 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage";
14import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";14import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
15import { cn, isMacPlatform } from "../lib/utils";15import { cn, isMacPlatform } from "../lib/utils";
16import { primaryServerKeybindingsAtom } from "../state/server";16import { primaryServerKeybindingsAtom } from "../state/server";
17import { useClientSettings } from "../hooks/useSettings";17import { useSidebarV2Enabled } from "../hooks/useSettings";
18import ThreadSidebar from "./Sidebar";18import ThreadSidebar from "./Sidebar";
19import ThreadSidebarV2 from "./SidebarV2";19import ThreadSidebarV2 from "./SidebarV2";
20import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";20import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
...@@ -115,7 +115,7 @@ function SidebarControl() {...@@ -115,7 +115,7 @@ function SidebarControl() {
115115
116export function AppSidebarLayout({ children }: { children: ReactNode }) {116export function AppSidebarLayout({ children }: { children: ReactNode }) {
117 const navigate = useNavigate();117 const navigate = useNavigate();
118 const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);118 const sidebarV2Enabled = useSidebarV2Enabled();
119 // Settings routes render the settings nav, which lives in the v1 component119 // Settings routes render the settings nav, which lives in the v1 component
120 // and is identical for both sidebars — so v1 stays mounted there.120 // and is identical for both sidebars — so v1 stays mounted there.
121 const pathname = useLocation({ select: (location) => location.pathname });121 const pathname = useLocation({ select: (location) => location.pathname });
apps/web/src/components/settings/BetaSettingsPanel.tsx+14-3
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { useEffect, useState } from "react";1import { useEffect, useState } from "react";
22
3import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings";3import {
4 useClientSettings,
5 useSidebarV2Enabled,
6 useUpdateClientSettings,
7} from "../../hooks/useSettings";
4import { Input } from "../ui/input";8import { Input } from "../ui/input";
5import { Switch } from "../ui/switch";9import { Switch } from "../ui/switch";
6import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";10import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
...@@ -51,7 +55,7 @@ function AutoSettleDaysInput({...@@ -51,7 +55,7 @@ function AutoSettleDaysInput({
51}55}
5256
53export function BetaSettingsPanel() {57export function BetaSettingsPanel() {
54 const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);58 const sidebarV2Enabled = useSidebarV2Enabled();
55 const sidebarAutoSettleAfterDays = useClientSettings(59 const sidebarAutoSettleAfterDays = useClientSettings(
56 (settings) => settings.sidebarAutoSettleAfterDays,60 (settings) => settings.sidebarAutoSettleAfterDays,
57 );61 );
...@@ -66,7 +70,14 @@ export function BetaSettingsPanel() {...@@ -66,7 +70,14 @@ export function BetaSettingsPanel() {
66 control={70 control={
67 <Switch71 <Switch
68 checked={sidebarV2Enabled}72 checked={sidebarV2Enabled}
69 onCheckedChange={(checked) => updateSettings({ sidebarV2Enabled: Boolean(checked) })}73 // Touching the switch pins the choice, so a nightly build that
74 // defaults v2 on does not flip it back after the user opts out.
75 onCheckedChange={(checked) =>
76 updateSettings({
77 sidebarV2Enabled: Boolean(checked),
78 sidebarV2ConfiguredByUser: true,
79 })
80 }
70 aria-label="Enable the sidebar v2 beta"81 aria-label="Enable the sidebar v2 beta"
71 />82 />
72 }83 }
apps/web/src/hooks/useSettings.ts+28
...@@ -24,6 +24,8 @@ import {...@@ -24,6 +24,8 @@ import {
24 type UnifiedSettings,24 type UnifiedSettings,
25} from "@t3tools/contracts/settings";25} from "@t3tools/contracts/settings";
26import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";26import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
27import { APP_STAGE_LABEL } from "~/branding";
28import { resolveSidebarV2Enabled } from "~/branding.logic";
27import { ensureLocalApi } from "~/localApi";29import { ensureLocalApi } from "~/localApi";
28import * as Struct from "effect/Struct";30import * as Struct from "effect/Struct";
29import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";31import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
...@@ -220,6 +222,32 @@ export function useClientSettings<T = ClientSettings>(...@@ -220,6 +222,32 @@ export function useClientSettings<T = ClientSettings>(
220 return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);222 return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);
221}223}
222224
225/**
226 * Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user
227 * has made one, otherwise the default for this build stage (on for nightly and
228 * dev, off for production). Every consumer must read through this rather than
229 * `settings.sidebarV2Enabled`, which is only meaningful alongside
230 * `sidebarV2ConfiguredByUser`.
231 *
232 * Held at v1 until client settings hydrate. The pre-hydration snapshot is just
233 * the schema defaults, so resolving against it would mount one sidebar and then
234 * swap it out once persisted settings land — remounting the whole tree.
235 */
236export function useSidebarV2Enabled(): boolean {
237 const settingsHydrated = useClientSettingsHydrated();
238 const settings = useClientSettingsValue();
239 return useMemo(
240 () =>
241 resolveSidebarV2Enabled({
242 enabled: settings.sidebarV2Enabled,
243 configuredByUser: settings.sidebarV2ConfiguredByUser,
244 settingsHydrated,
245 stageLabel: APP_STAGE_LABEL,
246 }),
247 [settings.sidebarV2Enabled, settings.sidebarV2ConfiguredByUser, settingsHydrated],
248 );
249}
250
223/** Read current settings for one environment, merged with client-local preferences. */251/** Read current settings for one environment, merged with client-local preferences. */
224export function useEnvironmentSettings<T = UnifiedSettings>(252export function useEnvironmentSettings<T = UnifiedSettings>(
225 environmentId: EnvironmentId,253 environmentId: EnvironmentId,
apps/web/src/routes/_chat.tsx+2-2
...@@ -3,7 +3,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -3,7 +3,7 @@ import { useAtomValue } from "@effect/atom-react";
3import { useEffect, useMemo } from "react";3import { useEffect, useMemo } from "react";
44
5import { isCommandPaletteOpen } from "../commandPaletteBus";5import { isCommandPaletteOpen } from "../commandPaletteBus";
6import { useClientSettings } from "../hooks/useSettings";6import { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings";
7import { openCommandPalette } from "../commandPaletteBus";7import { openCommandPalette } from "../commandPaletteBus";
8import { useProjects } from "../state/entities";8import { useProjects } from "../state/entities";
9import { usePrimaryEnvironmentId } from "../state/environments";9import { usePrimaryEnvironmentId } from "../state/environments";
...@@ -28,7 +28,7 @@ function ChatRouteGlobalShortcuts() {...@@ -28,7 +28,7 @@ function ChatRouteGlobalShortcuts() {
28 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =28 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
29 useHandleNewThread();29 useHandleNewThread();
30 const keybindings = useAtomValue(primaryServerKeybindingsAtom);30 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
31 const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);31 const sidebarV2Enabled = useSidebarV2Enabled();
32 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);32 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
33 const projects = useProjects();33 const projects = useProjects();
34 const primaryEnvironmentId = usePrimaryEnvironmentId();34 const primaryEnvironmentId = usePrimaryEnvironmentId();
packages/contracts/src/settings.test.ts+25
...@@ -56,6 +56,31 @@ describe("ClientSettings sidebar v2", () => {...@@ -56,6 +56,31 @@ describe("ClientSettings sidebar v2", () => {
56 expect(settings.sidebarAutoSettleAfterDays).toBe(3);56 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
57 });57 });
5858
59 it("treats settings written before the beta had a per-channel default as unconfigured", () => {
60 // The stored blob always carries `sidebarV2Enabled`, so only the companion
61 // flag can distinguish "user opted out" from "never touched it".
62 expect(decodeClientSettings({ sidebarV2Enabled: false }).sidebarV2ConfiguredByUser).toBe(false);
63 expect(decodeClientSettings({ sidebarV2Enabled: true }).sidebarV2ConfiguredByUser).toBe(false);
64 });
65
66 it("preserves an explicit beta choice", () => {
67 const settings = decodeClientSettings({
68 sidebarV2Enabled: false,
69 sidebarV2ConfiguredByUser: true,
70 });
71 expect(settings.sidebarV2Enabled).toBe(false);
72 expect(settings.sidebarV2ConfiguredByUser).toBe(true);
73 });
74
75 it("carries an explicit beta opt-out through the patch the beta toggle writes", () => {
76 const patch = decodeClientSettingsPatch({
77 sidebarV2Enabled: false,
78 sidebarV2ConfiguredByUser: true,
79 });
80 expect(patch.sidebarV2Enabled).toBe(false);
81 expect(patch.sidebarV2ConfiguredByUser).toBe(true);
82 });
83
59 it("allows auto-settle by inactivity to be disabled", () => {84 it("allows auto-settle by inactivity to be disabled", () => {
60 expect(85 expect(
61 decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays,86 decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays,
packages/contracts/src/settings.ts+7
...@@ -115,6 +115,12 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -115,6 +115,12 @@ export const ClientSettingsSchema = Schema.Struct({
115 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),115 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),
116 ),116 ),
117 sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),117 sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
118 // Whether `sidebarV2Enabled` reflects an explicit choice in Settings → Beta.
119 // Client settings persist as a whole blob, so every user who has ever touched
120 // any setting already has `sidebarV2Enabled: false` stored — without this bit
121 // there is no way to tell that apart from "left alone", and a channel-derived
122 // default could never reach them. Mirrors `updateChannelConfiguredByUser`.
123 sidebarV2ConfiguredByUser: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
118 timestampFormat: TimestampFormat.pipe(124 timestampFormat: TimestampFormat.pipe(
119 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),125 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
120 ),126 ),
...@@ -636,6 +642,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -636,6 +642,7 @@ export const ClientSettingsPatch = Schema.Struct({
636 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),642 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
637 sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),643 sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
638 sidebarV2Enabled: Schema.optionalKey(Schema.Boolean),644 sidebarV2Enabled: Schema.optionalKey(Schema.Boolean),
645 sidebarV2ConfiguredByUser: Schema.optionalKey(Schema.Boolean),
639 timestampFormat: Schema.optionalKey(TimestampFormat),646 timestampFormat: Schema.optionalKey(TimestampFormat),
640 wordWrap: Schema.optionalKey(Schema.Boolean),647 wordWrap: Schema.optionalKey(Schema.Boolean),
641});648});