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 = {
3030 sidebarThreadSortOrder: "created_at",
3131 sidebarThreadPreviewCount: 6,
3232 sidebarV2Enabled: false,
33 sidebarV2ConfiguredByUser: false,
3334 timestampFormat: "24-hour",
3435 wordWrap: true,
3536};
apps/mobile/src/features/home/HomeHeader.tsx+9-15
......@@ -1,7 +1,5 @@
11import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";
22import type { MenuAction } from "@react-native-menu/menu";
3import { useAtomValue } from "@effect/atom-react";
4import { AsyncResult } from "effect/unstable/reactivity";
53import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
64import { useCallback, useMemo, useRef } from "react";
75import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native";
......@@ -12,7 +10,7 @@ import { ControlPillMenu } from "../../components/ControlPill";
1210import { SymbolView } from "../../components/AppSymbol";
1311import { T3Wordmark } from "../../components/T3Wordmark";
1412import { useThemeColor } from "../../lib/useThemeColor";
15import { mobilePreferencesAtom } from "../../state/preferences";
13import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
1614import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
1715import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
1816import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
......@@ -59,21 +57,14 @@ function checkedMenuState(checked: boolean) {
5957 return checked ? ("on" as const) : undefined;
6058}
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
7260function AndroidHomeHeader(props: HomeHeaderProps) {
7361 const insets = useSafeAreaInsets();
7462 const iconColor = useThemeColor("--color-icon");
7563 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();
7768 const hasCustomListOptions = threadListV2Enabled
7869 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
7970 : hasCustomHomeListOptions(props);
......@@ -291,7 +282,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
291282function IosHomeHeader(props: HomeHeaderProps) {
292283 const searchBarRef = useRef<SearchBarCommands>(null);
293284 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();
295289 const hasCustomListOptions = threadListV2Enabled
296290 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
297291 : hasCustomHomeListOptions(props);
apps/mobile/src/features/home/HomeScreen.tsx+2-3
......@@ -27,6 +27,7 @@ import type { SavedRemoteConnection } from "../../lib/connection";
2727import { scopedProjectKey } from "../../lib/scopedEntities";
2828import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
2929import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
30import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
3031import { environmentServerConfigsAtom } from "../../state/server";
3132import type { PendingNewTask } from "../../state/use-pending-new-tasks";
3233import {
......@@ -181,9 +182,7 @@ export function HomeScreen(props: HomeScreenProps) {
181182 ReadonlyMap<string, HomeGroupDisplayState>
182183 >(() => new Map());
183184 const preferencesResult = useAtomValue(mobilePreferencesAtom);
184 const threadListV2Enabled =
185 AsyncResult.isSuccess(preferencesResult) &&
186 preferencesResult.value.threadListV2Enabled === true;
185 const threadListV2Enabled = useThreadListV2Enabled();
187186 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
188187 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
189188 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";
4646import { runtime } from "../../lib/runtime";
4747import { useThemeColor } from "../../lib/useThemeColor";
4848import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
49import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
4950import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
5051import { SettingsRow } from "./components/SettingsRow";
5152import { SettingsSection } from "./components/SettingsSection";
......@@ -555,11 +556,8 @@ function GeneralSettingsSection() {
555556 * the counterpart of web's Settings → Beta backed by mobile preferences.
556557 */
557558function BetaSettingsSection() {
558 const preferencesResult = useAtomValue(mobilePreferencesAtom);
559559 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
560 const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult)
561 ? preferencesResult.value.threadListV2Enabled === true
562 : false;
560 const threadListV2Enabled = useThreadListV2Enabled();
563561
564562 return (
565563 <View className="gap-3">
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+2-6
......@@ -6,7 +6,6 @@ import type {
66import { LegendList } from "@legendapp/list/react-native";
77import type { MenuAction } from "@react-native-menu/menu";
88import { useAtomValue } from "@effect/atom-react";
9import { AsyncResult } from "effect/unstable/reactivity";
109import type { EnvironmentId } from "@t3tools/contracts";
1110import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
1211import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
......@@ -25,7 +24,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
2524import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
2625import { useThemeColor } from "../../lib/useThemeColor";
2726import { useProjects, useThreadShells } from "../../state/entities";
28import { mobilePreferencesAtom } from "../../state/preferences";
27import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
2928import { environmentServerConfigsAtom } from "../../state/server";
3029import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks";
3130import { useWorkspaceState } from "../../state/workspace";
......@@ -196,10 +195,7 @@ function ThreadNavigationSidebarPane(
196195 const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);
197196 const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
198197 useThreadListActions();
199 const preferencesResult = useAtomValue(mobilePreferencesAtom);
200 const threadListV2Enabled =
201 AsyncResult.isSuccess(preferencesResult) &&
202 preferencesResult.value.threadListV2Enabled === true;
198 const threadListV2Enabled = useThreadListV2Enabled();
203199 const pendingTasks = usePendingNewTasks();
204200 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
205201 const environments = useMemo(
apps/mobile/src/features/threads/threadListV2.test.ts+42
......@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
44
55import {
66 buildThreadListV2Items,
7 resolveThreadListV2Enabled,
78 resolveThreadListV2Status,
89 sortThreadsForListV2,
910} from "./threadListV2";
......@@ -38,6 +39,47 @@ function makeThread(
3839
3940const 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
4183describe("resolveThreadListV2Status", () => {
4284 it("prioritizes approval over a running session", () => {
4385 const thread = makeThread({
apps/mobile/src/features/threads/threadListV2.ts+31
......@@ -18,6 +18,37 @@ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "
1818export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
1919export 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
2152export function resolveThreadListV2Status(
2253 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
2354): 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 {
2626 /**
2727 * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no
2828 * 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`.
3031 */
3132 readonly threadListV2Enabled?: boolean;
3233}
apps/web/src/branding.logic.ts+45
......@@ -11,6 +11,51 @@ export function formatAppDisplayName(input: {
1111 return `${input.baseName} (${input.stageLabel})`;
1212}
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
1459export function resolveServerBackedAppStageLabel(input: {
1560 readonly primaryServerVersion: string | null | undefined;
1661 readonly fallbackStageLabel: string;
apps/web/src/branding.test.ts+73
......@@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";
22import {
33 resolveServerBackedAppDisplayName,
44 resolveServerBackedAppStageLabel,
5 resolveSidebarV2Default,
6 resolveSidebarV2Enabled,
57} from "./branding.logic";
68
79const originalWindow = globalThis.window;
......@@ -114,3 +116,74 @@ describe("branding logic", () => {
114116 ).toBe("T3 Code (Alpha)");
115117 });
116118});
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";
1414import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
1515import { cn, isMacPlatform } from "../lib/utils";
1616import { primaryServerKeybindingsAtom } from "../state/server";
17import { useClientSettings } from "../hooks/useSettings";
17import { useSidebarV2Enabled } from "../hooks/useSettings";
1818import ThreadSidebar from "./Sidebar";
1919import ThreadSidebarV2 from "./SidebarV2";
2020import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
......@@ -115,7 +115,7 @@ function SidebarControl() {
115115
116116export function AppSidebarLayout({ children }: { children: ReactNode }) {
117117 const navigate = useNavigate();
118 const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
118 const sidebarV2Enabled = useSidebarV2Enabled();
119119 // Settings routes render the settings nav, which lives in the v1 component
120120 // and is identical for both sidebars — so v1 stays mounted there.
121121 const pathname = useLocation({ select: (location) => location.pathname });
apps/web/src/components/settings/BetaSettingsPanel.tsx+14-3
......@@ -1,6 +1,10 @@
11import { useEffect, useState } from "react";
22
3import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings";
3import {
4 useClientSettings,
5 useSidebarV2Enabled,
6 useUpdateClientSettings,
7} from "../../hooks/useSettings";
48import { Input } from "../ui/input";
59import { Switch } from "../ui/switch";
610import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
......@@ -51,7 +55,7 @@ function AutoSettleDaysInput({
5155}
5256
5357export function BetaSettingsPanel() {
54 const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
58 const sidebarV2Enabled = useSidebarV2Enabled();
5559 const sidebarAutoSettleAfterDays = useClientSettings(
5660 (settings) => settings.sidebarAutoSettleAfterDays,
5761 );
......@@ -66,7 +70,14 @@ export function BetaSettingsPanel() {
6670 control={
6771 <Switch
6872 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 }
7081 aria-label="Enable the sidebar v2 beta"
7182 />
7283 }
apps/web/src/hooks/useSettings.ts+28
......@@ -24,6 +24,8 @@ import {
2424 type UnifiedSettings,
2525} from "@t3tools/contracts/settings";
2626import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
27import { APP_STAGE_LABEL } from "~/branding";
28import { resolveSidebarV2Enabled } from "~/branding.logic";
2729import { ensureLocalApi } from "~/localApi";
2830import * as Struct from "effect/Struct";
2931import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
......@@ -220,6 +222,32 @@ export function useClientSettings<T = ClientSettings>(
220222 return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);
221223}
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
223251/** Read current settings for one environment, merged with client-local preferences. */
224252export function useEnvironmentSettings<T = UnifiedSettings>(
225253 environmentId: EnvironmentId,
apps/web/src/routes/_chat.tsx+2-2
......@@ -3,7 +3,7 @@ import { useAtomValue } from "@effect/atom-react";
33import { useEffect, useMemo } from "react";
44
55import { isCommandPaletteOpen } from "../commandPaletteBus";
6import { useClientSettings } from "../hooks/useSettings";
6import { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings";
77import { openCommandPalette } from "../commandPaletteBus";
88import { useProjects } from "../state/entities";
99import { usePrimaryEnvironmentId } from "../state/environments";
......@@ -28,7 +28,7 @@ function ChatRouteGlobalShortcuts() {
2828 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
2929 useHandleNewThread();
3030 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
31 const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
31 const sidebarV2Enabled = useSidebarV2Enabled();
3232 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
3333 const projects = useProjects();
3434 const primaryEnvironmentId = usePrimaryEnvironmentId();
packages/contracts/src/settings.test.ts+25
......@@ -56,6 +56,31 @@ describe("ClientSettings sidebar v2", () => {
5656 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
5757 });
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
5984 it("allows auto-settle by inactivity to be disabled", () => {
6085 expect(
6186 decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays,
packages/contracts/src/settings.ts+7
......@@ -115,6 +115,12 @@ export const ClientSettingsSchema = Schema.Struct({
115115 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),
116116 ),
117117 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))),
118124 timestampFormat: TimestampFormat.pipe(
119125 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
120126 ),
......@@ -636,6 +642,7 @@ export const ClientSettingsPatch = Schema.Struct({
636642 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
637643 sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
638644 sidebarV2Enabled: Schema.optionalKey(Schema.Boolean),
645 sidebarV2ConfiguredByUser: Schema.optionalKey(Schema.Boolean),
639646 timestampFormat: Schema.optionalKey(TimestampFormat),
640647 wordWrap: Schema.optionalKey(Schema.Boolean),
641648});