| author | |
| committer | |
| log | c13a021e432e94728fe88ff983fb9601c6d6b943 |
| tree | b86efc9ca8421c1c662fe19a630a3604deb9661b |
| parent | 476d69cd1d5d89b4aad77df8fc01c71e34af930c |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; |
| 2 | import type { MenuAction } from "@react-native-menu/menu"; | 2 | import type { MenuAction } from "@react-native-menu/menu"; |
| 3 | import { useAtomValue } from "@effect/atom-react"; | ||
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 5 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 3 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 6 | import { useCallback, useMemo, useRef } from "react"; | 4 | import { useCallback, useMemo, useRef } from "react"; |
| 7 | import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; | 5 | import { 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"; |
| 12 | import { SymbolView } from "../../components/AppSymbol"; | 10 | import { SymbolView } from "../../components/AppSymbol"; |
| 13 | import { T3Wordmark } from "../../components/T3Wordmark"; | 11 | import { T3Wordmark } from "../../components/T3Wordmark"; |
| 14 | import { useThemeColor } from "../../lib/useThemeColor"; | 12 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 15 | import { mobilePreferencesAtom } from "../../state/preferences"; | 13 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 16 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | 14 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 17 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | 15 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 18 | import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; | 16 | import { 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 | } |
| 61 | 59 | ||
| 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. */ | ||
| 65 | function useThreadListV2FilterGate() { | ||
| 66 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | ||
| 67 | return ( | ||
| 68 | AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true | ||
| 69 | ); | ||
| 70 | } | ||
| 71 | |||
| 72 | function AndroidHomeHeader(props: HomeHeaderProps) { | 60 | function 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 = threadListV2Enabled | 68 | const hasCustomListOptions = threadListV2Enabled |
| 78 | ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null | 69 | ? 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) { |
| 291 | function IosHomeHeader(props: HomeHeaderProps) { | 282 | function 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 = threadListV2Enabled | 289 | const hasCustomListOptions = threadListV2Enabled |
| 296 | ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null | 290 | ? 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"; |
| 27 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 27 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 28 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 28 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 29 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 29 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 30 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | ||
| 30 | import { environmentServerConfigsAtom } from "../../state/server"; | 31 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 31 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 32 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 32 | import { | 33 | import { |
| ... | @@ -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"; |
| 46 | import { runtime } from "../../lib/runtime"; | 46 | import { runtime } from "../../lib/runtime"; |
| 47 | import { useThemeColor } from "../../lib/useThemeColor"; | 47 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 48 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 48 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 49 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | ||
| 49 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 50 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 50 | import { SettingsRow } from "./components/SettingsRow"; | 51 | import { SettingsRow } from "./components/SettingsRow"; |
| 51 | import { SettingsSection } from "./components/SettingsSection"; | 52 | import { 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 | */ |
| 557 | function BetaSettingsSection() { | 558 | function 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; | ||
| 563 | 561 | ||
| 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 { |
| 6 | import { LegendList } from "@legendapp/list/react-native"; | 6 | import { LegendList } from "@legendapp/list/react-native"; |
| 7 | import type { MenuAction } from "@react-native-menu/menu"; | 7 | import type { MenuAction } from "@react-native-menu/menu"; |
| 8 | import { useAtomValue } from "@effect/atom-react"; | 8 | import { useAtomValue } from "@effect/atom-react"; |
| 9 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 10 | import type { EnvironmentId } from "@t3tools/contracts"; | 9 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 11 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 10 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 12 | import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; | 11 | import 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"; |
| 25 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; | 24 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 26 | import { useThemeColor } from "../../lib/useThemeColor"; | 25 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 27 | import { useProjects, useThreadShells } from "../../state/entities"; | 26 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 28 | import { mobilePreferencesAtom } from "../../state/preferences"; | 27 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; |
| 29 | import { environmentServerConfigsAtom } from "../../state/server"; | 28 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 30 | import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; | 29 | import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 31 | import { useWorkspaceState } from "../../state/workspace"; | 30 | import { 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"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 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( |
| 38 | 39 | ||
| 39 | const NOW = "2026-06-02T00:00:00.000Z"; | 40 | const NOW = "2026-06-02T00:00:00.000Z"; |
| 40 | 41 | ||
| 42 | describe("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 | |||
| 41 | describe("resolveThreadListV2Status", () => { | 83 | describe("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" | " |
| 18 | export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; | 18 | export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; |
| 19 | export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; | 19 | export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; |
| 20 | 20 | ||
| 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 | */ | ||
| 26 | export 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 | */ | ||
| 41 | export 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 | |||
| 21 | export function resolveThreadListV2Status( | 52 | export 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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 3 | import Constants from "expo-constants"; | ||
| 4 | |||
| 5 | import { mobilePreferencesAtom } from "../../state/preferences"; | ||
| 6 | import { 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 | */ | ||
| 17 | export 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 no | 27 | * 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 per | 28 | * 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 | } |
| 13 | 13 | ||
| 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 | */ | ||
| 22 | export 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 | */ | ||
| 44 | export 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 | |||
| 14 | export function resolveServerBackedAppStageLabel(input: { | 59 | export 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"; |
| 2 | import { | 2 | import { |
| 3 | resolveServerBackedAppDisplayName, | 3 | resolveServerBackedAppDisplayName, |
| 4 | resolveServerBackedAppStageLabel, | 4 | resolveServerBackedAppStageLabel, |
| 5 | resolveSidebarV2Default, | ||
| 6 | resolveSidebarV2Enabled, | ||
| 5 | } from "./branding.logic"; | 7 | } from "./branding.logic"; |
| 6 | 8 | ||
| 7 | const originalWindow = globalThis.window; | 9 | const 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 | |||
| 120 | describe("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 | |||
| 130 | describe("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"; |
| 14 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 14 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 15 | import { cn, isMacPlatform } from "../lib/utils"; | 15 | import { cn, isMacPlatform } from "../lib/utils"; |
| 16 | import { primaryServerKeybindingsAtom } from "../state/server"; | 16 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 17 | import { useClientSettings } from "../hooks/useSettings"; | 17 | import { useSidebarV2Enabled } from "../hooks/useSettings"; |
| 18 | import ThreadSidebar from "./Sidebar"; | 18 | import ThreadSidebar from "./Sidebar"; |
| 19 | import ThreadSidebarV2 from "./SidebarV2"; | 19 | import ThreadSidebarV2 from "./SidebarV2"; |
| 20 | import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; | 20 | import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; |
| ... | @@ -115,7 +115,7 @@ function SidebarControl() { | ... | @@ -115,7 +115,7 @@ function SidebarControl() { |
| 115 | 115 | ||
| 116 | export function AppSidebarLayout({ children }: { children: ReactNode }) { | 116 | export 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 component | 119 | // 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 @@ |
| 1 | import { useEffect, useState } from "react"; | 1 | import { useEffect, useState } from "react"; |
| 2 | 2 | ||
| 3 | import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; | 3 | import { |
| 4 | useClientSettings, | ||
| 5 | useSidebarV2Enabled, | ||
| 6 | useUpdateClientSettings, | ||
| 7 | } from "../../hooks/useSettings"; | ||
| 4 | import { Input } from "../ui/input"; | 8 | import { Input } from "../ui/input"; |
| 5 | import { Switch } from "../ui/switch"; | 9 | import { Switch } from "../ui/switch"; |
| 6 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; | 10 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; |
| ... | @@ -51,7 +55,7 @@ function AutoSettleDaysInput({ | ... | @@ -51,7 +55,7 @@ function AutoSettleDaysInput({ |
| 51 | } | 55 | } |
| 52 | 56 | ||
| 53 | export function BetaSettingsPanel() { | 57 | export 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 | <Switch | 71 | <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"; |
| 26 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | 26 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 27 | import { APP_STAGE_LABEL } from "~/branding"; | ||
| 28 | import { resolveSidebarV2Enabled } from "~/branding.logic"; | ||
| 27 | import { ensureLocalApi } from "~/localApi"; | 29 | import { ensureLocalApi } from "~/localApi"; |
| 28 | import * as Struct from "effect/Struct"; | 30 | import * as Struct from "effect/Struct"; |
| 29 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; | 31 | import { 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 | } |
| 222 | 224 | ||
| 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 | */ | ||
| 236 | export 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. */ |
| 224 | export function useEnvironmentSettings<T = UnifiedSettings>( | 252 | export 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"; |
| 3 | import { useEffect, useMemo } from "react"; | 3 | import { useEffect, useMemo } from "react"; |
| 4 | 4 | ||
| 5 | import { isCommandPaletteOpen } from "../commandPaletteBus"; | 5 | import { isCommandPaletteOpen } from "../commandPaletteBus"; |
| 6 | import { useClientSettings } from "../hooks/useSettings"; | 6 | import { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings"; |
| 7 | import { openCommandPalette } from "../commandPaletteBus"; | 7 | import { openCommandPalette } from "../commandPaletteBus"; |
| 8 | import { useProjects } from "../state/entities"; | 8 | import { useProjects } from "../state/entities"; |
| 9 | import { usePrimaryEnvironmentId } from "../state/environments"; | 9 | import { 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 | }); |
| 58 | 58 | ||
| 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 | }); |