diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9a51725478e1..68c25e6d2c03 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -2,6 +2,7 @@ import type { ExpoConfig } from "expo/config"; import { BRAND_ASSET_PATHS } from "../../scripts/lib/brand-assets.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import { createSchemeNeutralSplashConfig } from "./src/lib/splashConfig.ts"; type AppVariant = "development" | "preview" | "production"; @@ -299,19 +300,9 @@ const config: ExpoConfig = { }, ], ["expo-image-picker", { photosPermission: false, microphonePermission: false }], - [ - "expo-splash-screen", - { - image: variant.assets.splashIcon, - resizeMode: "contain", - backgroundColor: "#ffffff", - imageWidth: 220, - dark: { - image: variant.assets.splashIcon, - backgroundColor: "#0a0a0a", - }, - }, - ], + // Expo's storyboard mod runs first, then this inlines its generated color. + "./plugins/withIosSplashScreenInlineBackground.cjs", + ["expo-splash-screen", createSchemeNeutralSplashConfig(variant.assets.splashIcon)], [ "expo-build-properties", { diff --git a/apps/mobile/global.css b/apps/mobile/global.css index bfb2448be2a1..b9a923ab3fdb 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -80,8 +80,10 @@ --color-md-blockquote-bg: rgba(0, 0, 0, 0.02); --color-md-code-bg: rgba(0, 0, 0, 0.04); --color-md-code-text: #262626; + --color-md-inline-code-text: #5f6368; --color-md-user-code-bg: rgba(255, 255, 255, 0.22); --color-md-user-code-text: #ffffff; + --color-md-user-inline-code-text: rgba(255, 255, 255, 0.82); --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); --color-md-user-fence-text: #ffffff; --color-md-hr: rgba(0, 0, 0, 0.08); @@ -174,8 +176,10 @@ --color-md-blockquote-bg: rgba(255, 255, 255, 0.03); --color-md-code-bg: rgba(255, 255, 255, 0.06); --color-md-code-text: #e5e5e5; + --color-md-inline-code-text: #b8bcc2; --color-md-user-code-bg: rgba(255, 255, 255, 0.18); --color-md-user-code-text: #ffffff; + --color-md-user-inline-code-text: rgba(255, 255, 255, 0.82); --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); --color-md-user-fence-text: #ffffff; --color-md-hr: rgba(255, 255, 255, 0.08); diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 97e9f696db90..77cafdc53662 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -1404,16 +1404,27 @@ private fun parseStringSet(value: String): Set = try { emptySet() } -private fun parseColor(value: String, fallback: Int): Int = try { - Color.parseColor(value) -} catch (_: Exception) { - fallback +private fun parseColor(value: String, fallback: Int): Int = parseColorOrNull(value) ?: fallback + +private fun parseColorOrNull(value: String): Int? { + decodeCssHexColor(value)?.let { return it } + return try { + Color.parseColor(value) + } catch (_: Exception) { + null + } } -private fun parseColorOrNull(value: String): Int? = try { - Color.parseColor(value) -} catch (_: Exception) { - null +private fun decodeCssHexColor(value: String): Int? { + val hex = value.trim().takeIf { it.startsWith("#") }?.drop(1) ?: return null + if (hex.length != 6 && hex.length != 8) return null + if (!hex.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) return null + val raw = hex.toLongOrNull(16) ?: return null + if (hex.length == 6) return (0xff000000L or raw).toInt() + + val alpha = raw and 0xff + val rgb = raw shr 8 + return ((alpha shl 24) or rgb).toInt() } private fun JSONObject.optNullableString(key: String): String? = diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 88de793a8f7d..e89484787e55 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -428,10 +428,24 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } } - private fun parseColor(value: String, fallback: Int): Int = - try { + private fun parseColor(value: String, fallback: Int): Int { + decodeCssHexColor(value)?.let { return it } + return try { Color.parseColor(value) } catch (_: IllegalArgumentException) { fallback } + } + + private fun decodeCssHexColor(value: String): Int? { + val hex = value.trim().takeIf { it.startsWith("#") }?.drop(1) ?: return null + if (hex.length != 6 && hex.length != 8) return null + if (!hex.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) return null + val raw = hex.toLongOrNull(16) ?: return null + if (hex.length == 6) return (0xff000000L or raw).toInt() + + val alpha = raw and 0xff + val rgb = raw shr 8 + return ((alpha shl 24) or rgb).toInt() + } } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index cee4d0427c60..db27c9197aee 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -181,15 +181,27 @@ private enum TerminalAppearanceScheme: String { } private extension UIColor { - convenience init(hexString: String) { - let sanitized = hexString.replacingOccurrences(of: "#", with: "") - let value = Int(sanitized, radix: 16) ?? 0 - self.init( - red: CGFloat((value >> 16) & 0xFF) / 255, - green: CGFloat((value >> 8) & 0xFF) / 255, - blue: CGFloat(value & 0xFF) / 255, - alpha: 1 - ) + convenience init?(cssHex: String) { + let value = cssHex.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.hasPrefix("#") else { return nil } + let hex = String(value.dropFirst()) + guard hex.count == 6 || hex.count == 8, let raw = UInt64(hex, radix: 16) else { return nil } + + if hex.count == 8 { + self.init( + red: CGFloat((raw >> 24) & 0xFF) / 255, + green: CGFloat((raw >> 16) & 0xFF) / 255, + blue: CGFloat((raw >> 8) & 0xFF) / 255, + alpha: CGFloat(raw & 0xFF) / 255 + ) + } else { + self.init( + red: CGFloat((raw >> 16) & 0xFF) / 255, + green: CGFloat((raw >> 8) & 0xFF) / 255, + blue: CGFloat(raw & 0xFF) / 255, + alpha: 1 + ) + } } } @@ -211,7 +223,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { private var isCreatingSurface = false private var surfaceCreationFailed = false private var appearance = TerminalAppearanceScheme.dark - private var backgroundColorValue = UIColor(hexString: "#24292e") + private var backgroundColorValue = UIColor(cssHex: "#24292e") ?? .black let onInput = EventDispatcher() let onResize = EventDispatcher() @@ -276,7 +288,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { var backgroundColorHex: String = "#24292e" { didSet { - backgroundColorValue = UIColor(hexString: backgroundColorHex) + guard let color = UIColor(cssHex: backgroundColorHex) else { + backgroundColorHex = oldValue + return + } + backgroundColorValue = color applyTheme() } } diff --git a/apps/mobile/plugins/withIosSplashScreenInlineBackground.cjs b/apps/mobile/plugins/withIosSplashScreenInlineBackground.cjs new file mode 100644 index 000000000000..213e779e5371 --- /dev/null +++ b/apps/mobile/plugins/withIosSplashScreenInlineBackground.cjs @@ -0,0 +1,35 @@ +const { withMod } = require("expo/config-plugins"); + +function inlineSplashScreenBackground(modResults) { + // Expo's splashScreenStoryboard modResults is the parsed XML root, whose + // top-level `document` property contains the storyboard document. + const document = modResults.document; + const mainView = document.scenes?.[0]?.scene?.[0]?.objects?.[0]?.viewController?.[0]?.view?.[0]; + const namedBackground = document.resources?.[0]?.namedColor?.find( + (entry) => entry.$?.name === "SplashScreenBackground", + ); + const backgroundReference = mainView?.color?.find((entry) => entry.$?.key === "backgroundColor"); + const concreteColor = namedBackground?.color?.[0]?.$; + + if (backgroundReference?.$?.name !== "SplashScreenBackground" || !concreteColor) { + throw new Error( + "Could not find Expo's generated splash background in SplashScreen.storyboard.", + ); + } + + mainView.color = [{ $: { key: "backgroundColor", ...concreteColor } }]; + return modResults; +} + +module.exports = function withIosSplashScreenInlineBackground(config) { + return withMod(config, { + platform: "ios", + mod: "splashScreenStoryboard", + action: async (nextConfig) => { + nextConfig.modResults = inlineSplashScreenBackground(nextConfig.modResults); + return nextConfig; + }, + }); +}; + +module.exports.inlineSplashScreenBackground = inlineSplashScreenBackground; diff --git a/apps/mobile/plugins/withIosSplashScreenInlineBackground.test.ts b/apps/mobile/plugins/withIosSplashScreenInlineBackground.test.ts new file mode 100644 index 000000000000..1d5e645d132e --- /dev/null +++ b/apps/mobile/plugins/withIosSplashScreenInlineBackground.test.ts @@ -0,0 +1,107 @@ +import * as NodeModule from "node:module"; + +import { describe, expect, it } from "vite-plus/test"; + +type StoryboardColor = { + $: Record; +}; + +type StoryboardModResults = { + document: { + scenes: Array<{ + scene: Array<{ + objects: Array<{ + viewController: Array<{ + view: Array<{ + color: Array; + }>; + }>; + }>; + }>; + }>; + resources: Array<{ + namedColor: Array<{ + $: { name: string }; + color: Array; + }>; + }>; + }; +}; + +const require = NodeModule.createRequire(import.meta.url); +const { + inlineSplashScreenBackground, +}: { + inlineSplashScreenBackground: (modResults: StoryboardModResults) => StoryboardModResults; +} = require("./withIosSplashScreenInlineBackground.cjs"); + +describe("iOS splash screen inline background", () => { + it("transforms Expo's real storyboard modResults shape", () => { + const modResults: StoryboardModResults = { + document: { + scenes: [ + { + scene: [ + { + objects: [ + { + viewController: [ + { + view: [ + { + color: [ + { + $: { + key: "backgroundColor", + name: "SplashScreenBackground", + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + resources: [ + { + namedColor: [ + { + $: { name: "SplashScreenBackground" }, + color: [ + { + $: { + alpha: "1.000", + blue: "0.450980392156863", + green: "0.450980392156863", + red: "0.450980392156863", + }, + }, + ], + }, + ], + }, + ], + }, + }; + + expect(inlineSplashScreenBackground(modResults)).toBe(modResults); + expect( + modResults.document.scenes[0]?.scene[0]?.objects[0]?.viewController[0]?.view[0]?.color, + ).toEqual([ + { + $: { + key: "backgroundColor", + alpha: "1.000", + blue: "0.450980392156863", + green: "0.450980392156863", + red: "0.450980392156863", + }, + }, + ]); + }); +}); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 06bd4bc57733..77801a85aed1 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,8 +1,8 @@ import { BlurTargetView } from "expo-blur"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; -import { useEffect } from "react"; -import { StatusBar, useColorScheme } from "react-native"; +import { useEffect, useMemo } from "react"; +import { StatusBar } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; @@ -21,7 +21,9 @@ import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { OverlayPortalHost } from "./components/OverlayPortal"; import { appBlurTargetRef } from "./lib/appBlurTarget"; +import { DEFAULT_MOBILE_THEME_ID, resolveMobileFormSheetBackground } from "./lib/mobileTheme"; import { useThemeColor } from "./lib/useThemeColor"; +import { NATIVE_SHEET_SURFACE_COLOR } from "./native/sheet-surface"; import "../global.css"; @@ -57,44 +59,85 @@ function SplashScreenCoordinator() { return null; } -export default function App() { - const colorScheme = useColorScheme(); +function ThemedApp() { + const { appearanceMode, effectiveColorScheme, importedThemes, themeId } = + useAppearancePreferences(); const statusBarBg = useThemeColor("--color-status-bar"); + const navigationBackground = String(useThemeColor("--color-screen")); + const navigationCard = String(useThemeColor("--color-sheet")); + const navigationText = String(useThemeColor("--color-foreground")); + const navigationBorder = String(useThemeColor("--color-border")); + const navigationPrimary = String(useThemeColor("--color-primary")); + const navigationTheme = useMemo(() => { + const base = effectiveColorScheme === "dark" ? DarkTheme : DefaultTheme; + return { + ...base, + formSheetBackground: + appearanceMode === "system" && + themeId === DEFAULT_MOBILE_THEME_ID && + NATIVE_SHEET_SURFACE_COLOR !== undefined + ? NATIVE_SHEET_SURFACE_COLOR + : resolveMobileFormSheetBackground(themeId, effectiveColorScheme, importedThemes), + colors: { + ...base.colors, + background: navigationBackground, + card: navigationCard, + text: navigationText, + border: navigationBorder, + primary: navigationPrimary, + }, + }; + }, [ + appearanceMode, + effectiveColorScheme, + importedThemes, + navigationBackground, + navigationBorder, + navigationCard, + navigationPrimary, + navigationText, + themeId, + ]); + + return ( + <> + + + + + + {/* The navigation theme drives the NATIVE header appearance: native-stack + forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without + this, React Navigation defaults to its light theme and every native + header (glass buttons, title, materials) is forced light even when + the system is in dark mode. */} + {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} + + + + + + + {/* Anchored-menu overlays render here — in-window, so the + keyboard stays up while a dropdown is open. */} + + + + + + ); +} +export default function App() { return ( - - - - - - {/* The navigation theme drives the NATIVE header appearance: native-stack - forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without - this, React Navigation defaults to its light theme and every native - header (glass buttons, title, materials) is forced light even when - the system is in dark mode. */} - {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} - - - - - - - {/* Anchored-menu overlays render here — in-window, so the - keyboard stays up while a dropdown is open. */} - - - - + diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 20bba1f6062d..bf98abc508ff 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -11,7 +11,7 @@ import { type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; import { useEffect, useRef } from "react"; -import { Platform, Pressable, ScrollView, StyleSheet } from "react-native"; +import { Platform, Pressable, ScrollView, StyleSheet, type ColorValue } from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; @@ -71,12 +71,9 @@ import { } from "./features/sharing/incoming-share-presentation"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; -import { - FORM_SHEET_PRESENTATION_OPTIONS, - NATIVE_SHEET_SURFACE_COLOR, - NATIVE_SHEET_SURFACE_CONTENT_STYLE, -} from "./native/sheet-surface"; +import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; +import { useSyncedClientPreferences } from "./state/synced-client-preferences"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -84,6 +81,23 @@ type AppScreenOptions = NativeStackNavigationOptions & { readonly unstable_navigationItemStyle?: "editor"; }; +type AppNavigationTheme = ReactNavigation.Theme & { + readonly formSheetBackground: ColorValue; +}; + +function themedFormSheetContentStyle(theme: ReactNavigation.Theme) { + // SAFETY: App's NavigationContainer always provides the AppNavigationTheme built in App.tsx. + return { + backgroundColor: (theme as AppNavigationTheme).formSheetBackground, + }; +} + +function themedOpaqueHeaderStyle(theme: ReactNavigation.Theme) { + // SAFETY: native-stack types this as `string`, but the native side accepts + // every ColorValue, including DynamicColorIOS. + return themedFormSheetContentStyle(theme) as { readonly backgroundColor: string }; +} + // Shared header presets. Screens only override genuinely dynamic values (titles, // subtitles, toolbar items, search callbacks) via NativeStackScreenOptions. // @@ -96,11 +110,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { headerLargeTitle: false, headerShadowVisible: false, headerShown: true, - headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED - ? { backgroundColor: "transparent" } - : NATIVE_SHEET_SURFACE_COLOR !== undefined - ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } - : undefined, + ...(NATIVE_LIQUID_GLASS_SUPPORTED ? { headerStyle: { backgroundColor: "transparent" } } : null), headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined, @@ -115,12 +125,6 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = { headerLargeTitle: false, headerShadowVisible: false, headerShown: true, - headerStyle: - NATIVE_SHEET_SURFACE_COLOR !== undefined - ? // native-stack types this as `string`, but the native side accepts any - // ColorValue including DynamicColorIOS. - { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } - : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: false, unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, @@ -150,11 +154,12 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { const SettingsContentStack = createNativeStackNavigator({ initialRouteName: "Settings", - screenOptions: { + screenOptions: ({ theme }) => ({ ...GLASS_HEADER_OPTIONS, + ...(!NATIVE_LIQUID_GLASS_SUPPORTED ? { headerStyle: themedOpaqueHeaderStyle(theme) } : null), // Sheets read better with the iOS-default centered title (no editor style). unstable_navigationItemStyle: undefined, - }, + }), screens: { Settings: createNativeStackScreen({ screen: SettingsRouteScreen, @@ -252,18 +257,24 @@ const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; // in-sheet pushes come from this nested stack). const NewTaskSheetStack = createNativeStackNavigator({ initialRouteName: "NewTask", - screenOptions: { + screenOptions: ({ route, theme }) => ({ ...SHEET_GLASS_HEADER_OPTIONS, + ...(!NATIVE_LIQUID_GLASS_SUPPORTED ? { headerStyle: themedOpaqueHeaderStyle(theme) } : null), // The form-sheet host owns the one opaque adaptive surface. Child screens // and the navigation bar stay transparent over it, avoiding visible color // slabs as view controllers move horizontally. - contentStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, + contentStyle: + Platform.OS === "ios" + ? route.name === "ThreadSettings" + ? themedFormSheetContentStyle(theme) + : { backgroundColor: "transparent" } + : undefined, // UIKit's default push adds a dimming shadow and independently transitions // the navigation bar. Both read as mismatched sheet backgrounds here. // simple_push retains native push/pop gestures without either artifact. animation: Platform.OS === "ios" ? "simple_push" : undefined, animationDuration: Platform.OS === "ios" ? 350 : undefined, - }, + }), screens: { NewTask: createNativeStackScreen({ screen: NewTaskRouteScreen, @@ -372,6 +383,11 @@ function ThreadOutboxDrainWorker() { return null; } +function SyncedClientPreferencesWorker() { + useSyncedClientPreferences(); + return null; +} + function RootStackLayout(props: { readonly children: React.ReactNode; readonly state: NavigationState; @@ -408,6 +424,7 @@ function RootStackLayout(props: { return ( + @@ -457,12 +474,30 @@ function NotFoundScreen() { ); } +const ROOT_FORM_SHEET_ROUTES = new Set([ + "ConnectOnboarding", + "Connections", + "ConnectionsNew", + "GitBranches", + "GitCommit", + "GitConfirm", + "GitOverview", + "NewTaskSheet", + "SettingsSheet", + "ThreadReviewComment", + "ThreadSettingsSheet", +]); + export const RootStack = createNativeStackNavigator({ initialRouteName: "Home", layout: RootStackLayout, - screenOptions: { + screenOptions: ({ route, theme }) => ({ headerShown: false, - }, + headerStyle: themedOpaqueHeaderStyle(theme), + ...(Platform.OS === "ios" && ROOT_FORM_SHEET_ROUTES.has(route.name) + ? { contentStyle: themedFormSheetContentStyle(theme) } + : null), + }), screens: { Home: createNativeStackScreen({ screen: HomeRouteScreen, @@ -505,11 +540,11 @@ export const RootStack = createNativeStackNavigator({ ThreadFiles: createNativeStackScreen({ screen: ThreadFilesTreeScreen, linking: `${THREAD_LINKING_PREFIX}/files`, - options: { + options: ({ theme }) => ({ ...GLASS_HEADER_OPTIONS, - contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE, + contentStyle: themedFormSheetContentStyle(theme), title: "Files", - }, + }), }), ThreadFile: createNativeStackScreen({ screen: ThreadFileScreen, diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 89dc0cc045b2..25ba39552f9a 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -118,6 +118,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "folder.badge.plus": IconFolderPlus, "folder.fill": IconFolder, gearshape: IconSettings, + hammer: IconHammer, "info.circle": IconInfoCircle, link: IconLink, "line.3.horizontal.decrease.circle": IconFilter, diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 9774130eb2eb..580673e0bfd8 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -2,14 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import type { ComponentType } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - FlatList, - ScrollView, - Text as NativeText, - useColorScheme, - useWindowDimensions, - View, -} from "react-native"; +import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; @@ -17,7 +10,6 @@ import { type NativeReviewDiffViewProps, resolveNativeReviewDiffView, } from "../diffs/nativeReviewDiffSurface"; -import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter"; import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering"; import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; import { cn } from "../../lib/cn"; @@ -25,11 +17,13 @@ import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences" import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { buildNativeSourceTokens, + createNativeSourceFileTheme, NATIVE_SOURCE_CONTENT_WIDTH, nativeSourceRowId, } from "./nativeSourceFileAdapter"; import { prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; interface SourceFileSurfaceProps { readonly contents: string; @@ -115,8 +109,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { }); function useSourceFileModel(props: SourceFileSurfaceProps) { - const colorScheme = useColorScheme(); - const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; + const { effectiveColorScheme: theme } = useAppearancePreferences(); const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]); const { contents: normalizedContents, lines, rowsJson } = document; const targetIndex = @@ -159,6 +152,7 @@ function NativeSourceFileSurface( ) { const { NativeView, onRefresh } = props; const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); + const { nativeSurfaceColors } = useAppearancePreferences(); const { width: viewportWidth } = useWindowDimensions(); const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props); const [isPullRefreshing, setIsPullRefreshing] = useState(false); @@ -178,7 +172,10 @@ function NativeSourceFileSurface( () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), [targetIndex], ); - const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]); + const themeJson = useMemo( + () => JSON.stringify(createNativeSourceFileTheme(theme, nativeSurfaceColors)), + [nativeSurfaceColors, theme], + ); const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]); const contentWidth = codeWordBreak ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24) diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts index 0e7d478c6bdb..b2f0b19df8b2 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildNativeSourceRows, buildNativeSourceTokens, + createNativeSourceFileTheme, NATIVE_SOURCE_ROW_HEIGHT, NATIVE_SOURCE_STYLE, nativeSourceRowId, @@ -13,6 +14,58 @@ import { } from "../review/nativeReviewDiffAdapter"; describe("nativeSourceFileAdapter", () => { + it("keeps the default native source theme byte-for-byte stable", () => { + expect(createNativeSourceFileTheme("light")).toEqual({ + background: "#f2f2f7", + text: "#070707", + mutedText: "#8E8E95", + headerBackground: "#f2f2f7", + border: "#eeeeef", + hunkBackground: "#e0f2ff", + hunkText: "#009fff", + addBackground: "#e5f8f5", + deleteBackground: "#ffe6e7", + addBar: "#00cab1", + deleteBar: "#ff2e3f", + addText: "#199F43", + deleteText: "#D52C36", + }); + expect(createNativeSourceFileTheme("dark")).toEqual({ + background: "#0e0e0e", + text: "#adadb1", + mutedText: "#8E8E95", + headerBackground: "#0e0e0e", + border: "#2e2e30", + hunkBackground: "#071f28", + hunkText: "#009fff", + addBackground: "#0d2f28", + deleteBackground: "#391415", + addBar: "#00cab1", + deleteBar: "#ff2e3f", + addText: "#5ECC71", + deleteText: "#FF6762", + }); + }); + + it("passes themed native source roles through without changing syntax tokens", () => { + expect( + createNativeSourceFileTheme("light", { + sheetBackground: "#eef4fa", + foreground: "#102030", + mutedForeground: "#607080", + border: "#ccddee", + accent: "#2277cc", + }), + ).toMatchObject({ + background: "#eef4fa", + text: "#102030", + mutedText: "#607080", + headerBackground: "#eef4fa", + border: "#ccddee", + hunkText: "#2277cc", + }); + }); + it("uses the same compact code typography as the diff viewer", () => { expect(NATIVE_SOURCE_ROW_HEIGHT).toBe(NATIVE_REVIEW_DIFF_ROW_HEIGHT); expect(NATIVE_SOURCE_STYLE).toMatchObject({ diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts index 0c83134ea703..3d80abc1bb22 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts @@ -7,6 +7,7 @@ import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences" import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; import type { SourceHighlightTokens } from "./sourceHighlightingState"; +export { createNativeReviewDiffTheme as createNativeSourceFileTheme } from "../review/nativeReviewDiffAdapter"; export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 93ecc109d04f..be36c98b2334 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -26,7 +26,6 @@ import { ScrollView, type NativeSyntheticEvent, StyleSheet, - useColorScheme, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -63,6 +62,7 @@ import { } from "../diffs/nativeReviewDiffSurface"; import { NATIVE_REVIEW_DIFF_CONTENT_WIDTH } from "./nativeReviewDiffAdapter"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useReviewDiffData } from "./useReviewDiffData"; import { useReviewDiffPrewarming } from "./useReviewDiffPrewarming"; import { useReviewFileVisibility } from "./reviewFileVisibility"; @@ -346,7 +346,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const colorScheme = useColorScheme(); + const { effectiveColorScheme } = useAppearancePreferences(); const headerIcon = String(useThemeColor("--color-icon")); const { environmentId, threadId } = props.route.params; const environment = useEnvironmentPresentation(environmentId); @@ -371,7 +371,7 @@ export function ReviewSheet(props: ReviewSheetProps) { // selected thread (it always does when reached from the thread's toolbar). const gitMenuAvailable = selectedThread !== null && String(selectedThread.id) === String(threadId); - const selectedTheme = colorScheme === "dark" ? "dark" : "light"; + const selectedTheme = effectiveColorScheme; // With a solid (non-overlay) header the content lays out below the header // natively, so no manual top inset is needed. (Android renders its own // in-flow AndroidScreenHeader, so it needs no inset either.) diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index 1722b06d6f8f..8bad69dc4cc4 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + createNativeReviewDiffTheme, getCachedNativeReviewDiffData, type BuildNativeReviewDiffDataInput, } from "./nativeReviewDiffAdapter"; @@ -19,6 +20,90 @@ const parsedDiff = buildReviewParsedDiff( "native-review-cache-test", ); +describe("createNativeReviewDiffTheme", () => { + it("keeps the default light review output byte-for-byte stable", () => { + expect(createNativeReviewDiffTheme("light")).toEqual({ + background: "#f2f2f7", + text: "#070707", + mutedText: "#8E8E95", + headerBackground: "#f2f2f7", + border: "#eeeeef", + hunkBackground: "#e0f2ff", + hunkText: "#009fff", + addBackground: "#e5f8f5", + deleteBackground: "#ffe6e7", + addBar: "#00cab1", + deleteBar: "#ff2e3f", + addText: "#199F43", + deleteText: "#D52C36", + }); + }); + + it("keeps the default dark review output byte-for-byte stable", () => { + expect(createNativeReviewDiffTheme("dark")).toEqual({ + background: "#0e0e0e", + text: "#adadb1", + mutedText: "#8E8E95", + headerBackground: "#0e0e0e", + border: "#2e2e30", + hunkBackground: "#071f28", + hunkText: "#009fff", + addBackground: "#0d2f28", + deleteBackground: "#391415", + addBar: "#00cab1", + deleteBar: "#ff2e3f", + addText: "#5ECC71", + deleteText: "#FF6762", + }); + }); + + it("themes surface roles without changing diff semantic colors", () => { + const base = createNativeReviewDiffTheme("dark"); + const themed = createNativeReviewDiffTheme("dark", { + sheetBackground: "#101820", + foreground: "#f0f4f8", + mutedForeground: "#8795a1", + border: "#334455", + accent: "#44aaff", + }); + + expect(themed).toMatchObject({ + background: "#101820", + text: "#f0f4f8", + mutedText: "#8795a1", + headerBackground: "#101820", + border: "#334455", + hunkText: "#44aaff", + }); + expect(themed).toMatchObject({ + addBackground: base.addBackground, + deleteBackground: base.deleteBackground, + addBar: base.addBar, + deleteBar: base.deleteBar, + addText: base.addText, + deleteText: base.deleteText, + }); + }); + + it("serializes native review alpha colors in CSS RRGGBBAA order", () => { + const payload = JSON.stringify( + createNativeReviewDiffTheme("dark", { + sheetBackground: "#11223344", + foreground: "#55667788", + mutedForeground: "#99aabbcc", + border: "#ddeeff11", + accent: "#22446680", + }), + ); + + expect(payload).toContain('"background":"#11223344"'); + expect(payload).toContain('"text":"#55667788"'); + expect(payload).toContain('"mutedText":"#99aabbcc"'); + expect(payload).toContain('"border":"#ddeeff11"'); + expect(payload).toContain('"hunkText":"#22446680"'); + }); +}); + function makeComment(text: string): ReviewInlineComment { return { id: "comment-1", diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 6d82940bb029..7ebc1af9f453 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -17,10 +17,16 @@ import { type ReviewRenderableLineRow, } from "./reviewModel"; import type { ReviewInlineComment } from "./reviewCommentSelection"; +import type { MobileNativeSurfaceColors } from "../../lib/mobileTheme"; const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; +export type NativeReviewDiffThemeOverrides = Pick< + MobileNativeSurfaceColors, + "sheetBackground" | "foreground" | "mutedForeground" | "border" | "accent" +>; + export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; @@ -112,47 +118,49 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray>( () => new Set(), ); - const theme = useMemo(() => createNativeReviewDiffTheme(scheme), [scheme]); + const theme = useMemo( + () => createNativeReviewDiffTheme(scheme, nativeSurfaceColors), + [nativeSurfaceColors, scheme], + ); const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]); const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]); const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]); diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 5b62942bb697..0a29ae827311 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -4,6 +4,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { ColorSchemeAppearanceSection } from "./appearance/sections/ColorSchemeAppearanceSection"; import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; @@ -29,6 +30,7 @@ export function SettingsAppearanceRouteScreen() { paddingBottom: Math.max(insets.bottom, 18) + 18, }} > + diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index b0e851b59d88..44d2099c90ea 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -35,6 +35,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { usePlanModeEnabled } from "../threads/use-plan-mode-enabled"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -43,6 +44,7 @@ import { runAppUpdateCheck, } from "../updates/app-updates"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { useUpdatePlanModePreference } from "../../state/synced-client-preferences"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; @@ -548,37 +550,35 @@ function GeneralSettingsSection() { ); } -/** - * Device-local legacy toggles. Mobile has no client-settings sync, so this is - * the counterpart of web's Settings → General → Legacy features backed by - * mobile preferences. - */ function LegacySettingsSection() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const preferences = useAtomValue(mobilePreferencesAtom); + const updatePlanModePreference = useUpdatePlanModePreference(); + const planModeEnabled = usePlanModeEnabled(); const threadListV2Enabled = useThreadListV2Enabled(); - const planModeEnabled = - AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; return ( + savePreferences({ legacyThreadListEnabled: value })} /> - savePreferences({ planModeEnabled: value })} - /> - Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan - control; otherwise every task runs in Build mode. + Opt into retired interfaces kept for compatibility. Plan mode restores the Build/Plan + control and the /plan and /default commands; while off, every task uses default mode. + + + Brings back the original grouped thread list. The default list is flat, in creation order: + active work renders as cards; settled threads collapse to compact rows. ); diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.test.ts b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.test.ts new file mode 100644 index 000000000000..a4624980ee72 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { setColorScheme, setTheme } = vi.hoisted(() => ({ + setColorScheme: vi.fn(), + setTheme: vi.fn(), +})); + +vi.mock("react-native", () => ({ + Appearance: { setColorScheme }, + useColorScheme: vi.fn(() => "light"), +})); + +vi.mock("uniwind", () => ({ + Uniwind: { + currentTheme: "light", + setTheme, + updateCSSVariables: vi.fn(), + }, +})); + +vi.mock("../../../state/preferences", () => ({ + mobilePreferencesAtom: {}, + updateMobilePreferencesAtom: {}, +})); + +vi.mock("../../../state/synced-client-preferences", () => ({ + useUpdateAppearanceModePreference: vi.fn(), + useUpdateThemePreference: vi.fn(), +})); + +vi.mock("../../terminal/terminalUiState", () => ({ + cacheTerminalFontSize: vi.fn(), +})); + +import { parseMobileThemeFile } from "../../../lib/mobileThemeFile"; +import { + applyAppearanceModeToRuntimes, + applyImportedThemeRemovalAtConfirmation, +} from "./AppearancePreferencesProvider"; + +describe("applyAppearanceModeToRuntimes", () => { + beforeEach(() => { + setColorScheme.mockClear(); + setTheme.mockClear(); + }); + + it.each([ + { mode: "light" as const, runtimeTheme: "light", nativeScheme: "light" }, + { mode: "dark" as const, runtimeTheme: "dark", nativeScheme: "dark" }, + { mode: "system" as const, runtimeTheme: "system", nativeScheme: "unspecified" }, + ])("applies $mode to both styling runtimes", ({ mode, runtimeTheme, nativeScheme }) => { + applyAppearanceModeToRuntimes(mode); + + expect(setTheme).toHaveBeenCalledWith(runtimeTheme); + expect(setColorScheme).toHaveBeenCalledWith(nativeScheme); + }); +}); + +describe("applyImportedThemeRemovalAtConfirmation", () => { + it("removes the alert target without replacing a newer theme selection", () => { + const removalTarget = parseMobileThemeFile({ + version: 1, + id: "removal-target", + name: "Removal Target", + appearance: "light", + colors: { canvas: "#ffffff" }, + }); + const newerSelection = parseMobileThemeFile({ + version: 1, + id: "newer-selection", + name: "Newer Selection", + appearance: "dark", + colors: { canvas: "#000000" }, + }); + const saveImportedThemes = vi.fn(); + const publishThemeId = vi.fn(); + + applyImportedThemeRemovalAtConfirmation(removalTarget.id, { + importedThemes: [removalTarget, newerSelection], + selectedThemeId: newerSelection.id, + saveImportedThemes, + publishThemeId, + }); + + expect(saveImportedThemes).toHaveBeenCalledWith([newerSelection]); + expect(publishThemeId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 7eaafc0e9e65..f2b1b0be11c0 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,7 +1,17 @@ -import { createContext, use, useCallback, useEffect, useMemo, type ReactNode } from "react"; +import { + createContext, + use, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; +import { Appearance, useColorScheme } from "react-native"; import { Uniwind } from "uniwind"; @@ -9,16 +19,46 @@ import { resolveAppearance, resolveAppearancePreferences, resolveTextScaleVariables, - type AppearancePreferences, type ResolvedAppearance, } from "../../../lib/appearancePreferences"; +import { + resolveColorSchemeOverride, + removeImportedMobileTheme, + resolveMobileNativeSurfaceColors, + resolveMobileThemePreferences, + resolveMobileThemeVariables, + isMobileThemeId, + type MobileAppearanceMode, + type MobileNativeSurfaceColors, + type MobileThemeAppearance, + type MobileThemeId, +} from "../../../lib/mobileTheme"; +import { + addImportedMobileTheme, + parseMobileThemeFileJson, + sanitizeImportedMobileThemes, + type ImportedMobileTheme, +} from "../../../lib/mobileThemeFile"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; +import { + useUpdateAppearanceModePreference, + useUpdateThemePreference, +} from "../../../state/synced-client-preferences"; import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { /** Effective values with base-size derivation applied. Use this for rendering. */ readonly appearance: ResolvedAppearance; + readonly appearanceMode: MobileAppearanceMode; + readonly effectiveColorScheme: MobileThemeAppearance; + readonly themeId: MobileThemeId; + readonly importedThemes: ReadonlyArray; + readonly nativeSurfaceColors: MobileNativeSurfaceColors | null; readonly isReady: boolean; + readonly setAppearanceMode: (value: MobileAppearanceMode) => void; + readonly setThemeId: (value: MobileThemeId) => void; + readonly importThemeJson: (value: string) => void; + readonly removeImportedTheme: (themeId: string) => void; readonly setBaseFontSize: (value: number) => void; /** Pass null to clear the override and follow the base font size. */ readonly setTerminalFontSize: (value: number | null) => void; @@ -29,26 +69,57 @@ interface AppearancePreferencesContextValue { const AppearancePreferencesContext = createContext(null); -/** - * Injects the scaled `--text-*` variables into Uniwind so every - * className-based text size (`text-sm`, `text-base`, ...) re-resolves live. - * Updates the current theme last so the active stylesheet settles correctly. - */ -function applyTextScaleVariables(baseFontSize: number) { - const variables = resolveTextScaleVariables(baseFontSize); +interface ImportedThemeRemovalConfirmationState { + readonly importedThemes: ReadonlyArray; + readonly selectedThemeId: MobileThemeId; + readonly saveImportedThemes: (themes: ReadonlyArray) => void; + readonly publishThemeId: (themeId: MobileThemeId) => void; +} + +export function applyImportedThemeRemovalAtConfirmation( + removedThemeId: string, + current: ImportedThemeRemovalConfirmationState, +) { + const patch = removeImportedMobileTheme( + current.importedThemes, + removedThemeId, + current.selectedThemeId, + ); + if (!patch) return; + current.saveImportedThemes(patch.importedThemes); + if (patch.themeId !== undefined) current.publishThemeId(patch.themeId); +} + +/** Updates the active stylesheet last so it settles correctly. */ +function updateCSSVariables( + resolveVariables: (theme: MobileThemeAppearance) => Readonly>, +) { const currentTheme = Uniwind.currentTheme; + const inactiveTheme = currentTheme === "light" ? "dark" : "light"; + Uniwind.updateCSSVariables(inactiveTheme, resolveVariables(inactiveTheme)); + Uniwind.updateCSSVariables(currentTheme, resolveVariables(currentTheme)); +} - for (const theme of ["light", "dark"] as const) { - if (theme !== currentTheme) { - Uniwind.updateCSSVariables(theme, variables); - } - } - Uniwind.updateCSSVariables(currentTheme, variables); +function applyThemeVariables( + themeId: MobileThemeId, + importedThemes: ReadonlyArray, +) { + updateCSSVariables((theme) => resolveMobileThemeVariables(themeId, theme, importedThemes)); +} + +export function applyAppearanceModeToRuntimes(appearanceMode: MobileAppearanceMode) { + const colorSchemeOverride = resolveColorSchemeOverride(appearanceMode); + Uniwind.setTheme(colorSchemeOverride ?? "system"); + Appearance.setColorScheme(colorSchemeOverride ?? "unspecified"); } export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const setAppearanceMode = useUpdateAppearanceModePreference(); + const updateThemePreference = useUpdateThemePreference(); + const systemColorScheme = useColorScheme(); + const [hasAppliedInitialPreferences, setHasAppliedInitialPreferences] = useState(false); const preferences = useMemo( () => resolveAppearancePreferences( @@ -56,58 +127,154 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN ), [preferencesResult], ); - const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; + const importedThemesJson = JSON.stringify( + AsyncResult.isSuccess(preferencesResult) ? (preferencesResult.value.importedThemes ?? []) : [], + ); + const importedThemes = useMemo( + () => sanitizeImportedMobileThemes(JSON.parse(importedThemesJson)), + [importedThemesJson], + ); + const themePreferences = useMemo( + () => + resolveMobileThemePreferences( + AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null, + importedThemes, + ), + [importedThemes, preferencesResult], + ); + const preferencesLoaded = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; + const effectiveColorScheme = + resolveColorSchemeOverride(themePreferences.appearanceMode) ?? + (systemColorScheme === "dark" ? "dark" : "light"); + const nativeSurfaceColors = useMemo( + () => + resolveMobileNativeSurfaceColors( + themePreferences.themeId, + effectiveColorScheme, + importedThemes, + ), + [effectiveColorScheme, importedThemes, themePreferences.themeId], + ); + const importedThemeRemovalState = useRef({ + importedThemes, + selectedThemeId: themePreferences.themeId, + saveImportedThemes: (themes) => savePreferences({ importedThemes: themes }), + publishThemeId: updateThemePreference, + }); + importedThemeRemovalState.current = { + importedThemes, + selectedThemeId: themePreferences.themeId, + saveImportedThemes: (themes) => savePreferences({ importedThemes: themes }), + publishThemeId: updateThemePreference, + }; + + useEffect(() => { + applyThemeVariables(themePreferences.themeId, importedThemes); + }, [importedThemes, themePreferences.themeId]); + + useEffect(() => { + applyAppearanceModeToRuntimes(themePreferences.appearanceMode); + }, [themePreferences.appearanceMode]); + + useEffect(() => { + const variables = resolveTextScaleVariables(preferences.baseFontSize); + updateCSSVariables(() => variables); + }, [preferences.baseFontSize]); useEffect(() => { - applyTextScaleVariables(preferences.baseFontSize); cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); - }, [preferences]); + }, [preferences.baseFontSize, preferences.terminalFontSize]); - const updatePreferences = useCallback( - (patch: Partial) => { - savePreferences(patch); - }, - [savePreferences], - ); + useEffect(() => { + if (preferencesLoaded) setHasAppliedInitialPreferences(true); + }, [preferencesLoaded]); + + const isReady = preferencesLoaded && hasAppliedInitialPreferences; const setBaseFontSize = useCallback( (value: number) => { - updatePreferences({ baseFontSize: value }); + savePreferences({ baseFontSize: value }); }, - [updatePreferences], + [savePreferences], ); const setTerminalFontSize = useCallback( (value: number | null) => { - updatePreferences({ terminalFontSize: value }); + savePreferences({ terminalFontSize: value }); }, - [updatePreferences], + [savePreferences], ); const setCodeFontSize = useCallback( (value: number | null) => { - updatePreferences({ codeFontSize: value }); + savePreferences({ codeFontSize: value }); }, - [updatePreferences], + [savePreferences], ); const setCodeWordBreak = useCallback( (value: boolean) => { - updatePreferences({ codeWordBreak: value }); + savePreferences({ codeWordBreak: value }); + }, + [savePreferences], + ); + + const setThemeId = useCallback( + (value: MobileThemeId) => { + if (!isMobileThemeId(value, importedThemes)) return; + updateThemePreference(value); + }, + [importedThemes, updateThemePreference], + ); + + const importThemeJson = useCallback( + (source: string) => { + const importedTheme = parseMobileThemeFileJson(source); + const next = addImportedMobileTheme(importedThemes, importedTheme); + savePreferences({ importedThemes: next }); + updateThemePreference(importedTheme.id); }, - [updatePreferences], + [importedThemes, savePreferences, updateThemePreference], ); + const removeImportedTheme = useCallback((removedThemeId: string) => { + applyImportedThemeRemovalAtConfirmation(removedThemeId, importedThemeRemovalState.current); + }, []); + const value = useMemo( (): AppearancePreferencesContextValue => ({ appearance: resolveAppearance(preferences), + appearanceMode: themePreferences.appearanceMode, + effectiveColorScheme, + themeId: themePreferences.themeId, + importedThemes, + nativeSurfaceColors, isReady, + setAppearanceMode, + setThemeId, + importThemeJson, + removeImportedTheme, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak, }), - [preferences, isReady, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak], + [ + preferences, + themePreferences, + effectiveColorScheme, + importedThemes, + nativeSurfaceColors, + isReady, + setAppearanceMode, + setThemeId, + importThemeJson, + removeImportedTheme, + setBaseFontSize, + setTerminalFontSize, + setCodeFontSize, + setCodeWordBreak, + ], ); return ( diff --git a/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx new file mode 100644 index 000000000000..9c553343576e --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx @@ -0,0 +1,273 @@ +import { useMemo, useState } from "react"; +import { Alert, KeyboardAvoidingView, Modal, Platform, Pressable, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../../../components/AppText"; +import { SymbolView } from "../../../../components/AppSymbol"; +import { + MOBILE_APPEARANCE_OPTIONS, + resolveMobileThemePickerOptions, + type MobileThemePickerOption, +} from "../../../../lib/mobileTheme"; +import { useThemeColor } from "../../../../lib/useThemeColor"; +import { SettingsSection } from "../../components/SettingsSection"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; + +function ThemePreview(props: { readonly theme: MobileThemePickerOption }) { + return ( + + {(["light", "dark"] as const).map((appearance) => ( + + + + ))} + + ); +} + +function ImportThemeSheet(props: { + readonly visible: boolean; + readonly onClose: () => void; + readonly onImport: (source: string) => void; +}) { + const [source, setSource] = useState(""); + const [error, setError] = useState(null); + + const close = () => { + setSource(""); + setError(null); + props.onClose(); + }; + const importTheme = () => { + try { + props.onImport(source); + close(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "That theme file could not be imported."); + } + }; + + return ( + + + + + Cancel + + Import theme + + + Import + + + + + + Paste a T3 Code ThemeFile v1 JSON object. Mobile colors support hex, named colors, + rgb()/rgba(), hsl()/hsla(), hwb(), lab(), lch(), oklab(), oklch(), and color(display-p3 + ...)/color(srgb ...). + + { + setSource(value); + setError(null); + }} + className="min-h-64 flex-1 font-mono text-sm" + placeholder={'{\n "version": 1,\n "name": "My theme",\n ...\n}'} + /> + {error ? ( + + {error} + + ) : null} + + + + ); +} + +export function ColorSchemeAppearanceSection() { + const checkmarkColor = useThemeColor("--color-primary-foreground"); + const dangerColor = String(useThemeColor("--color-danger-foreground")); + const [importSheetVisible, setImportSheetVisible] = useState(false); + const { + appearanceMode, + importedThemes, + importThemeJson, + isReady, + removeImportedTheme, + setAppearanceMode, + setThemeId, + themeId, + } = useAppearancePreferences(); + const themeRows = useMemo(() => { + const themes = resolveMobileThemePickerOptions(importedThemes); + return Array.from({ length: Math.ceil(themes.length / 2) }, (_, index) => + themes.slice(index * 2, index * 2 + 2), + ); + }, [importedThemes]); + + const confirmRemoveTheme = (theme: MobileThemePickerOption) => { + Alert.alert("Remove theme?", `Remove ${theme.label} from this device?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => removeImportedTheme(theme.id), + }, + ]); + }; + + return ( + + + Appearance + + {MOBILE_APPEARANCE_OPTIONS.map((option) => { + const selected = appearanceMode === option.id; + return ( + setAppearanceMode(option.id)} + className={ + selected + ? "min-h-11 flex-1 items-center justify-center rounded-xl bg-primary px-2" + : "min-h-11 flex-1 items-center justify-center rounded-xl border border-border bg-card-alt px-2" + } + > + + {option.label} + + + ); + })} + + + + + + + Theme + + {themeRows.map((row) => ( + + {row.map((theme) => { + const selected = themeId === theme.id; + return ( + + setThemeId(theme.id)} + className={selected ? "gap-2 p-2.5" : "gap-2 p-3"} + > + + + + {theme.label} + + {selected ? ( + + + + ) : null} + + + {theme.imported ? ( + confirmRemoveTheme(theme)} + > + + + ) : null} + + ); + })} + {row.length === 1 ? : null} + + ))} + + setImportSheetVisible(true)} + > + Import theme + + + setImportSheetVisible(false)} + onImport={importThemeJson} + /> + + ); +} diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index b205b4df72cc..96493583b2c2 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -39,6 +39,7 @@ interface TerminalSurfaceProps extends ViewProps { readonly isRunning: boolean; readonly autoFocus?: boolean; readonly keyboardFocusRequest?: number; + readonly appearanceScheme?: "light" | "dark"; readonly theme?: TerminalTheme; readonly onInput: (data: string) => void; readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void; @@ -60,7 +61,8 @@ function estimateGridSize(input: { const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) { const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; const inputRef = useRef(null); - const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; + const systemAppearanceScheme = useColorScheme() === "light" ? "light" : "dark"; + const appearanceScheme = props.appearanceScheme ?? systemAppearanceScheme; const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); const statusLabel = props.isRunning ? "Native terminal unavailable. Using text fallback." @@ -173,7 +175,8 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; - const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; + const systemAppearanceScheme = useColorScheme() === "light" ? "light" : "dark"; + const appearanceScheme = props.appearanceScheme ?? systemAppearanceScheme; const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); const { onInput, onResize } = props; const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index a80d90d82cd7..e64460775985 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -5,7 +5,7 @@ import { SymbolView } from "../../components/AppSymbol"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Platform, Pressable, View, useColorScheme } from "react-native"; +import { Platform, Pressable, View } from "react-native"; import { KeyboardController, KeyboardEvents, @@ -34,6 +34,7 @@ import { TERMINAL_FONT_SIZE_STEP, stepTerminalFontSize, } from "../../lib/appearancePreferences"; +import { withAlpha } from "../../lib/mobileTheme"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAttachedTerminalSession, @@ -166,7 +167,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); - const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const { state: workspaceState } = useWorkspaceState(); const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const params = props.route.params; @@ -184,7 +184,9 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const requestedTerminalId = firstRouteParam(params.terminalId); const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; const { + effectiveColorScheme: appearanceScheme, isReady: hasResolvedFontPreference, + nativeSurfaceColors, appearance, setTerminalFontSize, } = useAppearancePreferences(); @@ -466,7 +468,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) [selectedEnvironmentConnection?.environmentLabel], ); - const terminalTheme = getPierreTerminalTheme(appearanceScheme); + const terminalTheme = useMemo( + () => getPierreTerminalTheme(appearanceScheme, nativeSurfaceColors), + [appearanceScheme, nativeSurfaceColors], + ); const usesNativeHeaderGlass = Platform.OS === "ios"; const pendingModifier = pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; @@ -1219,6 +1224,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) <> @@ -1248,7 +1255,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {terminalToolbarActions.map((action) => { const active = diff --git a/apps/mobile/src/features/terminal/terminalTheme.test.ts b/apps/mobile/src/features/terminal/terminalTheme.test.ts index 3bf37b2eaab1..1e57b98a313c 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.test.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.test.ts @@ -3,23 +3,84 @@ import { describe, expect, it } from "vite-plus/test"; import { buildGhosttyThemeConfig, getPierreTerminalTheme } from "./terminalTheme"; describe("getPierreTerminalTheme", () => { - it("returns the Pierre light terminal palette", () => { - expect(getPierreTerminalTheme("light")).toMatchObject({ + it("keeps the default light terminal output byte-for-byte stable", () => { + expect(getPierreTerminalTheme("light")).toEqual({ background: "#f2f2f7", foreground: "#6C6C71", + mutedForeground: "#8E8E95", + border: "#eeeeef", cursorForeground: "#009fff", cursorBackground: "#f2f2f7", + palette: [ + "#1F1F21", + "#ff2e3f", + "#0dbe4e", + "#ffca00", + "#009fff", + "#c635e4", + "#08c0ef", + "#c6c6c8", + "#1F1F21", + "#ff2e3f", + "#0dbe4e", + "#ffca00", + "#009fff", + "#c635e4", + "#08c0ef", + "#c6c6c8", + ], }); }); - it("returns the Pierre dark terminal palette", () => { - expect(getPierreTerminalTheme("dark")).toMatchObject({ + it("keeps the default dark terminal output byte-for-byte stable", () => { + expect(getPierreTerminalTheme("dark")).toEqual({ background: "#0a0a0a", foreground: "#adadb1", + mutedForeground: "#8E8E95", + border: "#2e2e30", cursorForeground: "#009fff", cursorBackground: "#0a0a0a", + palette: [ + "#141415", + "#ff2e3f", + "#0dbe4e", + "#ffca00", + "#009fff", + "#c635e4", + "#08c0ef", + "#c6c6c8", + "#141415", + "#ff2e3f", + "#0dbe4e", + "#ffca00", + "#009fff", + "#c635e4", + "#08c0ef", + "#c6c6c8", + ], }); }); + + it("themes native surface roles without changing ANSI colors", () => { + const base = getPierreTerminalTheme("dark"); + const themed = getPierreTerminalTheme("dark", { + terminalBackground: "#101820", + terminalForeground: "#f0f4f8", + mutedForeground: "#8795a1", + border: "#334455", + terminalCursor: "#44aaff", + }); + + expect(themed).toMatchObject({ + background: "#101820", + foreground: "#f0f4f8", + mutedForeground: "#8795a1", + border: "#334455", + cursorForeground: "#44aaff", + cursorBackground: "#101820", + }); + expect(themed.palette).toEqual(base.palette); + }); }); describe("buildGhosttyThemeConfig", () => { @@ -33,4 +94,25 @@ describe("buildGhosttyThemeConfig", () => { expect(config).toContain("palette = 15=#c6c6c8"); expect(config.endsWith("\n")).toBe(true); }); + + it("serializes native terminal alpha colors in CSS RRGGBBAA order", () => { + const theme = getPierreTerminalTheme("dark", { + terminalBackground: "#11223344", + terminalForeground: "#55667788", + terminalCursor: "#99aabbcc", + mutedForeground: "#ddeeff11", + border: "#22446680", + }); + + expect({ + backgroundColor: theme.background, + foregroundColor: theme.foreground, + mutedForegroundColor: theme.mutedForeground, + }).toEqual({ + backgroundColor: "#11223344", + foregroundColor: "#55667788", + mutedForegroundColor: "#ddeeff11", + }); + expect(buildGhosttyThemeConfig(theme)).toContain("cursor-color = #99aabbcc"); + }); }); diff --git a/apps/mobile/src/features/terminal/terminalTheme.ts b/apps/mobile/src/features/terminal/terminalTheme.ts index c5ebd10b6894..b9d040cc75e3 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.ts @@ -1,3 +1,5 @@ +import type { MobileNativeSurfaceColors } from "../../lib/mobileTheme"; + export type TerminalAppearanceScheme = "light" | "dark"; export interface TerminalTheme { @@ -10,6 +12,11 @@ export interface TerminalTheme { readonly palette: readonly string[]; } +export type TerminalThemeOverrides = Pick< + MobileNativeSurfaceColors, + "terminalBackground" | "terminalForeground" | "terminalCursor" | "mutedForeground" | "border" +>; + const PIERRE_LIGHT_THEME: TerminalTheme = { // Pierre terminal palette with the app's shared screen background. background: "#f2f2f7", @@ -66,8 +73,21 @@ const PIERRE_DARK_THEME: TerminalTheme = { ], }; -export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme { - return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; +export function getPierreTerminalTheme( + scheme: TerminalAppearanceScheme, + overrides: TerminalThemeOverrides | null = null, +): TerminalTheme { + const base = scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; + if (!overrides) return base; + return { + ...base, + background: overrides.terminalBackground, + foreground: overrides.terminalForeground, + mutedForeground: overrides.mutedForeground, + border: overrides.border, + cursorForeground: overrides.terminalCursor, + cursorBackground: overrides.terminalBackground, + }; } export function buildGhosttyThemeConfig(theme: TerminalTheme): string { diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 17758721f0ab..101881a3b9a2 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -2,7 +2,7 @@ import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { memo } from "react"; -import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; +import { Pressable, ScrollView, View, type ViewStyle } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { GlassSurface } from "../../components/GlassSurface"; @@ -42,6 +42,7 @@ interface ComposerCommandPopoverProps { readonly items: ReadonlyArray; readonly triggerKind: ComposerTriggerKind | null; readonly isLoading: boolean; + readonly isDarkMode: boolean; readonly onSelect: (item: ComposerCommandItem) => void; } @@ -150,11 +151,10 @@ const CommandRow = memo(function CommandRow(props: { export const ComposerCommandPopover = memo(function ComposerCommandPopover( props: ComposerCommandPopoverProps, ) { - const isDarkMode = useColorScheme() === "dark"; const label = groupLabel(props.triggerKind); return ( - + {label ? ( diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 6084d8e15c68..e438b8c7564c 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -5,8 +5,8 @@ import { useNavigation, usePreventRemove, } from "@react-navigation/native"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Alert, Platform, Pressable, ScrollView, View, useColorScheme } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { KeyboardController, KeyboardStickyView, @@ -16,12 +16,17 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; +import { detectComposerTrigger, type ComposerTrigger } from "@t3tools/shared/composerTrigger"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; +import { + ComposerEditor, + type ComposerEditorHandle, + type ComposerEditorSelection, +} from "../../components/ComposerEditor"; import { ComposerInlineControl, ComposerToolbarButton, @@ -34,6 +39,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ComposerSurface } from "./ThreadComposer"; +import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning, @@ -41,6 +47,8 @@ import { import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; +import { withAlpha } from "../../lib/mobileTheme"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, @@ -63,7 +71,20 @@ import { resolveNewTaskBranchLabel, resolveNewTaskWorkspaceLabel, } from "./new-task-context-presentation"; +import { + canSubmitNewTaskDraft, + resolveNewTaskComposerSelection, + shouldApplyNewTaskCommandSelection, + shouldInterpretNewTaskSubmit, +} from "./new-task-submit"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; +import { + getPlanModeComposerSlashCommands, + replaceCurrentComposerTrigger, + resolveComposerInteractionMode, + resolveComposerSubmitInteractionMode, + resolveSlashCommandInteractionMode, +} from "./plan-mode"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; @@ -98,6 +119,7 @@ export function NewTaskDraftScreen(props: { const projects = useProjects(); const createProjectThread = useCreateProjectThread(); const flow = useNewTaskFlow(); + const planModeEnabled = flow.planModeEnabled; const navigation = useNavigation(); const { consumeShare, @@ -107,7 +129,7 @@ export function NewTaskDraftScreen(props: { reserveShare, } = useIncomingShare(); const insets = useSafeAreaInsets(); - const colorScheme = useColorScheme(); + const { effectiveColorScheme, nativeSurfaceColors } = useAppearancePreferences(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = Math.max(insets.bottom, 10); const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); @@ -122,6 +144,13 @@ export function NewTaskDraftScreen(props: { (environment) => environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; const promptInputRef = useRef(null); + const startInFlightRef = useRef(false); + const composerSelectionRef = useRef({ + start: flow.prompt.length, + end: flow.prompt.length, + }); + const composerSelectionDraftKeyRef = useRef(flow.draftKey); + const [composerSelection, setComposerSelection] = useState(composerSelectionRef.current); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ @@ -221,6 +250,41 @@ export function NewTaskDraftScreen(props: { !props.incomingShareId || (hasImportedIncomingShare && !incomingShare) || isIncomingShareUnavailable; + const composerTrigger = useMemo(() => { + if (composerSelection.start !== composerSelection.end) { + return null; + } + const trigger = detectComposerTrigger(flow.prompt, composerSelection.end); + return trigger?.kind === "slash-command" ? trigger : null; + }, [composerSelection, flow.prompt]); + const composerMenuItems = useMemo( + () => + composerTrigger + ? getPlanModeComposerSlashCommands({ + planModeEnabled, + query: composerTrigger.query, + }) + : [], + [composerTrigger, planModeEnabled], + ); + const updateComposerSelection = useCallback((selection: ComposerEditorSelection) => { + composerSelectionRef.current = selection; + setComposerSelection(selection); + }, []); + useEffect(() => { + const previousDraftKey = composerSelectionDraftKeyRef.current; + composerSelectionDraftKeyRef.current = flow.draftKey; + const next = resolveNewTaskComposerSelection({ + previousDraftKey, + draftKey: flow.draftKey, + promptLength: flow.prompt.length, + selection: composerSelectionRef.current, + }); + const current = composerSelectionRef.current; + if (next.start !== current.start || next.end !== current.end) { + updateComposerSelection(next); + } + }, [flow.draftKey, flow.prompt.length, updateComposerSelection]); const appliedInitialProjectKeyRef = useRef(null); useEffect(() => { if (cancelledIncomingShareId === props.incomingShareId) { @@ -293,8 +357,12 @@ export function NewTaskDraftScreen(props: { const projectUnderlineColor = useThemeColor("--color-foreground-muted"); const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); - const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; - const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; + const sheetFadeOpaque = String(useThemeColor("--color-sheet")); + const sheetFadeTransparent = nativeSurfaceColors + ? withAlpha(nativeSurfaceColors.sheetBackground, "00") + : effectiveColorScheme === "dark" + ? "rgba(14,14,14,0)" + : "rgba(242,242,247,0)"; // A new navigation to this mounted screen delivers a fresh initialProjectRef // reference — treat it as a new request and let it apply again. @@ -633,6 +701,55 @@ export function NewTaskDraftScreen(props: { [flow], ); + const handleCommandSelect = useCallback( + (item: ComposerCommandItem): void => { + const draftKey = flow.draftKey; + if ( + !shouldApplyNewTaskCommandSelection({ + incomingShareTransferPending: isIncomingShareTransferPending, + }) || + !draftKey || + !composerTrigger || + item.type !== "slash-command" + ) { + return; + } + const interactionMode = resolveSlashCommandInteractionMode({ + command: item.command, + planModeEnabled, + }); + if (interactionMode === null) { + return; + } + const draft = getComposerDraftSnapshot(draftKey); + const result = replaceCurrentComposerTrigger({ + text: draft.text, + selection: composerSelectionRef.current, + expectedKind: composerTrigger.kind, + expectedText: flow.prompt.slice(composerTrigger.rangeStart, composerTrigger.rangeEnd), + replacement: "", + extendSlashCommandToken: true, + }); + if (!result) { + console.warn("[new-task] composer trigger changed before command selection"); + return; + } + updateComposerSelection({ start: result.cursor, end: result.cursor }); + flow.setPrompt(result.text); + flow.setInteractionMode(interactionMode); + }, + [ + composerTrigger, + flow.draftKey, + flow.prompt, + flow.setInteractionMode, + flow.setPrompt, + isIncomingShareTransferPending, + planModeEnabled, + updateComposerSelection, + ], + ); + async function handleStart(): Promise { const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; @@ -640,6 +757,19 @@ export function NewTaskDraftScreen(props: { return; } const draft = getComposerDraftSnapshot(draftKey); + const submitInteractionMode = shouldInterpretNewTaskSubmit(flow) + ? resolveComposerSubmitInteractionMode({ + text: draft.text, + attachmentCount: draft.attachments.length, + planModeEnabled, + }) + : null; + if (submitInteractionMode !== null && !flow.submitting) { + updateComposerSelection({ start: 0, end: 0 }); + flow.setPrompt(""); + flow.setInteractionMode(submitInteractionMode); + return; + } // Snapshot read keeps just-typed selector state; the availability gate // still applies so a stored selection on a disabled provider falls back // to the flow's resolved model. @@ -654,16 +784,24 @@ export function NewTaskDraftScreen(props: { draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; - const interactionMode = flow.planModeEnabled - ? (draft.interactionMode ?? flow.interactionMode) - : "default"; + const interactionMode = resolveComposerInteractionMode({ + interactionMode: draft.interactionMode ?? flow.interactionMode, + planModeEnabled, + }); const initialMessageText = draft.text.trim(); if ( !modelSelection || - initialMessageText.length === 0 || - flow.submitting || - (workspaceMode === "worktree" && !selectedBranchName) + startInFlightRef.current || + !canSubmitNewTaskDraft({ + text: draft.text, + incomingShareReady: isIncomingShareReady, + importingShare: isImportingShare, + planModePreferenceLoaded: flow.planModePreferenceLoaded, + submitting: flow.submitting, + workspaceMode, + selectedBranchName, + }) ) { return; } @@ -686,6 +824,7 @@ export function NewTaskDraftScreen(props: { if (!message) { return; } + startInFlightRef.current = true; flow.setSubmitting(true); try { await enqueueThreadOutboxMessage(message); @@ -697,6 +836,7 @@ export function NewTaskDraftScreen(props: { return; } finally { flow.setSubmitting(false); + startInFlightRef.current = false; } if (editingPendingTask) { flow.finishEditingPendingTask(); @@ -710,7 +850,6 @@ export function NewTaskDraftScreen(props: { return; } - flow.setSubmitting(true); // Arm the lock-screen card before the async thread creation: backgrounding // the app right after tapping submit would otherwise reject the foreground // -only Activity start. If creation fails, the token registration's replay @@ -725,57 +864,65 @@ export function NewTaskDraftScreen(props: { selectedBranch: selectedBranchName, currentCheckoutBranch: flow.currentCheckoutBranchName, }); - const result = await createProjectThread({ - project: selectedProject, - modelSelection, - envMode: workspaceMode, - branch: creationBranch, - worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, - startFromOrigin, - runtimeMode, - interactionMode, - initialMessageText, - initialAttachments: draft.attachments, - ...(editingPendingTask - ? { - turnMetadata: { - threadId: editingPendingTask.threadId, - commandId: editingPendingTask.commandId, - messageId: editingPendingTask.messageId, - createdAt: editingPendingTask.createdAt, - }, - } - : {}), - }); - flow.setSubmitting(false); + startInFlightRef.current = true; + flow.setSubmitting(true); + try { + let createInput: Parameters[0] = { + project: selectedProject, + modelSelection, + envMode: workspaceMode, + branch: creationBranch, + worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, + startFromOrigin, + runtimeMode, + interactionMode, + initialMessageText, + initialAttachments: draft.attachments, + }; + if (editingPendingTask) { + createInput = { + ...createInput, + turnMetadata: { + threadId: editingPendingTask.threadId, + commandId: editingPendingTask.commandId, + messageId: editingPendingTask.messageId, + createdAt: editingPendingTask.createdAt, + }, + }; + } + const result = await createProjectThread(createInput); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Alert.alert( - "Could not start task", - error instanceof Error ? error.message : "The task could not be started.", - ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not start task", + error instanceof Error ? error.message : "The task could not be started.", + ); + } + return; } - return; - } - if (editingPendingTask) { - try { - await removeThreadOutboxMessage(editingPendingTask); - } catch (error) { - console.warn("[new-task] failed to remove delivered pending task", error); + if (editingPendingTask) { + try { + await removeThreadOutboxMessage(editingPendingTask); + } catch (error) { + console.warn("[new-task] failed to remove delivered pending task", error); + } + flow.finishEditingPendingTask(); + } else { + clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); } - flow.finishEditingPendingTask(); - } else { - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(result.value.environmentId), + threadId: String(result.value.threadId), + }), + ); + } finally { + flow.setSubmitting(false); + startInFlightRef.current = false; } - navigation.dispatch( - StackActions.replace("Thread", { - environmentId: String(result.value.environmentId), - threadId: String(result.value.threadId), - }), - ); } if (!selectedProject) { @@ -794,15 +941,29 @@ export function NewTaskDraftScreen(props: { } const isAndroid = Platform.OS === "android"; - const isDarkMode = colorScheme === "dark"; + const isDarkMode = effectiveColorScheme === "dark"; const canStart = Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && - flow.prompt.trim().length > 0 && - isIncomingShareReady && - !isImportingShare && - !flow.submitting && - !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); + canSubmitNewTaskDraft({ + text: flow.prompt, + incomingShareReady: isIncomingShareReady, + importingShare: isImportingShare, + planModePreferenceLoaded: flow.planModePreferenceLoaded, + submitting: flow.submitting, + workspaceMode: flow.workspaceMode, + selectedBranchName: flow.selectedBranchName, + }); + const commandPopover = + composerTrigger && composerMenuItems.length > 0 ? ( + + ) : null; const promptEditor = ( setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} + onSubmit={() => void handleStart()} placeholder="Ask anything…" singleLineCentered={false} contentInsetVertical={0} @@ -952,7 +1116,10 @@ export function NewTaskDraftScreen(props: { ); const composerDock = ( - + + {commandPopover ? ( + {commandPopover} + ) : null} {workspaceControls} 0 ? "Queue" : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; + const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = + usePlanModePreferenceState(); + const canSend = canSubmitExistingThreadDraft({ hasContent, planModePreferenceLoaded }); const connectionStatus = composerConnectionStatus({ connectionError: props.connectionError, connectionState: props.connectionState, @@ -347,25 +357,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); // ── Trigger detection ──────────────────────────────────── - const [composerSelection, setComposerSelection] = useState(() => ({ + const composerSelectionRef = useRef({ start: props.draftMessage.length, end: props.draftMessage.length, - })); + }); + const [composerSelection, setComposerSelection] = useState(composerSelectionRef.current); - const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => { + const updateComposerSelection = useCallback((selection: ComposerEditorSelection) => { + composerSelectionRef.current = selection; setComposerSelection(selection); }, []); useEffect(() => { const end = props.draftMessage.length; - setComposerSelection((selection) => { - const start = Math.min(selection.start, end); - const selectionEnd = Math.min(selection.end, end); - if (start === selection.start && selectionEnd === selection.end) { - return selection; - } - return { start, end: selectionEnd }; - }); - }, [props.draftMessage.length]); + const selection = composerSelectionRef.current; + const next = { + start: Math.min(selection.start, end), + end: Math.min(selection.end, end), + }; + if (next.start !== selection.start || next.end !== selection.end) { + updateComposerSelection(next); + } + }, [props.draftMessage.length, updateComposerSelection]); const composerTrigger = useMemo(() => { if (composerSelection.start !== composerSelection.end) { @@ -384,30 +396,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (composerTrigger.kind === "slash-command") { const q = composerTrigger.query.toLowerCase(); - const allBuiltIn = [ - { - id: "cmd:model", - type: "slash-command" as const, - command: "model", - label: "/model", - description: "Switch model", - }, - { - id: "cmd:plan", - type: "slash-command" as const, - command: "plan", - label: "/plan", - description: "Switch to plan mode", - }, - { - id: "cmd:default", - type: "slash-command" as const, - command: "default", - label: "/default", - description: "Switch to default mode", - }, - ]; - const builtIn = allBuiltIn.filter((item) => item.command.includes(q)); + const builtIn = getBuiltInComposerSlashCommands({ planModeEnabled, query: q }); const providerCommands: ComposerCommandItem[] = []; for (const cmd of selectedProviderStatus?.slashCommands ?? []) { @@ -522,77 +511,108 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + }, [composerTrigger, pathSearch.entries, planModeEnabled, selectedProviderStatus]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; const handleSend = useCallback(async () => { + if (!canSend) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const draft = getComposerDraftSnapshot(threadKey); + const submitInteractionMode = resolveComposerSubmitInteractionMode({ + text: draft.text, + attachmentCount: draft.attachments.length, + planModeEnabled, + }); + if (submitInteractionMode !== null) { + updateComposerSelection({ start: 0, end: 0 }); + onChangeDraftMessage(""); + onUpdateInteractionMode(submitInteractionMode); + return; + } + if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); try { - await onSendMessage(); - // Sending a prompt starts agent work: arm the lock-screen card while the - // app is foregrounded and the activity token can be registered. Armed - // after the send so its preference read and native Activity start don't - // contend with the queued-message feedback on the tap frame. - armAgentAwarenessLiveActivityForLocalWork({ - environmentId: props.environmentId, - threadTitle: props.selectedThread.title, - projectTitle: props.environmentLabel ?? "T3 Code", + await sendThreadComposerMessage({ + onSendMessage, + onSent: () => { + // Sending a prompt starts agent work: arm the lock-screen card while the + // app is foregrounded and the activity token can be registered. Armed + // after the send so its preference read and native Activity start don't + // contend with the queued-message feedback on the tap frame. + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: props.environmentId, + threadTitle: props.selectedThread.title, + projectTitle: props.environmentLabel ?? "T3 Code", + }); + }, }); } finally { inFlightThreadIdsRef.current.delete(threadKey); } }, [ + canSend, + onChangeDraftMessage, onSendMessage, + onUpdateInteractionMode, + planModeEnabled, props.environmentId, props.environmentLabel, props.selectedThread.id, props.selectedThread.title, + updateComposerSelection, ]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { if (!composerTrigger) return; - if ( - item.type === "slash-command" && - (item.command === "plan" || item.command === "default") - ) { - const result = replaceTextRange( - draftMessage, - composerTrigger.rangeStart, - composerTrigger.rangeEnd, - "", - ); - setComposerSelection({ start: result.cursor, end: result.cursor }); - onChangeDraftMessage(result.text); - onUpdateInteractionMode(item.command); - return; - } - + const interactionMode = + item.type === "slash-command" + ? resolveSlashCommandInteractionMode({ + command: item.command, + planModeEnabled, + }) + : null; let replacement = ""; if (item.type === "path") { replacement = `${serializeComposerFileLink(item.path)} `; } else if (item.type === "skill") { replacement = `$${item.skill.name} `; } else if (item.type === "slash-command") { - replacement = `/${item.command} `; + replacement = interactionMode === null ? `/${item.command} ` : ""; } else if (item.type === "provider-slash-command") { replacement = `/${item.command.name} `; } - const result = replaceTextRange( - draftMessage, - composerTrigger.rangeStart, - composerTrigger.rangeEnd, + const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const draft = getComposerDraftSnapshot(threadKey); + const result = replaceCurrentComposerTrigger({ + text: draft.text, + selection: composerSelectionRef.current, + expectedKind: composerTrigger.kind, + expectedText: draftMessage.slice(composerTrigger.rangeStart, composerTrigger.rangeEnd), replacement, - ); - setComposerSelection({ start: result.cursor, end: result.cursor }); + extendSlashCommandToken: true, + }); + if (!result) return; + updateComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); + if (interactionMode !== null) { + onUpdateInteractionMode(interactionMode); + } }, - [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], + [ + composerTrigger, + draftMessage, + onChangeDraftMessage, + onUpdateInteractionMode, + planModeEnabled, + props.environmentId, + props.selectedThread.id, + updateComposerSelection, + ], ); // ── Model menu ─────────────────────────────────────────── @@ -721,6 +741,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer items={composerMenuItems} triggerKind={composerTrigger.kind} isLoading={pathSearch.isPending} + isDarkMode={isDarkMode} onSelect={handleCommandSelect} /> @@ -779,7 +800,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer skills={selectedProviderStatus?.skills ?? []} selection={composerSelection} onChangeText={props.onChangeDraftMessage} - onSelectionChange={handleSelectionChange} + onSelectionChange={updateComposerSelection} onPasteImages={(uris) => void props.onNativePasteImages(uris)} placeholder={props.placeholder} onFocus={handleFocus} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index c5edb822ae5b..0f9924999e81 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -37,7 +37,6 @@ import { StyleSheet, Text as NativeText, type ColorValue, - useColorScheme, useWindowDimensions, View, } from "react-native"; @@ -194,43 +193,6 @@ function MessageAttachmentImage(props: { ); } -const MARKDOWN_COLORS = { - light: { - body: "#111111", - strong: "#000000", - link: "#2563eb", - blockquoteBorder: "rgba(0, 0, 0, 0.08)", - blockquoteBackground: "rgba(0, 0, 0, 0.02)", - codeBackground: "rgba(0, 0, 0, 0.04)", - codeText: "#262626", - inlineCodeText: "#5f6368", - horizontalRule: "rgba(0, 0, 0, 0.08)", - userBody: "#ffffff", - userCodeBackground: "rgba(255, 255, 255, 0.22)", - userCodeText: "#ffffff", - userInlineCodeText: "rgba(255, 255, 255, 0.82)", - userFenceBackground: "rgba(0, 0, 0, 0.16)", - userFenceText: "#ffffff", - }, - dark: { - body: "#e5e5e5", - strong: "#f5f5f5", - link: "#60a5fa", - blockquoteBorder: "rgba(255, 255, 255, 0.1)", - blockquoteBackground: "rgba(255, 255, 255, 0.03)", - codeBackground: "rgba(255, 255, 255, 0.06)", - codeText: "#e5e5e5", - inlineCodeText: "#b8bcc2", - horizontalRule: "rgba(255, 255, 255, 0.08)", - userBody: "#ffffff", - userCodeBackground: "rgba(255, 255, 255, 0.18)", - userCodeText: "#ffffff", - userInlineCodeText: "rgba(255, 255, 255, 0.82)", - userFenceBackground: "rgba(0, 0, 0, 0.28)", - userFenceText: "#ffffff", - }, -} as const; - const MARKDOWN_MONO_FONT = Platform.select({ ios: "ui-monospace", android: "monospace", @@ -426,8 +388,8 @@ function MarkdownCodeBlock(props: { } function useReviewCommentColors(): ReviewCommentColors { - const colorScheme = useColorScheme(); - const isDark = colorScheme === "dark"; + const { effectiveColorScheme } = useAppearancePreferences(); + const isDark = effectiveColorScheme === "dark"; const background = isDark ? "#151515" : "#ffffff"; const border = isDark ? "#2a2a2a" : "#d7d7d7"; const mutedBackground = isDark ? "#242424" : "#f2f2f2"; @@ -449,8 +411,7 @@ function useReviewCommentColors(): ReviewCommentColors { } function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { - const colorScheme = useColorScheme(); - const { appearance } = useAppearancePreferences(); + const { appearance, effectiveColorScheme } = useAppearancePreferences(); const markdownFontSizes = useMemo( () => resolveMarkdownFontSizes(appearance.baseFontSize), [appearance.baseFontSize], @@ -459,8 +420,22 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe () => resolveNativeMarkdownTypography(appearance.baseFontSize), [appearance.baseFontSize], ); - const themeMode = colorScheme === "dark" ? "dark" : "light"; - const colors = MARKDOWN_COLORS[themeMode]; + const themeMode = effectiveColorScheme; + const markdownBodyColor = String(useThemeColor("--color-md-body")); + const markdownStrongColor = String(useThemeColor("--color-md-strong")); + const markdownLinkColor = String(useThemeColor("--color-md-link")); + const markdownBlockquoteBorder = String(useThemeColor("--color-md-blockquote-border")); + const markdownBlockquoteBg = String(useThemeColor("--color-md-blockquote-bg")); + const markdownCodeBg = String(useThemeColor("--color-md-code-bg")); + const markdownCodeText = String(useThemeColor("--color-md-code-text")); + const markdownInlineCodeText = String(useThemeColor("--color-md-inline-code-text")); + const markdownHrColor = String(useThemeColor("--color-md-hr")); + const markdownUserBodyColor = String(useThemeColor("--color-user-bubble-foreground")); + const markdownUserCodeBg = String(useThemeColor("--color-md-user-code-bg")); + const markdownUserCodeText = String(useThemeColor("--color-md-user-code-text")); + const markdownUserInlineCodeText = String(useThemeColor("--color-md-user-inline-code-text")); + const markdownUserFenceBg = String(useThemeColor("--color-md-user-fence-bg")); + const markdownUserFenceText = String(useThemeColor("--color-md-user-fence-text")); const iconSubtleColor = String(useThemeColor("--color-icon-subtle")); const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted")); @@ -468,22 +443,6 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe const boldFontFamily = useFontFamily("bold"); return useMemo(() => { - const markdownBodyColor = colors.body; - const markdownStrongColor = colors.strong; - const markdownLinkColor = colors.link; - const markdownBlockquoteBg = colors.blockquoteBackground; - const markdownBlockquoteBorder = colors.blockquoteBorder; - const markdownCodeBg = colors.codeBackground; - const markdownCodeText = colors.codeText; - const markdownInlineCodeText = colors.inlineCodeText; - const markdownHrColor = colors.horizontalRule; - const markdownUserBodyColor = colors.userBody; - const markdownUserCodeBg = colors.userCodeBackground; - const markdownUserCodeText = colors.userCodeText; - const markdownUserInlineCodeText = colors.userInlineCodeText; - const markdownUserFenceBg = colors.userFenceBackground; - const markdownUserFenceText = colors.userFenceText; - const baseTheme: PartialMarkdownTheme = { colors: { text: markdownBodyColor, @@ -807,10 +766,24 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe }; }, [ boldFontFamily, - colors, iconSubtleColor, inlineSkillForeground, + markdownBlockquoteBg, + markdownBlockquoteBorder, + markdownBodyColor, + markdownCodeBg, + markdownCodeText, markdownFontSizes, + markdownHrColor, + markdownInlineCodeText, + markdownLinkColor, + markdownStrongColor, + markdownUserBodyColor, + markdownUserCodeBg, + markdownUserCodeText, + markdownUserFenceBg, + markdownUserFenceText, + markdownUserInlineCodeText, nativeMarkdownTypography, onLinkPress, regularFontFamily, @@ -1141,8 +1114,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { readonly colors: ReviewCommentColors; }) { const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface(); - const colorScheme = useColorScheme(); - const appearanceScheme = colorScheme === "light" ? "light" : "dark"; + const { effectiveColorScheme: appearanceScheme, nativeSurfaceColors } = + useAppearancePreferences(); const NativeReviewDiffView = resolveNativeReviewDiffView(); const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]); const parsedDiff = useMemo( @@ -1155,8 +1128,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { [nativeReviewDiffData.rows], ); const nativeReviewDiffTheme = useMemo( - () => createNativeReviewDiffTheme(appearanceScheme), - [appearanceScheme], + () => createNativeReviewDiffTheme(appearanceScheme, nativeSurfaceColors), + [appearanceScheme, nativeSurfaceColors], ); const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); const nativeThemeJson = useMemo( diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14f0fcc95a22..5d274d487f0f 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -77,11 +77,12 @@ import { } from "../home/homeThreadList"; import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; -import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; +import { resolveComposerInteractionMode } from "./plan-mode"; +import { usePlanModePreferenceState } from "./use-plan-mode-enabled"; type WorkspaceMode = "local" | "worktree"; @@ -144,6 +145,7 @@ type NewTaskFlowContextValue = { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly planModeEnabled: boolean; + readonly planModePreferenceLoaded: boolean; readonly expandedProvider: string | null; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; @@ -194,7 +196,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); const groupingSettings = useMobileProjectGroupingSettings(); - const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); + const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = + usePlanModePreferenceState(); const projectScopes = useMemo( () => sortHomeProjectScopes({ @@ -401,9 +404,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? true; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = planModeEnabled - ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) - : DEFAULT_PROVIDER_INTERACTION_MODE; + const interactionMode = resolveComposerInteractionMode({ + interactionMode: selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + planModeEnabled, + }); // Stored selections only count while their provider is usable on the // server; otherwise the server's default model wins instead of silently @@ -896,11 +900,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [ editingPendingProject, editingPendingTask, + planModeEnabled, selectedEnvironmentServerConfig, selectedModel, selectedProject, selectedProjectDraftKey, - planModeEnabled, planModePreferenceLoaded, startFromOrigin, workspaceMode, @@ -1022,6 +1026,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { runtimeMode, interactionMode, planModeEnabled, + planModePreferenceLoaded, expandedProvider, environments, selectedProject, @@ -1074,6 +1079,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { finishEditingPendingTask, interactionMode, planModeEnabled, + planModePreferenceLoaded, loadBranches, loadMoreBranches, projectScopes, diff --git a/apps/mobile/src/features/threads/new-task-submit.test.ts b/apps/mobile/src/features/threads/new-task-submit.test.ts new file mode 100644 index 000000000000..0bb886e74855 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-submit.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + canSubmitNewTaskDraft, + resolveNewTaskComposerSelection, + shouldApplyNewTaskCommandSelection, + shouldInterpretNewTaskSubmit, +} from "./new-task-submit"; + +describe("new-task submission eligibility", () => { + const readyInput = { + text: "Implement the task", + incomingShareReady: true, + importingShare: false, + planModePreferenceLoaded: true, + submitting: false, + workspaceMode: "local" as const, + selectedBranchName: null, + }; + + it("accepts a ready local draft", () => { + expect(canSubmitNewTaskDraft(readyInput)).toBe(true); + }); + + it("blocks submission while incoming share work is incomplete", () => { + expect(canSubmitNewTaskDraft({ ...readyInput, incomingShareReady: false })).toBe(false); + expect(canSubmitNewTaskDraft({ ...readyInput, importingShare: true })).toBe(false); + }); + + it("blocks submission while React state is active", () => { + expect(canSubmitNewTaskDraft({ ...readyInput, submitting: true })).toBe(false); + }); + + it("blocks submission until the plan mode preference is hydrated", () => { + expect(canSubmitNewTaskDraft({ ...readyInput, planModePreferenceLoaded: false })).toBe(false); + }); + + it("requires non-empty text and a non-empty worktree branch", () => { + expect(canSubmitNewTaskDraft({ ...readyInput, text: " \n\t" })).toBe(false); + expect( + canSubmitNewTaskDraft({ + ...readyInput, + workspaceMode: "worktree", + selectedBranchName: null, + }), + ).toBe(false); + expect( + canSubmitNewTaskDraft({ + ...readyInput, + workspaceMode: "worktree", + selectedBranchName: "", + }), + ).toBe(false); + expect( + canSubmitNewTaskDraft({ + ...readyInput, + workspaceMode: "worktree", + selectedBranchName: "main", + }), + ).toBe(true); + }); + + it("interprets submit-time commands only when creating a new task", () => { + expect(shouldInterpretNewTaskSubmit({ editingPendingTask: null })).toBe(true); + expect(shouldInterpretNewTaskSubmit({ editingPendingTask: {} })).toBe(false); + }); + + it("blocks command selection while an incoming share transfer is pending", () => { + expect(shouldApplyNewTaskCommandSelection({ incomingShareTransferPending: false })).toBe(true); + expect(shouldApplyNewTaskCommandSelection({ incomingShareTransferPending: true })).toBe(false); + }); + + it("resets selection to the hydrated draft end only when the draft key changes", () => { + expect( + resolveNewTaskComposerSelection({ + previousDraftKey: "new-task:project-1", + draftKey: "pending-task:message-1", + promptLength: 17, + selection: { start: 2, end: 2 }, + }), + ).toEqual({ start: 17, end: 17 }); + expect( + resolveNewTaskComposerSelection({ + previousDraftKey: "pending-task:message-1", + draftKey: "pending-task:message-1", + promptLength: 4, + selection: { start: 7, end: 7 }, + }), + ).toEqual({ start: 4, end: 4 }); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-submit.ts b/apps/mobile/src/features/threads/new-task-submit.ts new file mode 100644 index 000000000000..f25c62993764 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-submit.ts @@ -0,0 +1,45 @@ +export function shouldInterpretNewTaskSubmit(input: { + readonly editingPendingTask: object | null; +}): boolean { + return input.editingPendingTask === null; +} + +export function shouldApplyNewTaskCommandSelection(input: { + readonly incomingShareTransferPending: boolean; +}): boolean { + return !input.incomingShareTransferPending; +} + +export function resolveNewTaskComposerSelection(input: { + readonly previousDraftKey: string | null; + readonly draftKey: string | null; + readonly promptLength: number; + readonly selection: { readonly start: number; readonly end: number }; +}) { + if (input.previousDraftKey !== input.draftKey) { + return { start: input.promptLength, end: input.promptLength }; + } + return { + start: Math.max(0, Math.min(input.selection.start, input.promptLength)), + end: Math.max(0, Math.min(input.selection.end, input.promptLength)), + }; +} + +export function canSubmitNewTaskDraft(input: { + readonly text: string; + readonly incomingShareReady: boolean; + readonly importingShare: boolean; + readonly planModePreferenceLoaded: boolean; + readonly submitting: boolean; + readonly workspaceMode: "local" | "worktree"; + readonly selectedBranchName: string | null; +}): boolean { + return ( + input.text.trim().length > 0 && + input.incomingShareReady && + !input.importingShare && + input.planModePreferenceLoaded && + !input.submitting && + (input.workspaceMode !== "worktree" || Boolean(input.selectedBranchName)) + ); +} diff --git a/apps/mobile/src/features/threads/plan-mode.test.ts b/apps/mobile/src/features/threads/plan-mode.test.ts new file mode 100644 index 000000000000..aeb0c6b760d5 --- /dev/null +++ b/apps/mobile/src/features/threads/plan-mode.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + canSubmitExistingThreadDraft, + getBuiltInComposerSlashCommands, + getPlanModeComposerSlashCommands, + replaceCurrentComposerTrigger, + resolveComposerEnqueueInteractionMode, + resolveComposerInteractionMode, + resolveComposerSubmitInteractionMode, + resolvePlanModeEnabled, + resolveSlashCommandInteractionMode, +} from "./plan-mode"; + +describe("mobile plan mode", () => { + it("defaults off and hides the legacy slash commands", () => { + const planModeEnabled = resolvePlanModeEnabled(undefined); + + expect(planModeEnabled).toBe(false); + expect(getBuiltInComposerSlashCommands({ planModeEnabled, query: "" })).toEqual([ + expect.objectContaining({ command: "model" }), + ]); + }); + + it("shows the legacy slash commands when enabled", () => { + expect( + getBuiltInComposerSlashCommands({ planModeEnabled: true, query: "" }).map( + (item) => item.command, + ), + ).toEqual(["model", "plan", "default"]); + }); + + it("matches slash commands case-insensitively in both composers", () => { + expect( + getBuiltInComposerSlashCommands({ planModeEnabled: true, query: "PL" }).map( + (item) => item.command, + ), + ).toEqual(["plan"]); + expect( + getPlanModeComposerSlashCommands({ planModeEnabled: true, query: "PL" }).map( + (item) => item.command, + ), + ).toEqual(["plan"]); + }); + + it("only applies plan mode slash commands when enabled", () => { + expect(resolveSlashCommandInteractionMode({ command: "plan", planModeEnabled: false })).toBe( + null, + ); + expect(resolveSlashCommandInteractionMode({ command: "default", planModeEnabled: false })).toBe( + null, + ); + expect(resolveSlashCommandInteractionMode({ command: "plan", planModeEnabled: true })).toBe( + "plan", + ); + expect(resolveSlashCommandInteractionMode({ command: "default", planModeEnabled: true })).toBe( + "default", + ); + }); + + it("forces outgoing turns to default mode while disabled", () => { + expect( + resolveComposerInteractionMode({ interactionMode: "plan", planModeEnabled: false }), + ).toBe("default"); + expect(resolveComposerInteractionMode({ interactionMode: "plan", planModeEnabled: true })).toBe( + "plan", + ); + }); + + it("waits for the Plan Mode preference before choosing the queued mode", () => { + expect( + resolveComposerEnqueueInteractionMode({ + interactionMode: "plan", + planModeEnabled: false, + preferenceLoaded: false, + }), + ).toBeNull(); + expect( + resolveComposerEnqueueInteractionMode({ + interactionMode: "plan", + planModeEnabled: true, + preferenceLoaded: true, + }), + ).toBe("plan"); + }); + + describe("existing-thread submit parsing", () => { + it("blocks submission until the plan mode preference is hydrated", () => { + expect( + canSubmitExistingThreadDraft({ + hasContent: true, + planModePreferenceLoaded: false, + }), + ).toBe(false); + expect( + canSubmitExistingThreadDraft({ + hasContent: true, + planModePreferenceLoaded: true, + }), + ).toBe(true); + }); + + it("switches modes for standalone commands while enabled", () => { + expect( + resolveComposerSubmitInteractionMode({ + text: " /PLAN ", + attachmentCount: 0, + planModeEnabled: true, + }), + ).toBe("plan"); + expect( + resolveComposerSubmitInteractionMode({ + text: "/default", + attachmentCount: 0, + planModeEnabled: true, + }), + ).toBe("default"); + }); + + it("keeps typed commands inert while disabled", () => { + expect( + resolveComposerSubmitInteractionMode({ + text: "/plan", + attachmentCount: 0, + planModeEnabled: false, + }), + ).toBeNull(); + }); + + it("sends standalone-looking commands when attachments are present", () => { + expect( + resolveComposerSubmitInteractionMode({ + text: "/plan", + attachmentCount: 1, + planModeEnabled: true, + }), + ).toBeNull(); + }); + + it("sends non-standalone text containing a plan command", () => { + expect( + resolveComposerSubmitInteractionMode({ + text: "please use /plan for this", + attachmentCount: 0, + planModeEnabled: true, + }), + ).toBeNull(); + expect( + resolveComposerSubmitInteractionMode({ + text: "/plan extra", + attachmentCount: 0, + planModeEnabled: true, + }), + ).toBeNull(); + }); + }); + + describe("new-task submit parsing", () => { + it("shows only plan-mode commands in the popover while enabled", () => { + expect( + getPlanModeComposerSlashCommands({ planModeEnabled: true, query: "" }).map( + (item) => item.command, + ), + ).toEqual(["plan", "default"]); + expect(getPlanModeComposerSlashCommands({ planModeEnabled: false, query: "" })).toEqual([]); + }); + + it("keeps the typed command sendable and forces created threads to default while disabled", () => { + expect( + resolveComposerSubmitInteractionMode({ + text: "/plan", + attachmentCount: 0, + planModeEnabled: false, + }), + ).toBeNull(); + expect( + resolveComposerInteractionMode({ interactionMode: "plan", planModeEnabled: false }), + ).toBe("default"); + }); + }); + + describe("composer command replacement", () => { + it("aborts when the current trigger no longer matches the rendered trigger", () => { + expect( + replaceCurrentComposerTrigger({ + text: "/default", + selection: { start: 8, end: 8 }, + expectedKind: "slash-command", + expectedText: "/plan", + replacement: "", + extendSlashCommandToken: true, + }), + ).toBeNull(); + }); + + it("replaces the full slash-command token when the caret is inside it", () => { + expect( + replaceCurrentComposerTrigger({ + text: "/plan do things", + selection: { start: 3, end: 3 }, + expectedKind: "slash-command", + expectedText: "/pl", + replacement: "", + extendSlashCommandToken: true, + }), + ).toEqual({ text: " do things", cursor: 0 }); + }); + + it("preserves the slash-command remainder when token extension is disabled", () => { + expect( + replaceCurrentComposerTrigger({ + text: "/plan do things", + selection: { start: 3, end: 3 }, + expectedKind: "slash-command", + expectedText: "/pl", + replacement: "", + extendSlashCommandToken: false, + }), + ).toEqual({ text: "an do things", cursor: 0 }); + }); + + it("replaces the full path token when the caret is inside it", () => { + expect( + replaceCurrentComposerTrigger({ + text: "Open @src/old-file.ts now", + selection: { start: 13, end: 13 }, + expectedKind: "path", + expectedText: "@src/old", + replacement: "[new-file.ts](src/new-file.ts)", + extendSlashCommandToken: true, + }), + ).toEqual({ + text: "Open [new-file.ts](src/new-file.ts) now", + cursor: 35, + }); + }); + }); +}); diff --git a/apps/mobile/src/features/threads/plan-mode.ts b/apps/mobile/src/features/threads/plan-mode.ts new file mode 100644 index 000000000000..859cbc469314 --- /dev/null +++ b/apps/mobile/src/features/threads/plan-mode.ts @@ -0,0 +1,139 @@ +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + type ProviderInteractionMode, +} from "@t3tools/contracts"; +import { + detectComposerTrigger, + parseStandaloneComposerSlashCommand, + replaceTextRange, + type ComposerTrigger, +} from "@t3tools/shared/composerTrigger"; + +const BUILT_IN_COMPOSER_SLASH_COMMANDS = [ + { + id: "cmd:model", + type: "slash-command" as const, + command: "model", + label: "/model", + description: "Switch model", + }, + { + id: "cmd:plan", + type: "slash-command" as const, + command: "plan", + label: "/plan", + description: "Switch to plan mode", + }, + { + id: "cmd:default", + type: "slash-command" as const, + command: "default", + label: "/default", + description: "Switch to default mode", + }, +] as const; + +export function resolvePlanModeEnabled(preference: boolean | undefined): boolean { + return preference === true; +} + +function composerSlashCommandMatchesQuery(command: string, query: string): boolean { + return command.includes(query.toLowerCase()); +} + +export function getBuiltInComposerSlashCommands(input: { + readonly planModeEnabled: boolean; + readonly query: string; +}) { + return BUILT_IN_COMPOSER_SLASH_COMMANDS.filter( + (item) => + composerSlashCommandMatchesQuery(item.command, input.query) && + (input.planModeEnabled || (item.command !== "plan" && item.command !== "default")), + ); +} + +export function getPlanModeComposerSlashCommands(input: { + readonly planModeEnabled: boolean; + readonly query: string; +}) { + return BUILT_IN_COMPOSER_SLASH_COMMANDS.filter( + (item) => + item.command !== "model" && + input.planModeEnabled && + composerSlashCommandMatchesQuery(item.command, input.query), + ); +} + +export function resolveSlashCommandInteractionMode(input: { + readonly command: string; + readonly planModeEnabled: boolean; +}): ProviderInteractionMode | null { + if (!input.planModeEnabled) return null; + if (input.command === "plan" || input.command === DEFAULT_PROVIDER_INTERACTION_MODE) { + return input.command; + } + return null; +} + +export function resolveComposerSubmitInteractionMode(input: { + readonly text: string; + readonly attachmentCount: number; + readonly planModeEnabled: boolean; +}): ProviderInteractionMode | null { + if (input.attachmentCount > 0) return null; + const command = parseStandaloneComposerSlashCommand(input.text); + return command === null + ? null + : resolveSlashCommandInteractionMode({ command, planModeEnabled: input.planModeEnabled }); +} + +export function replaceCurrentComposerTrigger(input: { + readonly text: string; + readonly selection: { readonly start: number; readonly end: number }; + readonly expectedKind: ComposerTrigger["kind"]; + readonly expectedText: string; + readonly replacement: string; + readonly extendSlashCommandToken: boolean; +}): { readonly text: string; readonly cursor: number } | null { + if (input.selection.start !== input.selection.end) return null; + const trigger = detectComposerTrigger(input.text, input.selection.end); + if ( + trigger?.kind !== input.expectedKind || + input.text.slice(trigger.rangeStart, trigger.rangeEnd) !== input.expectedText + ) { + return null; + } + + let rangeEnd = trigger.rangeEnd; + if (input.extendSlashCommandToken) { + while (rangeEnd < input.text.length && !/\s/u.test(input.text[rangeEnd] ?? "")) { + rangeEnd += 1; + } + } + + return replaceTextRange(input.text, trigger.rangeStart, rangeEnd, input.replacement); +} + +export function resolveComposerInteractionMode(input: { + readonly interactionMode: ProviderInteractionMode | null | undefined; + readonly planModeEnabled: boolean; +}): ProviderInteractionMode { + return input.planModeEnabled + ? (input.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) + : DEFAULT_PROVIDER_INTERACTION_MODE; +} + +export function canSubmitExistingThreadDraft(input: { + readonly hasContent: boolean; + readonly planModePreferenceLoaded: boolean; +}): boolean { + return input.hasContent && input.planModePreferenceLoaded; +} + +export function resolveComposerEnqueueInteractionMode(input: { + readonly interactionMode: ProviderInteractionMode | null | undefined; + readonly planModeEnabled: boolean; + readonly preferenceLoaded: boolean; +}): ProviderInteractionMode | null { + return input.preferenceLoaded ? resolveComposerInteractionMode(input) : null; +} diff --git a/apps/mobile/src/features/threads/thread-composer-send.test.ts b/apps/mobile/src/features/threads/thread-composer-send.test.ts new file mode 100644 index 000000000000..aebc92755b0f --- /dev/null +++ b/apps/mobile/src/features/threads/thread-composer-send.test.ts @@ -0,0 +1,31 @@ +import { MessageId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { sendThreadComposerMessage } from "./thread-composer-send"; + +describe("thread composer send", () => { + it("does not run sent side effects when the draft was not queued", async () => { + const onSent = vi.fn(); + + await expect( + sendThreadComposerMessage({ + onSendMessage: async () => null, + onSent, + }), + ).resolves.toBeNull(); + expect(onSent).not.toHaveBeenCalled(); + }); + + it("runs sent side effects after a draft is queued", async () => { + const messageId = MessageId.make("message-1"); + const onSent = vi.fn(); + + await expect( + sendThreadComposerMessage({ + onSendMessage: async () => messageId, + onSent, + }), + ).resolves.toBe(messageId); + expect(onSent).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-composer-send.ts b/apps/mobile/src/features/threads/thread-composer-send.ts new file mode 100644 index 000000000000..08c93a3be5c9 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-composer-send.ts @@ -0,0 +1,12 @@ +import type { MessageId } from "@t3tools/contracts"; + +export async function sendThreadComposerMessage(input: { + readonly onSendMessage: () => Promise; + readonly onSent: () => void; +}): Promise { + const messageId = await input.onSendMessage(); + if (messageId === null) return null; + + input.onSent(); + return messageId; +} diff --git a/apps/mobile/src/features/threads/use-plan-mode-enabled.test.ts b/apps/mobile/src/features/threads/use-plan-mode-enabled.test.ts new file mode 100644 index 000000000000..042211b5813c --- /dev/null +++ b/apps/mobile/src/features/threads/use-plan-mode-enabled.test.ts @@ -0,0 +1,39 @@ +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted<{ + preferences: AsyncResult.AsyncResult<{ readonly planModeEnabled?: boolean }, never> | null; + reconciliationReady: boolean; +}>(() => ({ preferences: null, reconciliationReady: false })); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => testState.preferences, +})); + +vi.mock("../../state/preferences", () => ({ + mobilePreferencesAtom: Symbol("mobile-preferences"), +})); + +vi.mock("../../state/synced-client-preferences", () => ({ + usePlanModePreferenceReconciliationReady: () => testState.reconciliationReady, +})); + +import { usePlanModePreferenceState } from "./use-plan-mode-enabled"; + +describe("usePlanModePreferenceState", () => { + beforeEach(() => { + testState.preferences = AsyncResult.success({ planModeEnabled: false }); + testState.reconciliationReady = false; + }); + + it("keeps send gating closed when only device preferences have loaded", () => { + expect(usePlanModePreferenceState()).toEqual({ enabled: false, loaded: false }); + }); + + it("uses the device value after reconciliation readiness", () => { + testState.preferences = AsyncResult.success({ planModeEnabled: true }); + testState.reconciliationReady = true; + + expect(usePlanModePreferenceState()).toEqual({ enabled: true, loaded: true }); + }); +}); diff --git a/apps/mobile/src/features/threads/use-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-plan-mode-enabled.ts new file mode 100644 index 000000000000..213597e87ec6 --- /dev/null +++ b/apps/mobile/src/features/threads/use-plan-mode-enabled.ts @@ -0,0 +1,20 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { mobilePreferencesAtom } from "../../state/preferences"; +import { usePlanModePreferenceReconciliationReady } from "../../state/synced-client-preferences"; +import { resolvePlanModeEnabled } from "./plan-mode"; + +export function usePlanModePreferenceState() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const reconciliationReady = usePlanModePreferenceReconciliationReady(); + const loaded = AsyncResult.isSuccess(preferencesResult) && reconciliationReady; + return { + enabled: resolvePlanModeEnabled(loaded ? preferencesResult.value.planModeEnabled : undefined), + loaded, + } as const; +} + +export function usePlanModeEnabled(): boolean { + return usePlanModePreferenceState().enabled; +} diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts new file mode 100644 index 000000000000..22c8c8658154 --- /dev/null +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -0,0 +1,319 @@ +import * as NodeFS from "node:fs"; + +import { describe, expect, it } from "vite-plus/test"; + +import { + removeImportedMobileTheme, + resolveColorSchemeOverride, + resolveMobileFormSheetBackground, + resolveMobileThemePickerOptions, + resolveMobileThemeColors, + resolveMobileNativeSurfaceColors, + resolveMobileThemePreferences, + resolveMobileThemeVariables, + withAlpha, +} from "./mobileTheme"; +import { parseMobileThemeFile } from "./mobileThemeFile"; + +const GLOBAL_CSS = NodeFS.readFileSync(new URL("../../global.css", import.meta.url), "utf8"); + +function extractThemeVariables(appearance: "light" | "dark"): Readonly> { + const variant = GLOBAL_CSS.match( + new RegExp(`@variant\\s+${appearance}\\s*\\{([\\s\\S]*?)\\}`), + )?.[1]; + if (!variant) throw new Error(`Missing ${appearance} theme block in global.css`); + + return Object.fromEntries( + [...variant.matchAll(/(--color-[\w-]+)\s*:\s*([^;]+)\s*;/g)].map((match) => [ + match[1], + match[2]?.trim(), + ]), + ); +} + +describe("mobile theme preferences", () => { + it("defaults to the system appearance and T3 Code theme", () => { + expect(resolveMobileThemePreferences(undefined)).toEqual({ + appearanceMode: "system", + themeId: "t3-code", + }); + expect(resolveMobileNativeSurfaceColors("t3-code", "light")).toBeNull(); + expect(resolveMobileNativeSurfaceColors("t3-code", "dark")).toBeNull(); + expect(resolveMobileFormSheetBackground("t3-code", "light")).toBe("#f2f2f7"); + expect(resolveMobileFormSheetBackground("t3-code", "dark")).toBe("#0e0e0e"); + }); + + it("drops unknown stored values", () => { + expect( + resolveMobileThemePreferences({ appearanceMode: "sepia", themeId: "custom-theme" }), + ).toEqual({ appearanceMode: "system", themeId: "t3-code" }); + }); + + it("retains a selected imported theme while it remains installed", () => { + const imported = parseMobileThemeFile({ + version: 1, + id: "northern-lights", + name: "Northern Lights", + appearance: "light", + colors: { canvas: "#f1f5f9" }, + }); + + expect(resolveMobileThemePreferences({ themeId: imported.id }, [imported])).toEqual({ + appearanceMode: "system", + themeId: imported.id, + }); + }); + + it("falls back and persists cleanly when removing the selected imported theme", () => { + const first = parseMobileThemeFile({ + version: 1, + id: "first-theme", + name: "First Theme", + appearance: "light", + colors: { canvas: "#f1f5f9" }, + }); + const second = parseMobileThemeFile({ + version: 1, + id: "second-theme", + name: "Second Theme", + appearance: "dark", + colors: { canvas: "#0f172a" }, + }); + + expect(removeImportedMobileTheme([first, second], first.id, first.id)).toEqual({ + importedThemes: [second], + themeId: "t3-code", + }); + expect(removeImportedMobileTheme([first, second], first.id, second.id)).toEqual({ + importedThemes: [second], + }); + expect(removeImportedMobileTheme([first, second], "not-installed", second.id)).toBeNull(); + }); + + it("keeps built-ins first and preserves imported-theme order", () => { + const first = parseMobileThemeFile({ + version: 1, + id: "zebra-theme", + name: "Zebra Theme", + appearance: "light", + colors: { canvas: "#ffffff" }, + }); + const second = parseMobileThemeFile({ + version: 1, + id: "amber-theme", + name: "Amber Theme", + appearance: "dark", + colors: { canvas: "#000000" }, + }); + const options = resolveMobileThemePickerOptions([first, second]); + + expect(options.map((option) => option.id)).toEqual([ + "t3-code", + "t3-chat", + "grove", + "ocean", + "ember", + "iris", + "zebra-theme", + "amber-theme", + ]); + expect(resolveMobileThemePickerOptions([])[0]).toBe(options[0]); + }); +}); + +describe("mobile theme palette resolution", () => { + it.each(["light", "dark"] as const)( + "keeps the T3 Code %s palette identical to global.css", + (appearance) => { + expect(resolveMobileThemeVariables("t3-code", appearance)).toEqual( + extractThemeVariables(appearance), + ); + }, + ); + + it("resolves the selected theme for each appearance", () => { + expect(resolveMobileThemeColors("ocean", "light")).toMatchObject({ + canvas: "#f5f7f8", + accent: "#2672af", + }); + expect(resolveMobileThemeColors("ocean", "dark")).toMatchObject({ + canvas: "#17212b", + accent: "#70b9ee", + }); + expect(resolveMobileFormSheetBackground("ocean", "light")).toBe("#f5f7f8fa"); + expect(resolveMobileFormSheetBackground("ocean", "dark")).toBe("#17212bfa"); + }); + + it("falls back to T3 Code for stale theme ids", () => { + const flagship = resolveMobileThemeColors("t3-code", "light"); + + expect(resolveMobileThemeColors("removed-theme", "light")).toEqual(flagship); + }); + + it("maps imported theme roles with per-appearance T3 Code fallbacks", () => { + const imported = parseMobileThemeFile({ + version: 1, + id: "northern-lights", + name: "Northern Lights", + appearance: "light", + colors: { + canvas: "#f1f5f9", + text: "#0f172a", + border: "#cbd5e1", + accent: "#2563eb", + terminalBackground: "#e2e8f0", + terminalForeground: "#1e293b", + terminalCursor: "#3b82f6", + }, + }); + + expect(resolveMobileThemeColors(imported.id, "light", [imported])).toMatchObject({ + canvas: "#f1f5f9", + text: "#0f172a", + border: "#cbd5e1", + accent: "#2563eb", + surface: "#ffffff", + }); + expect(resolveMobileThemeColors(imported.id, "dark", [imported])).toEqual( + resolveMobileThemeColors("t3-code", "dark"), + ); + expect(resolveMobileNativeSurfaceColors(imported.id, "dark", [imported])).toBeNull(); + expect(resolveMobileNativeSurfaceColors(imported.id, "light", [imported])).toMatchObject({ + terminalBackground: "#e2e8f0", + terminalForeground: "#1e293b", + terminalCursor: "#3b82f6", + sheetBackground: "#f1f5f9fa", + foreground: "#0f172a", + border: "#cbd5e1", + accent: "#2563eb", + }); + }); + + it("resolves sparse imported roles with normalized T3 Code fallbacks", () => { + const imported = parseMobileThemeFile({ + version: 1, + id: "sparse-light", + name: "Sparse Light", + appearance: "light", + colors: { canvas: "#f1f5f9" }, + }); + + expect(resolveMobileThemeVariables(imported.id, "light", [imported])).toMatchObject({ + "--color-screen": "#f1f5f9", + "--color-card": "#ffffff", + "--color-border": "#00000014", + "--color-md-code-bg": "#0000000a", + }); + }); + + it("returns the exact T3 Code variables for an absent imported appearance", () => { + const imported = parseMobileThemeFile({ + version: 1, + id: "light-only", + name: "Light Only", + appearance: "light", + colors: resolveMobileThemeColors("ocean", "light"), + }); + + expect(resolveMobileThemeVariables(imported.id, "dark", [imported])).toBe( + resolveMobileThemeVariables("t3-code", "dark"), + ); + }); + + it("maps core roles onto mobile CSS variables", () => { + expect(resolveMobileThemeVariables("iris", "dark")).toMatchObject({ + "--color-screen": "#1d1929", + "--color-primary": "#9d7df2", + "--color-foreground": "#fffaff", + "--color-user-bubble": "#4b3d72", + }); + }); + + it("uses alternate-theme surfaces for input fills and web input roles for borders", () => { + expect(resolveMobileThemeVariables("iris", "dark")).toMatchObject({ + "--color-input": "#1d1929", + "--color-input-border": "#5d527b", + }); + }); + + it("uses accent-derived inline-skill colors for alternate themes", () => { + expect(resolveMobileThemeVariables("t3-chat", "light")).toMatchObject({ + "--color-inline-skill-background": "#db27771f", + "--color-inline-skill-border": "#db277740", + "--color-inline-skill-foreground": "#db2777", + }); + }); + + it("preserves the legacy T3 Code markdown palette", () => { + expect(resolveMobileThemeVariables("t3-code", "light")).toMatchObject({ + "--color-md-body": "#111111", + "--color-md-strong": "#000000", + "--color-md-link": "#2563eb", + "--color-md-blockquote-border": "rgba(0, 0, 0, 0.08)", + "--color-md-blockquote-bg": "rgba(0, 0, 0, 0.02)", + "--color-md-code-bg": "rgba(0, 0, 0, 0.04)", + "--color-md-code-text": "#262626", + "--color-md-inline-code-text": "#5f6368", + "--color-md-user-code-bg": "rgba(255, 255, 255, 0.22)", + "--color-md-user-code-text": "#ffffff", + "--color-md-user-inline-code-text": "rgba(255, 255, 255, 0.82)", + "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.16)", + "--color-md-user-fence-text": "#ffffff", + "--color-md-hr": "rgba(0, 0, 0, 0.08)", + "--color-user-bubble": "#007aff", + "--color-user-bubble-foreground": "#ffffff", + "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)", + }); + + expect(resolveMobileThemeVariables("t3-code", "dark")).toMatchObject({ + "--color-md-body": "#e5e5e5", + "--color-md-strong": "#f5f5f5", + "--color-md-link": "#60a5fa", + "--color-md-blockquote-border": "rgba(255, 255, 255, 0.1)", + "--color-md-blockquote-bg": "rgba(255, 255, 255, 0.03)", + "--color-md-code-bg": "rgba(255, 255, 255, 0.06)", + "--color-md-code-text": "#e5e5e5", + "--color-md-inline-code-text": "#b8bcc2", + "--color-md-user-code-bg": "rgba(255, 255, 255, 0.18)", + "--color-md-user-code-text": "#ffffff", + "--color-md-user-inline-code-text": "rgba(255, 255, 255, 0.82)", + "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.28)", + "--color-md-user-fence-text": "#ffffff", + "--color-md-hr": "rgba(255, 255, 255, 0.08)", + "--color-user-bubble": "#0a84ff", + "--color-user-bubble-foreground": "#ffffff", + "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)", + }); + }); + + it("uses readable role tokens for T3 Chat light markdown", () => { + expect(resolveMobileThemeVariables("t3-chat", "light")).toMatchObject({ + "--color-md-body": "#501854", + "--color-md-strong": "#501854", + "--color-md-link": "#db2777", + "--color-md-inline-code-text": "#ac1668", + "--color-md-user-inline-code-text": "#492c61d1", + "--color-user-bubble": "#f7def2", + "--color-user-bubble-foreground": "#492c61", + "--color-user-bubble-foreground-muted": "#492c61c7", + }); + }); +}); + +describe("mobile theme color helpers", () => { + it("rejects colors that cannot accept a hex alpha suffix", () => { + expect(() => withAlpha("rgb(0, 0, 0)", "1f")).toThrow(/6- or 8-digit hex/i); + }); + + it("combines an existing hex alpha for imported colors", () => { + expect(withAlpha("#00000014", "cc")).toBe("#00000010"); + }); +}); + +describe("mobile appearance mode override", () => { + it("derives native overrides while leaving system mode adaptive", () => { + expect(resolveColorSchemeOverride("system")).toBeNull(); + expect(resolveColorSchemeOverride("light")).toBe("light"); + expect(resolveColorSchemeOverride("dark")).toBe("dark"); + }); +}); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts new file mode 100644 index 000000000000..510e18bd8409 --- /dev/null +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -0,0 +1,820 @@ +import { + type ImportedMobileTheme, + normalizeMobileThemeColorLiteral, + type PortableThemeColorOverrides, +} from "./mobileThemeFile"; + +export const MOBILE_APPEARANCE_OPTIONS = [ + { id: "system", label: "System" }, + { id: "light", label: "Light" }, + { id: "dark", label: "Dark" }, +] as const; +export type MobileAppearanceMode = (typeof MOBILE_APPEARANCE_OPTIONS)[number]["id"]; +export type MobileThemeAppearance = Exclude; + +export const MOBILE_THEME_OPTIONS = [ + { id: "t3-code", label: "T3 Code" }, + { id: "t3-chat", label: "T3 Chat" }, + { id: "grove", label: "Grove" }, + { id: "ocean", label: "Ocean" }, + { id: "ember", label: "Ember" }, + { id: "iris", label: "Iris" }, +] as const; + +export type MobileBuiltInThemeId = (typeof MOBILE_THEME_OPTIONS)[number]["id"]; +export type MobileThemeId = string; + +export const DEFAULT_MOBILE_APPEARANCE_MODE: MobileAppearanceMode = "system"; +export const DEFAULT_MOBILE_THEME_ID: MobileBuiltInThemeId = "t3-code"; + +export interface MobileThemePreferences { + readonly appearanceMode: MobileAppearanceMode; + readonly themeId: MobileThemeId; +} + +export interface MobileCoreThemeColors { + readonly canvas: string; + readonly surface: string; + readonly surfaceRaised: string; + readonly surfaceOverlay: string; + readonly text: string; + readonly textMuted: string; + readonly border: string; + readonly input: string; + readonly accent: string; + readonly accentForeground: string; + readonly secondary: string; + readonly secondaryForeground: string; + readonly muted: string; + readonly mutedForeground: string; + readonly placeholder: string; + readonly error: string; + readonly errorForeground: string; + readonly errorSurface: string; + readonly messageSurface: string; + readonly messageForeground: string; + readonly codeBackground: string; + readonly codeForeground: string; + readonly sidebar: string; + readonly sidebarForeground: string; + readonly sidebarMutedForeground: string; + readonly sidebarControlSurface: string; +} + +export interface MobileNativeSurfaceColors { + readonly terminalBackground: string; + readonly terminalForeground: string; + readonly terminalCursor: string; + readonly sheetBackground: string; + readonly foreground: string; + readonly mutedForeground: string; + readonly border: string; + readonly accent: string; +} + +export interface MobileThemePickerOption { + readonly id: string; + readonly label: string; + readonly imported: boolean; + readonly light: MobileCoreThemeColors; + readonly dark: MobileCoreThemeColors; +} + +type MobileThemeVariants = Readonly< + Record>> +>; +type StoredMobileThemeValue = string | null | undefined; + +const T3_CODE_COLORS = { + light: { + canvas: "#f2f2f7", + surface: "#ffffff", + surfaceRaised: "#f5f5f5", + surfaceOverlay: "#ffffff", + text: "#262626", + textMuted: "#737373", + border: "rgba(0, 0, 0, 0.08)", + input: "rgba(0, 0, 0, 0.1)", + accent: "#262626", + accentForeground: "#ffffff", + secondary: "#ffffff", + secondaryForeground: "#262626", + muted: "rgba(0, 0, 0, 0.04)", + mutedForeground: "#525252", + placeholder: "#a3a3a3", + error: "rgba(239, 68, 68, 0.12)", + errorForeground: "#dc2626", + errorSurface: "#fef2f2", + messageSurface: "#007aff", + messageForeground: "#ffffff", + codeBackground: "rgba(0, 0, 0, 0.04)", + codeForeground: "#262626", + sidebar: "#ffffff", + sidebarForeground: "#262626", + sidebarMutedForeground: "#525252", + sidebarControlSurface: "rgba(118, 118, 128, 0.12)", + }, + dark: { + canvas: "#0a0a0a", + surface: "#171717", + surfaceRaised: "#1c1c1c", + surfaceOverlay: "#171717", + text: "#f5f5f5", + textMuted: "#8e8e93", + border: "rgba(255, 255, 255, 0.06)", + input: "rgba(255, 255, 255, 0.08)", + accent: "#f5f5f5", + accentForeground: "#0a0a0a", + secondary: "rgba(255, 255, 255, 0.04)", + secondaryForeground: "#f5f5f5", + muted: "rgba(255, 255, 255, 0.04)", + mutedForeground: "#a3a3a3", + placeholder: "#8e8e93", + error: "rgba(248, 113, 113, 0.18)", + errorForeground: "#fca5a5", + errorSurface: "rgba(239, 68, 68, 0.14)", + messageSurface: "#0a84ff", + messageForeground: "#ffffff", + codeBackground: "rgba(255, 255, 255, 0.06)", + codeForeground: "#e5e5e5", + sidebar: "#0e0e0e", + sidebarForeground: "#f5f5f5", + sidebarMutedForeground: "#a3a3a3", + sidebarControlSurface: "rgba(118, 118, 128, 0.24)", + }, +} satisfies Readonly>; + +function normalizeMobileThemeColors(colors: MobileCoreThemeColors): MobileCoreThemeColors { + const normalizedColors = { ...colors }; + // SAFETY: MobileCoreThemeColors is closed, so Object.keys returns only its declared roles. + for (const role of Object.keys(normalizedColors) as Array) { + const color = normalizeMobileThemeColorLiteral(normalizedColors[role]); + if (!color) throw new Error(`Invalid T3 Code fallback color for "${role}": ${colors[role]}`); + normalizedColors[role] = color; + } + return normalizedColors; +} + +const NORMALIZED_T3_CODE_COLORS = { + light: normalizeMobileThemeColors(T3_CODE_COLORS.light), + dark: normalizeMobileThemeColors(T3_CODE_COLORS.dark), +} satisfies Readonly>; + +const T3_CHAT_COLORS = { + light: { + canvas: "#fdf7fd", + surface: "#faf3fb", + surfaceRaised: "#fdfafd", + surfaceOverlay: "#ffffff", + text: "#501854", + textMuted: "#ac1668", + border: "#eee1ed", + input: "#e7c1dc", + accent: "#db2777", + accentForeground: "#ffffff", + secondary: "#f1c4e6", + secondaryForeground: "#77347c", + muted: "#eaa7cb", + mutedForeground: "#8d1255", + placeholder: "#8b5f90", + error: "#f7086c", + errorForeground: "#9d174d", + errorSurface: "#fde4f1", + messageSurface: "#f7def2", + messageForeground: "#492c61", + codeBackground: "#f5ecf9", + codeForeground: "#673c8b", + sidebar: "#f2e1f4", + sidebarForeground: "#454554", + sidebarMutedForeground: "#ac1668", + sidebarControlSurface: "#f8f8f7", + }, + dark: { + canvas: "#1f1a24", + surface: "#29232d", + surfaceRaised: "#2c2631", + surfaceOverlay: "#100a0e", + text: "#f9f8fb", + textMuted: "#e7d0dd", + border: "#27242c", + input: "#302029", + accent: "#a3004c", + accentForeground: "#fbd0e8", + secondary: "#362d3d", + secondaryForeground: "#d4c7e1", + muted: "#423a45", + mutedForeground: "#e7d0dd", + placeholder: "#968d9f", + error: "#9d174d", + errorForeground: "#fbd0e8", + errorSurface: "#331a2b", + messageSurface: "#2b2431", + messageForeground: "#f2ebfa", + codeBackground: "#1f1a24", + codeForeground: "#d8c3ef", + sidebar: "#171018", + sidebarForeground: "#f4f4f5", + sidebarMutedForeground: "#e7d0dd", + sidebarControlSurface: "#261922", + }, +} satisfies MobileThemeVariants; + +const MANAGED_LIGHT_COLORS = { + text: "#241523", + accentForeground: "#fffaff", + secondaryForeground: "#241523", + error: "#fb2c36", + errorForeground: "#c10007", + messageForeground: "#241523", + codeForeground: "#241523", + sidebarForeground: "#241523", +} as const; + +const MANAGED_DARK_COLORS = { + text: "#fffaff", + accentForeground: "#241523", + secondaryForeground: "#fffaff", + error: "#fb414a", + errorForeground: "#ff6467", + messageForeground: "#fffaff", + codeForeground: "#fffaff", + sidebarForeground: "#fffaff", +} as const; + +function managedTheme( + light: Readonly>, + dark: Readonly>, +) { + return { + light: { ...MANAGED_LIGHT_COLORS, ...light }, + dark: { ...MANAGED_DARK_COLORS, ...dark }, + } satisfies MobileThemeVariants; +} + +const THEME_VARIANTS = { + "t3-chat": T3_CHAT_COLORS, + grove: managedTheme( + { + canvas: "#f3f7f4", + surface: "#f3f7f4", + surfaceRaised: "#ecefed", + surfaceOverlay: "#e7e9e8", + textMuted: "#746c73", + border: "#cbd5d1", + input: "#becbc5", + accent: "#1b7d50", + secondary: "#e2ede7", + muted: "#e6f0ea", + mutedForeground: "#6e696f", + placeholder: "#716971", + errorSurface: "#f4e7e5", + messageSurface: "#cce1d7", + codeBackground: "#eef1ef", + sidebar: "#e2ede7", + sidebarMutedForeground: "#6b666c", + sidebarControlSurface: "#d3dcd8", + }, + { + canvas: "#1b2821", + surface: "#1b2821", + surfaceRaised: "#36413c", + surfaceOverlay: "#444d49", + textMuted: "#919595", + border: "#415f4f", + input: "#4f725f", + accent: "#69d69a", + secondary: "#2a4b39", + muted: "#253e31", + mutedForeground: "#9da5a2", + placeholder: "#a9abab", + errorSurface: "#3f2c28", + messageSurface: "#37664d", + codeBackground: "#28342e", + sidebar: "#21362b", + sidebarMutedForeground: "#9da3a2", + sidebarControlSurface: "#45554d", + }, + ), + ocean: managedTheme( + { + canvas: "#f5f7f8", + surface: "#f5f7f8", + surfaceRaised: "#edeff1", + surfaceOverlay: "#e8e9eb", + textMuted: "#746c75", + border: "#cdd4dc", + input: "#c0c9d4", + accent: "#2672af", + secondary: "#e4ecf2", + muted: "#e8eff4", + mutedForeground: "#6f6873", + placeholder: "#716972", + errorSurface: "#f5e6e9", + messageSurface: "#d0dfeb", + codeBackground: "#f0f1f3", + sidebar: "#e4ecf2", + sidebarMutedForeground: "#6c6570", + sidebarControlSurface: "#d5dbe2", + }, + { + canvas: "#17212b", + surface: "#17212b", + surfaceRaised: "#333b45", + surfaceOverlay: "#414851", + textMuted: "#8d8f97", + border: "#405567", + input: "#4f677b", + accent: "#70b9ee", + secondary: "#293f52", + muted: "#233544", + mutedForeground: "#969ca6", + placeholder: "#a4a4ac", + errorSurface: "#3c2630", + messageSurface: "#375871", + codeBackground: "#252e38", + sidebar: "#1e2d3b", + sidebarMutedForeground: "#989ca5", + sidebarControlSurface: "#424e5a", + }, + ), + ember: managedTheme( + { + canvas: "#f9f7f5", + surface: "#f9f7f5", + surfaceRaised: "#f1efee", + surfaceOverlay: "#ece9e9", + textMuted: "#766c74", + border: "#ddd2ce", + input: "#d4c6c1", + accent: "#ae552a", + secondary: "#f3eae5", + muted: "#f4ede9", + mutedForeground: "#74686f", + placeholder: "#736971", + errorSurface: "#f9e7e6", + messageSurface: "#ebdad1", + codeBackground: "#f3f1f0", + sidebar: "#f3eae5", + sidebarMutedForeground: "#71646b", + sidebarControlSurface: "#e2d9d6", + }, + { + canvas: "#291e1a", + surface: "#291e1a", + surfaceRaised: "#433835", + surfaceOverlay: "#4f4543", + textMuted: "#968e8f", + border: "#664c3f", + input: "#7a5d4d", + accent: "#f09a64", + secondary: "#513728", + muted: "#432e23", + mutedForeground: "#a59996", + placeholder: "#aba3a5", + errorSurface: "#4a2321", + messageSurface: "#704b34", + codeBackground: "#362b27", + sidebar: "#39281f", + sidebarMutedForeground: "#a49998", + sidebarControlSurface: "#584943", + }, + ), + iris: managedTheme( + { + canvas: "#f8f7f9", + surface: "#f8f7f9", + surfaceRaised: "#f0eff2", + surfaceOverlay: "#ebe9ed", + textMuted: "#766c76", + border: "#d6d1de", + input: "#ccc5d6", + accent: "#7253b9", + secondary: "#edeaf4", + muted: "#f0edf6", + mutedForeground: "#726874", + placeholder: "#736973", + errorSurface: "#f8e6ea", + messageSurface: "#e0d9ee", + codeBackground: "#f2f1f4", + sidebar: "#edeaf4", + sidebarMutedForeground: "#6f6471", + sidebarControlSurface: "#ddd9e3", + }, + { + canvas: "#1d1929", + surface: "#1d1929", + surfaceRaised: "#383443", + surfaceOverlay: "#454250", + textMuted: "#8e8a95", + border: "#4d4366", + input: "#5d527b", + accent: "#9d7df2", + secondary: "#362d51", + muted: "#2d2643", + mutedForeground: "#9690a1", + placeholder: "#a29ea8", + errorSurface: "#40202e", + messageSurface: "#4b3d72", + codeBackground: "#2a2736", + sidebar: "#272139", + sidebarMutedForeground: "#9792a0", + sidebarControlSurface: "#494459", + }, + ), +} satisfies Readonly, MobileThemeVariants>>; + +export function isMobileAppearanceMode( + value: StoredMobileThemeValue, +): value is MobileAppearanceMode { + return MOBILE_APPEARANCE_OPTIONS.some((option) => option.id === value); +} + +export function isMobileBuiltInThemeId( + value: StoredMobileThemeValue, +): value is MobileBuiltInThemeId { + return MOBILE_THEME_OPTIONS.some((theme) => theme.id === value); +} + +export function isMobileThemeId( + value: StoredMobileThemeValue, + importedThemes: ReadonlyArray = [], +): value is MobileThemeId { + return isMobileBuiltInThemeId(value) || importedThemes.some((theme) => theme.id === value); +} + +export function resolveMobileThemePreferences( + stored: + | { + readonly appearanceMode?: StoredMobileThemeValue; + readonly themeId?: StoredMobileThemeValue; + } + | null + | undefined, + importedThemes: ReadonlyArray = [], +): MobileThemePreferences { + return { + appearanceMode: isMobileAppearanceMode(stored?.appearanceMode) + ? stored.appearanceMode + : DEFAULT_MOBILE_APPEARANCE_MODE, + themeId: isMobileThemeId(stored?.themeId, importedThemes) + ? stored.themeId + : DEFAULT_MOBILE_THEME_ID, + }; +} + +export function removeImportedMobileTheme( + importedThemes: ReadonlyArray, + removedThemeId: string, + selectedThemeId: MobileThemeId, +): Readonly<{ + importedThemes: ReadonlyArray; + themeId?: MobileThemeId; +}> | null { + const next = importedThemes.filter((theme) => theme.id !== removedThemeId); + if (next.length === importedThemes.length) return null; + if (selectedThemeId === removedThemeId) { + return { importedThemes: next, themeId: DEFAULT_MOBILE_THEME_ID }; + } + return { importedThemes: next }; +} + +export function resolveColorSchemeOverride( + mode: MobileAppearanceMode, +): MobileThemeAppearance | null { + return mode === "system" ? null : mode; +} + +export function resolveMobileThemeColors( + themeId: string, + appearance: MobileThemeAppearance, + importedThemes: ReadonlyArray = [], +): MobileCoreThemeColors { + const fallback = T3_CODE_COLORS[appearance]; + if (isMobileBuiltInThemeId(themeId)) { + if (themeId === "t3-code") return fallback; + return { ...fallback, ...THEME_VARIANTS[themeId][appearance] }; + } + + const theme = importedThemes.find((candidate) => candidate.id === themeId); + const overrides = theme ? getImportedThemeOverrides(theme, appearance) : null; + return overrides + ? { ...NORMALIZED_T3_CODE_COLORS[appearance], ...pickMobileCoreThemeColors(overrides) } + : fallback; +} + +const BUILT_IN_MOBILE_THEME_PICKER_OPTIONS: ReadonlyArray = + MOBILE_THEME_OPTIONS.map((theme) => ({ + ...theme, + imported: false, + light: resolveMobileThemeColors(theme.id, "light"), + dark: resolveMobileThemeColors(theme.id, "dark"), + })); + +export function resolveMobileThemePickerOptions( + importedThemes: ReadonlyArray, +): ReadonlyArray { + return [ + ...BUILT_IN_MOBILE_THEME_PICKER_OPTIONS, + ...importedThemes.map((theme) => ({ + id: theme.id, + label: theme.name, + imported: true, + light: resolveMobileThemeColors(theme.id, "light", importedThemes), + dark: resolveMobileThemeColors(theme.id, "dark", importedThemes), + })), + ]; +} + +function getImportedThemeOverrides( + theme: ImportedMobileTheme, + appearance: MobileThemeAppearance, +): PortableThemeColorOverrides | null { + return appearance === theme.appearance ? theme.colors : (theme.variants?.[appearance] ?? null); +} + +function pickMobileCoreThemeColors( + colors: PortableThemeColorOverrides, +): Readonly> { + const overrides: { -readonly [Role in keyof MobileCoreThemeColors]?: string } = {}; + // SAFETY: T3_CODE_COLORS.light is a closed MobileCoreThemeColors object. + for (const role of Object.keys(T3_CODE_COLORS.light) as Array) { + const color = colors[role]; + if (color) overrides[role] = color; + } + return overrides; +} + +export function resolveMobileNativeSurfaceColors( + themeId: string, + appearance: MobileThemeAppearance, + importedThemes: ReadonlyArray = [], +): MobileNativeSurfaceColors | null { + if (themeId === "t3-code") return null; + const builtIn = isMobileBuiltInThemeId(themeId); + const importedTheme = importedThemes.find((candidate) => candidate.id === themeId); + if (!builtIn && !importedTheme) return null; + + const portableColors = importedTheme + ? getImportedThemeOverrides(importedTheme, appearance) + : null; + if (importedTheme && !portableColors) return null; + + const colors = resolveMobileThemeColors(themeId, appearance, importedThemes); + return { + terminalBackground: portableColors?.terminalBackground ?? colors.canvas, + terminalForeground: portableColors?.terminalForeground ?? colors.text, + terminalCursor: portableColors?.terminalCursor ?? colors.accent, + sheetBackground: withAlpha(colors.canvas, "fa"), + foreground: colors.text, + mutedForeground: colors.textMuted, + border: colors.border, + accent: colors.accent, + }; +} + +/** Keeps the shipped T3 Code sheet bytes while resolving selected-theme surfaces. */ +export function resolveMobileFormSheetBackground( + themeId: string, + appearance: MobileThemeAppearance, + importedThemes: ReadonlyArray = [], +): string { + const nativeSurfaceColors = resolveMobileNativeSurfaceColors(themeId, appearance, importedThemes); + return nativeSurfaceColors?.sheetBackground ?? (appearance === "dark" ? "#0e0e0e" : "#f2f2f7"); +} + +export function withAlpha(color: string, alpha: string): string { + if (!/^#[\da-f]{6}(?:[\da-f]{2})?$/i.test(color) || !/^[\da-f]{2}$/i.test(alpha)) { + throw new Error(`Expected a 6- or 8-digit hex color and 2-digit alpha, received ${color}`); + } + const existingAlpha = color.length === 9 ? Number.parseInt(color.slice(7), 16) : 255; + const requestedAlpha = Number.parseInt(alpha, 16); + const combinedAlpha = Math.round((existingAlpha * requestedAlpha) / 255); + return `${color.slice(0, 7)}${combinedAlpha.toString(16).padStart(2, "0")}`; +} + +const T3_CODE_MARKDOWN_VARIABLES = { + light: { + "--color-md-body": "#111111", + "--color-md-strong": "#000000", + "--color-md-link": "#2563eb", + "--color-md-blockquote-border": "rgba(0, 0, 0, 0.08)", + "--color-md-blockquote-bg": "rgba(0, 0, 0, 0.02)", + "--color-md-code-bg": "rgba(0, 0, 0, 0.04)", + "--color-md-code-text": "#262626", + "--color-md-inline-code-text": "#5f6368", + "--color-md-user-code-bg": "rgba(255, 255, 255, 0.22)", + "--color-md-user-code-text": "#ffffff", + "--color-md-user-inline-code-text": "rgba(255, 255, 255, 0.82)", + "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.16)", + "--color-md-user-fence-text": "#ffffff", + "--color-md-hr": "rgba(0, 0, 0, 0.08)", + "--color-user-bubble": "#007aff", + "--color-user-bubble-foreground": "#ffffff", + "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)", + }, + dark: { + "--color-md-body": "#e5e5e5", + "--color-md-strong": "#f5f5f5", + "--color-md-link": "#60a5fa", + "--color-md-blockquote-border": "rgba(255, 255, 255, 0.1)", + "--color-md-blockquote-bg": "rgba(255, 255, 255, 0.03)", + "--color-md-code-bg": "rgba(255, 255, 255, 0.06)", + "--color-md-code-text": "#e5e5e5", + "--color-md-inline-code-text": "#b8bcc2", + "--color-md-user-code-bg": "rgba(255, 255, 255, 0.18)", + "--color-md-user-code-text": "#ffffff", + "--color-md-user-inline-code-text": "rgba(255, 255, 255, 0.82)", + "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.28)", + "--color-md-user-fence-text": "#ffffff", + "--color-md-hr": "rgba(255, 255, 255, 0.08)", + "--color-user-bubble": "#0a84ff", + "--color-user-bubble-foreground": "#ffffff", + "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)", + }, +} as const; + +const T3_CODE_VARIABLES = { + light: { + "--color-screen": "#f2f2f7", + "--color-sheet": "rgba(242, 242, 247, 0.98)", + "--color-card": "#ffffff", + "--color-card-alt": "#f5f5f5", + "--color-card-translucent": "rgba(255, 255, 255, 0.8)", + "--color-foreground": "#262626", + "--color-foreground-secondary": "#525252", + "--color-foreground-muted": "#737373", + "--color-foreground-tertiary": "#8e8e93", + "--color-border": "rgba(0, 0, 0, 0.08)", + "--color-border-subtle": "rgba(0, 0, 0, 0.06)", + "--color-separator": "rgba(0, 0, 0, 0.04)", + "--color-subtle": "rgba(0, 0, 0, 0.04)", + "--color-subtle-strong": "rgba(0, 0, 0, 0.08)", + "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)", + "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)", + "--color-inline-skill-foreground": "#a21caf", + "--color-primary": "#262626", + "--color-primary-foreground": "#ffffff", + "--color-primary-shadow": "rgba(0, 0, 0, 0.18)", + "--color-secondary": "#ffffff", + "--color-secondary-foreground": "#262626", + "--color-secondary-border": "rgba(0, 0, 0, 0.08)", + "--color-switch-active": "#34c759", + "--color-danger": "#fef2f2", + "--color-danger-border": "rgba(239, 68, 68, 0.12)", + "--color-danger-foreground": "#dc2626", + "--color-input": "#ffffff", + "--color-input-border": "rgba(0, 0, 0, 0.1)", + "--color-sidebar-search": "rgba(118, 118, 128, 0.12)", + "--color-placeholder": "#a3a3a3", + "--color-icon": "#262626", + "--color-icon-muted": "#525252", + "--color-icon-subtle": "#a3a3a3", + "--color-header": "rgba(255, 255, 255, 0.97)", + "--color-header-border": "rgba(0, 0, 0, 0.06)", + "--color-glass-surface": "rgba(255, 255, 255, 0.72)", + "--color-glass-tint": "rgba(255, 255, 255, 0.18)", + "--color-status-bar": "#f2f2f7", + ...T3_CODE_MARKDOWN_VARIABLES.light, + "--color-backdrop": "rgba(0, 0, 0, 0.22)", + "--color-drawer": "rgba(255, 255, 255, 0.99)", + "--color-drawer-shadow": "rgba(0, 0, 0, 0.12)", + "--color-dot-separator": "rgba(0, 0, 0, 0.2)", + "--color-wordmark": "#262626", + "--color-chevron": "rgba(0, 0, 0, 0.2)", + }, + dark: { + "--color-screen": "#0a0a0a", + "--color-sheet": "rgba(14, 14, 14, 0.98)", + "--color-card": "#171717", + "--color-card-alt": "#1c1c1c", + "--color-card-translucent": "rgba(17, 17, 17, 0.8)", + "--color-foreground": "#f5f5f5", + "--color-foreground-secondary": "#a3a3a3", + "--color-foreground-muted": "#8e8e93", + "--color-foreground-tertiary": "#636366", + "--color-border": "rgba(255, 255, 255, 0.06)", + "--color-border-subtle": "rgba(255, 255, 255, 0.04)", + "--color-separator": "rgba(255, 255, 255, 0.03)", + "--color-subtle": "rgba(255, 255, 255, 0.04)", + "--color-subtle-strong": "rgba(255, 255, 255, 0.08)", + "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)", + "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)", + "--color-inline-skill-foreground": "#f0abfc", + "--color-primary": "#f5f5f5", + "--color-primary-foreground": "#0a0a0a", + "--color-primary-shadow": "rgba(0, 0, 0, 0.22)", + "--color-secondary": "rgba(255, 255, 255, 0.04)", + "--color-secondary-foreground": "#f5f5f5", + "--color-secondary-border": "rgba(255, 255, 255, 0.06)", + "--color-switch-active": "#30d158", + "--color-danger": "rgba(239, 68, 68, 0.14)", + "--color-danger-border": "rgba(248, 113, 113, 0.18)", + "--color-danger-foreground": "#fca5a5", + "--color-input": "#141414", + "--color-input-border": "rgba(255, 255, 255, 0.08)", + "--color-sidebar-search": "rgba(118, 118, 128, 0.24)", + "--color-placeholder": "#8e8e93", + "--color-icon": "#f5f5f5", + "--color-icon-muted": "#a3a3a3", + "--color-icon-subtle": "#8e8e93", + "--color-header": "rgba(10, 10, 10, 0.97)", + "--color-header-border": "rgba(255, 255, 255, 0.06)", + "--color-glass-surface": "rgba(23, 23, 23, 0.78)", + "--color-glass-tint": "rgba(23, 23, 23, 0.24)", + "--color-status-bar": "#0a0a0a", + ...T3_CODE_MARKDOWN_VARIABLES.dark, + "--color-backdrop": "rgba(0, 0, 0, 0.48)", + "--color-drawer": "rgba(14, 14, 14, 0.99)", + "--color-drawer-shadow": "rgba(0, 0, 0, 0.32)", + "--color-dot-separator": "rgba(255, 255, 255, 0.2)", + "--color-wordmark": "#f5f5f5", + "--color-chevron": "rgba(255, 255, 255, 0.2)", + }, +} as const; + +/** Maps web theme roles onto the smaller mobile token surface. */ +export function resolveMobileThemeVariables( + themeId: string, + appearance: MobileThemeAppearance, + importedThemes: ReadonlyArray = [], +) { + if (!isMobileThemeId(themeId, importedThemes) || themeId === "t3-code") { + return T3_CODE_VARIABLES[appearance]; + } + + const importedTheme = importedThemes.find((candidate) => candidate.id === themeId); + if (importedTheme && !getImportedThemeOverrides(importedTheme, appearance)) { + return T3_CODE_VARIABLES[appearance]; + } + + const colors = resolveMobileThemeColors(themeId, appearance, importedThemes); + const dark = appearance === "dark"; + const markdownVariables = { + "--color-md-body": colors.text, + "--color-md-strong": colors.text, + "--color-md-link": colors.accent, + "--color-md-blockquote-border": colors.border, + "--color-md-blockquote-bg": colors.muted, + "--color-md-code-bg": colors.codeBackground, + "--color-md-code-text": colors.codeForeground, + "--color-md-inline-code-text": colors.textMuted, + "--color-md-user-code-bg": withAlpha(colors.messageForeground, "38"), + "--color-md-user-code-text": colors.messageForeground, + "--color-md-user-inline-code-text": withAlpha(colors.messageForeground, "d1"), + "--color-md-user-fence-bg": withAlpha(colors.text, dark ? "47" : "29"), + "--color-md-user-fence-text": colors.messageForeground, + "--color-md-hr": colors.border, + "--color-user-bubble": colors.messageSurface, + "--color-user-bubble-foreground": colors.messageForeground, + "--color-user-bubble-foreground-muted": withAlpha(colors.messageForeground, "c7"), + }; + + return { + "--color-screen": colors.canvas, + "--color-sheet": withAlpha(colors.canvas, "fa"), + "--color-card": colors.surface, + "--color-card-alt": colors.surfaceRaised, + "--color-card-translucent": withAlpha(colors.surface, "cc"), + "--color-foreground": colors.text, + "--color-foreground-secondary": colors.mutedForeground, + "--color-foreground-muted": colors.textMuted, + "--color-foreground-tertiary": colors.placeholder, + "--color-border": colors.border, + "--color-border-subtle": withAlpha(colors.border, dark ? "b3" : "cc"), + "--color-separator": withAlpha(colors.border, "99"), + "--color-subtle": colors.muted, + "--color-subtle-strong": colors.secondary, + "--color-inline-skill-background": withAlpha(colors.accent, "1f"), + "--color-inline-skill-border": withAlpha(colors.accent, "40"), + "--color-inline-skill-foreground": colors.accent, + "--color-primary": colors.accent, + "--color-primary-foreground": colors.accentForeground, + "--color-primary-shadow": withAlpha(colors.text, dark ? "38" : "2e"), + "--color-secondary": colors.secondary, + "--color-secondary-foreground": colors.secondaryForeground, + "--color-secondary-border": colors.border, + "--color-switch-active": colors.accent, + "--color-danger": colors.errorSurface, + "--color-danger-border": colors.error, + "--color-danger-foreground": colors.errorForeground, + "--color-input": colors.surface, + "--color-input-border": colors.input, + "--color-sidebar-search": colors.sidebarControlSurface, + "--color-placeholder": colors.placeholder, + "--color-icon": colors.text, + "--color-icon-muted": colors.textMuted, + "--color-icon-subtle": colors.placeholder, + "--color-header": withAlpha(colors.canvas, "f7"), + "--color-header-border": colors.border, + "--color-glass-surface": withAlpha(colors.surface, "c7"), + "--color-glass-tint": withAlpha(colors.surfaceRaised, "3d"), + "--color-status-bar": colors.canvas, + ...markdownVariables, + "--color-backdrop": dark ? "#0000007a" : "#00000038", + "--color-drawer": withAlpha(colors.sidebar, "fc"), + "--color-drawer-shadow": dark ? "#00000052" : "#0000001f", + "--color-dot-separator": withAlpha(colors.text, "33"), + "--color-wordmark": colors.text, + "--color-chevron": withAlpha(colors.text, "33"), + }; +} diff --git a/apps/mobile/src/lib/mobileThemeFile.test.ts b/apps/mobile/src/lib/mobileThemeFile.test.ts new file mode 100644 index 000000000000..0a90774faac4 --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeFile.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + addImportedMobileTheme, + MAX_IMPORTED_MOBILE_THEMES, + MAX_IMPORTED_MOBILE_THEMES_BYTES, + MAX_MOBILE_THEME_FILE_BYTES, + parseMobileThemeFile, + parseMobileThemeFileJson, + sanitizeImportedMobileThemes, +} from "./mobileThemeFile"; + +function themeFile(id = "northern-lights") { + return { + version: 1, + id, + name: "Northern Lights", + appearance: "light", + colors: { + canvas: "#f1f5f9", + text: "rgb(15 23 42)", + accent: "oklch(0.62 0.2 280)", + terminalCursor: "hsl(210 90% 55%)", + }, + variants: { + dark: { + canvas: "#0f172a", + text: "#f8fafc", + accent: "#60a5fa", + }, + }, + } as const; +} + +describe("parseMobileThemeFile", () => { + it("accepts and normalizes a portable ThemeFile v1", () => { + expect(parseMobileThemeFile(themeFile())).toMatchObject({ + version: 1, + id: "northern-lights", + name: "Northern Lights", + appearance: "light", + colors: { + canvas: "#f1f5f9", + text: "#0f172a", + accent: expect.stringMatching(/^#[\da-f]{6}$/), + terminalCursor: expect.stringMatching(/^#[\da-f]{6}$/), + }, + variants: { + dark: { + canvas: "#0f172a", + text: "#f8fafc", + accent: "#60a5fa", + }, + }, + }); + }); + + it("normalizes supported CSS literals to RN-safe hex without dropping alpha", () => { + const parsed = parseMobileThemeFile({ + ...themeFile(), + colors: { + canvas: "#abc", + surface: "rgb(100% 0% 50% / 25%)", + text: "aliceblue", + border: "transparent", + accent: "hsl(120 100% 25% / 50%)", + terminalCursor: "oklch(100% 0 0 / 50%)", + }, + }); + + expect(parsed.colors).toEqual({ + canvas: "#aabbcc", + surface: "#ff008040", + text: "#f0f8ff", + border: "#00000000", + accent: "#00800080", + terminalCursor: "#ffffff80", + }); + }); + + it("matches web parsing of newline-separated functional color components", () => { + const parsed = parseMobileThemeFile({ + ...themeFile(), + colors: { canvas: "rgb(15\n23\n42)" }, + }); + + expect(parsed.colors.canvas).toBe("#0f172a"); + }); + + it.each([ + { input: "rgb(none 20 30)", expected: "#00141e" }, + { input: "rgb(255 0 0 / 200%)", expected: "#ff0000" }, + { input: "rgb(300 0 0)", expected: "#ff6c5b" }, + { input: "rgb(-10 0 0)", expected: "#000000" }, + { input: "rgb(-10% 0% 0%)", expected: "#000000" }, + ])("normalizes CSS RGB edge case $input", ({ input, expected }) => { + const parsed = parseMobileThemeFile({ ...themeFile(), colors: { canvas: input } }); + + expect(parsed.colors.canvas).toBe(expected); + }); + + it.each([ + { input: "hsl(none 0% 50%)", expected: "#808080" }, + { input: "hsl(120 none none)", expected: "#000000" }, + ])("matches web missing HSL component semantics for $input", ({ input, expected }) => { + const parsed = parseMobileThemeFile({ ...themeFile(), colors: { canvas: input } }); + + expect(parsed.colors.canvas).toBe(expected); + }); + + it.each([ + { format: "hwb typical", input: "hwb(45 10% 20%)", expected: "#cc9f1a" }, + { format: "hwb with alpha", input: "hwb(200 10% 20% / 40%)", expected: "#1991cc66" }, + { format: "hwb normalized edge", input: "hwb(30 80% 70%)", expected: "#888888" }, + { format: "lab typical", input: "lab(60% 40 30)", expected: "#d9725e" }, + { format: "lab with alpha", input: "lab(70% -20 40 / 25%)", expected: "#99b55f40" }, + { format: "lab out of gamut", input: "lab(60% 100 100)", expected: "#ff574a" }, + { format: "lch typical", input: "lch(60% 50 120)", expected: "#769c3e" }, + { format: "lch with alpha", input: "lch(70% 40 250 / 25%)", expected: "#60b5ef40" }, + { format: "lch out of gamut", input: "lch(60% 150 40)", expected: "#ff5a5b" }, + { format: "oklab typical", input: "oklab(0.6 0.1 -0.1)", expected: "#9f63ba" }, + { + format: "oklab with alpha", + input: "oklab(70% -0.1 0.1 / 25%)", + expected: "#77b25340", + }, + { format: "oklab out of gamut", input: "oklab(0.7 0.4 0.4)", expected: "#fe6a00" }, + { + format: "display-p3 typical", + input: "color(display-p3 0.8 0.2 0.3)", + expected: "#de1849", + }, + { + format: "display-p3 with alpha", + input: "color(display-p3 0.2 0.7 0.4 / 40%)", + expected: "#00b16966", + }, + { + format: "display-p3 out of gamut", + input: "color(display-p3 1 0 0)", + expected: "#ff3428", + }, + { + format: "srgb typical", + input: "color(srgb 0.2 0.4 0.8)", + expected: "#3366cc", + }, + { + format: "srgb with alpha", + input: "color(srgb 0.2 0.7 0.4 / 40%)", + expected: "#33b36666", + }, + { + format: "srgb out of gamut", + input: "color(srgb 1.2 -0.1 0.5)", + expected: "#ff7391", + }, + ])("matches web canonicalization for $format", ({ input, expected }) => { + const parsed = parseMobileThemeFile({ ...themeFile(), colors: { canvas: input } }); + + expect(parsed.colors.canvas).toBe(expected); + }); + + it("rejects unsupported versions", () => { + expect(() => parseMobileThemeFile({ ...themeFile(), version: 2 })).toThrow( + "unsupported version", + ); + }); + + it("rejects invalid ids", () => { + expect(() => parseMobileThemeFile({ ...themeFile(), id: "Not Valid" })).toThrow( + "lowercase letters, numbers, and hyphens", + ); + }); + + it("enforces id, name, appearance, and colors-object boundaries", () => { + expect(parseMobileThemeFile({ ...themeFile(), id: "a".repeat(48) }).id).toBe("a".repeat(48)); + expect(() => parseMobileThemeFile({ ...themeFile(), id: "a".repeat(49) })).toThrow("Theme ids"); + expect(() => parseMobileThemeFile({ ...themeFile(), name: " " })).toThrow("need a name"); + expect(() => parseMobileThemeFile({ ...themeFile(), name: "a".repeat(49) })).toThrow( + "48 characters", + ); + expect(() => parseMobileThemeFile({ ...themeFile(), appearance: "system" })).toThrow( + '"light" or "dark"', + ); + expect(() => parseMobileThemeFile({ ...themeFile(), colors: [] })).toThrow("colors object"); + }); + + it("matches web id derivation when the name has no slug characters", () => { + expect(parseMobileThemeFile({ ...themeFile(), id: undefined, name: "✨" }).id).toBe( + "custom-theme", + ); + }); + + it("suffixes a reserved id derived from the theme name", () => { + expect(parseMobileThemeFile({ ...themeFile(), id: undefined, name: "T3 Code" }).id).toBe( + "t3-code-2", + ); + }); + + it.each([ + "var(--color-screen)", + "color-mix(in srgb, red, blue)", + "color(rec2020 1 0 0)", + "hwb(1.e2 10% 20%)", + "lab(50% 1. 30)", + "color(srgb 1. .3 .4)", + "oklab(0.7 1e308 -1e308)", + "oklch(0.7 1e308 0)", + ])('rejects the unsupported color syntax "%s" with a role-specific error', (color) => { + expect(() => parseMobileThemeFile({ ...themeFile(), colors: { canvas: color } })).toThrow( + `The color "${color}" for "canvas" is not supported.`, + ); + }); + + it.each([ + "hwb(30 20% 30% /)", + "lab(60% 20 30 /)", + "lch(60% 40 30 /)", + "oklab(0.6 0.1 0.1 /)", + "oklch(0.6 0.1 30 /)", + "color(display-p3 0.2 0.3 0.4 /)", + "color(srgb 0.2 0.3 0.4 /)", + ])('rejects the missing alpha component in "%s"', (color) => { + expect(() => parseMobileThemeFile({ ...themeFile(), colors: { canvas: color } })).toThrow( + `The color "${color}" for "canvas" is not supported.`, + ); + }); + + it.each(["__proto__", "constructor"])( + 'rejects the prototype-inherited named color "%s"', + (color) => { + expect(() => parseMobileThemeFile({ ...themeFile(), colors: { canvas: color } })).toThrow( + `The color "${color}" for "canvas" is not supported.`, + ); + }, + ); + + it("rejects oversized pasted files before parsing", () => { + const source = `${JSON.stringify(themeFile())}${" ".repeat(MAX_MOBILE_THEME_FILE_BYTES)}`; + expect(() => parseMobileThemeFileJson(source)).toThrow("64 KB or smaller"); + }); +}); + +describe("addImportedMobileTheme", () => { + it("deliberately rejects a duplicate id without reordering the library", () => { + const installed = parseMobileThemeFile(themeFile()); + expect(() => addImportedMobileTheme([installed], installed)).toThrow("already installed"); + }); + + it("rejects imports above the installed-theme count cap", () => { + const installed = Array.from({ length: MAX_IMPORTED_MOBILE_THEMES }, (_, index) => + parseMobileThemeFile(themeFile(`theme-${index}`)), + ); + + expect(() => + addImportedMobileTheme(installed, parseMobileThemeFile(themeFile("one-too-many"))), + ).toThrow(`up to ${MAX_IMPORTED_MOBILE_THEMES} imported themes`); + }); + + it("rejects an imported library above the storage byte cap", () => { + const oversized = { + ...parseMobileThemeFile(themeFile()), + metadata: "x".repeat(MAX_IMPORTED_MOBILE_THEMES_BYTES), + }; + + expect(() => addImportedMobileTheme([], oversized)).toThrow("256 KB"); + }); +}); + +describe("sanitizeImportedMobileThemes", () => { + it("keeps the first 20 themes at the installed-theme boundary", () => { + const storedThemes = Array.from({ length: MAX_IMPORTED_MOBILE_THEMES + 1 }, (_, index) => + themeFile(`theme-${index}`), + ); + + expect(sanitizeImportedMobileThemes(storedThemes).map((theme) => theme.id)).toEqual( + Array.from({ length: MAX_IMPORTED_MOBILE_THEMES }, (_, index) => `theme-${index}`), + ); + }); +}); diff --git a/apps/mobile/src/lib/mobileThemeFile.ts b/apps/mobile/src/lib/mobileThemeFile.ts new file mode 100644 index 000000000000..09d8549587a4 --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeFile.ts @@ -0,0 +1,912 @@ +import * as Schema from "effect/Schema"; + +export const MOBILE_THEME_FILE_VERSION = 1 as const; +export const MAX_MOBILE_THEME_FILE_BYTES = 64 * 1024; +export const MAX_IMPORTED_MOBILE_THEMES = 20; +export const MAX_IMPORTED_MOBILE_THEMES_BYTES = 256 * 1024; + +export const PORTABLE_THEME_COLOR_ROLES = [ + "canvas", + "chrome", + "toolbar", + "toolbarForeground", + "toolbarBorder", + "toolbarControl", + "toolbarControlForeground", + "toolbarControlHover", + "surface", + "surfaceRaised", + "surfaceOverlay", + "text", + "textMuted", + "border", + "input", + "focus", + "accent", + "accentForeground", + "secondary", + "secondaryForeground", + "muted", + "mutedForeground", + "placeholder", + "secondaryLabel", + "iconMuted", + "error", + "errorForeground", + "errorSurface", + "warning", + "warningForeground", + "warningSurface", + "update", + "updateForeground", + "updateSurface", + "accentSurface", + "accentSurfaceForeground", + "messageSurface", + "messageForeground", + "messageAction", + "messageActionForeground", + "messageActionHover", + "codeBackground", + "codeForeground", + "sidebar", + "sidebarForeground", + "sidebarMutedForeground", + "sidebarControlSurface", + "sidebarRowHover", + "sidebarRowActive", + "sidebarRowSelected", + "sidebarBorder", + "terminalBackground", + "terminalForeground", + "terminalCursor", + "terminalSelection", + "terminalScrollbar", + "terminalScrollbarHover", +] as const; + +export type PortableThemeColorRole = (typeof PORTABLE_THEME_COLOR_ROLES)[number]; +export type MobileThemeAppearance = "light" | "dark"; +export type PortableThemeColorOverrides = Readonly>>; + +export interface ImportedMobileTheme { + readonly version: typeof MOBILE_THEME_FILE_VERSION; + readonly id: string; + readonly name: string; + readonly appearance: MobileThemeAppearance; + readonly colors: PortableThemeColorOverrides; + readonly variants?: Readonly>>; + readonly collection?: Readonly<{ id: string; label: string }>; + readonly managed?: true; +} + +interface UnparsedMobileThemeRecord { + readonly [key: string]: UnparsedMobileThemeValue; +} + +type UnparsedMobileThemeValue = + | string + | number + | boolean + | null + | undefined + | ReadonlyArray + | UnparsedMobileThemeRecord; + +const UnparsedMobileThemeValueSchema: Schema.Codec = Schema.Union([ + Schema.String, + Schema.Number, + Schema.Boolean, + Schema.Null, + Schema.Undefined, + Schema.Array( + Schema.suspend((): Schema.Codec => UnparsedMobileThemeValueSchema), + ), + Schema.Record( + Schema.String, + Schema.suspend((): Schema.Codec => UnparsedMobileThemeValueSchema), + ), +]); +const decodeUnparsedMobileThemeValue = Schema.decodeUnknownSync(UnparsedMobileThemeValueSchema); +const isUnparsedMobileThemeRecord = Schema.is( + Schema.Record(Schema.String, UnparsedMobileThemeValueSchema), +); +const isString = Schema.is(Schema.String); + +const PORTABLE_THEME_COLOR_ROLE_SET: ReadonlySet = new Set(PORTABLE_THEME_COLOR_ROLES); +const RESERVED_THEME_IDS = new Set([ + "system", + "light", + "dark", + "t3-code", + "t3-chat", + "grove", + "ocean", + "ember", + "iris", + "t3-chat-dark", + "t3-grove", + "t3-ocean", + "t3-ember", + "t3-iris", +]); + +const NAMED_COLORS = { + aliceblue: "#f0f8ff", + antiquewhite: "#faebd7", + aqua: "#00ffff", + aquamarine: "#7fffd4", + azure: "#f0ffff", + beige: "#f5f5dc", + bisque: "#ffe4c4", + black: "#000000", + blanchedalmond: "#ffebcd", + blue: "#0000ff", + blueviolet: "#8a2be2", + brown: "#a52a2a", + burlywood: "#deb887", + cadetblue: "#5f9ea0", + chartreuse: "#7fff00", + chocolate: "#d2691e", + coral: "#ff7f50", + cornflowerblue: "#6495ed", + cornsilk: "#fff8dc", + crimson: "#dc143c", + cyan: "#00ffff", + darkblue: "#00008b", + darkcyan: "#008b8b", + darkgoldenrod: "#b8860b", + darkgray: "#a9a9a9", + darkgreen: "#006400", + darkgrey: "#a9a9a9", + darkkhaki: "#bdb76b", + darkmagenta: "#8b008b", + darkolivegreen: "#556b2f", + darkorange: "#ff8c00", + darkorchid: "#9932cc", + darkred: "#8b0000", + darksalmon: "#e9967a", + darkseagreen: "#8fbc8f", + darkslateblue: "#483d8b", + darkslategray: "#2f4f4f", + darkslategrey: "#2f4f4f", + darkturquoise: "#00ced1", + darkviolet: "#9400d3", + deeppink: "#ff1493", + deepskyblue: "#00bfff", + dimgray: "#696969", + dimgrey: "#696969", + dodgerblue: "#1e90ff", + firebrick: "#b22222", + floralwhite: "#fffaf0", + forestgreen: "#228b22", + fuchsia: "#ff00ff", + gainsboro: "#dcdcdc", + ghostwhite: "#f8f8ff", + gold: "#ffd700", + goldenrod: "#daa520", + gray: "#808080", + green: "#008000", + greenyellow: "#adff2f", + grey: "#808080", + honeydew: "#f0fff0", + hotpink: "#ff69b4", + indianred: "#cd5c5c", + indigo: "#4b0082", + ivory: "#fffff0", + khaki: "#f0e68c", + lavender: "#e6e6fa", + lavenderblush: "#fff0f5", + lawngreen: "#7cfc00", + lemonchiffon: "#fffacd", + lightblue: "#add8e6", + lightcoral: "#f08080", + lightcyan: "#e0ffff", + lightgoldenrodyellow: "#fafad2", + lightgray: "#d3d3d3", + lightgreen: "#90ee90", + lightgrey: "#d3d3d3", + lightpink: "#ffb6c1", + lightsalmon: "#ffa07a", + lightseagreen: "#20b2aa", + lightskyblue: "#87cefa", + lightslategray: "#778899", + lightslategrey: "#778899", + lightsteelblue: "#b0c4de", + lightyellow: "#ffffe0", + lime: "#00ff00", + limegreen: "#32cd32", + linen: "#faf0e6", + magenta: "#ff00ff", + maroon: "#800000", + mediumaquamarine: "#66cdaa", + mediumblue: "#0000cd", + mediumorchid: "#ba55d3", + mediumpurple: "#9370db", + mediumseagreen: "#3cb371", + mediumslateblue: "#7b68ee", + mediumspringgreen: "#00fa9a", + mediumturquoise: "#48d1cc", + mediumvioletred: "#c71585", + midnightblue: "#191970", + mintcream: "#f5fffa", + mistyrose: "#ffe4e1", + moccasin: "#ffe4b5", + navajowhite: "#ffdead", + navy: "#000080", + oldlace: "#fdf5e6", + olive: "#808000", + olivedrab: "#6b8e23", + orange: "#ffa500", + orangered: "#ff4500", + orchid: "#da70d6", + palegoldenrod: "#eee8aa", + palegreen: "#98fb98", + paleturquoise: "#afeeee", + palevioletred: "#db7093", + papayawhip: "#ffefd5", + peachpuff: "#ffdab9", + peru: "#cd853f", + pink: "#ffc0cb", + plum: "#dda0dd", + powderblue: "#b0e0e6", + purple: "#800080", + rebeccapurple: "#663399", + red: "#ff0000", + rosybrown: "#bc8f8f", + royalblue: "#4169e1", + saddlebrown: "#8b4513", + salmon: "#fa8072", + sandybrown: "#f4a460", + seagreen: "#2e8b57", + seashell: "#fff5ee", + sienna: "#a0522d", + silver: "#c0c0c0", + skyblue: "#87ceeb", + slateblue: "#6a5acd", + slategray: "#708090", + slategrey: "#708090", + snow: "#fffafa", + springgreen: "#00ff7f", + steelblue: "#4682b4", + tan: "#d2b48c", + teal: "#008080", + thistle: "#d8bfd8", + tomato: "#ff6347", + transparent: "#00000000", + turquoise: "#40e0d0", + violet: "#ee82ee", + wheat: "#f5deb3", + white: "#ffffff", + whitesmoke: "#f5f5f5", + yellow: "#ffff00", + yellowgreen: "#9acd32", +} as const; +const NAMED_COLOR_BY_NAME = new Map(Object.entries(NAMED_COLORS)); + +function isThemeAppearance(value: UnparsedMobileThemeValue): value is MobileThemeAppearance { + return value === "light" || value === "dark"; +} + +function isThemeId(value: UnparsedMobileThemeValue): value is string { + return isString(value) && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value); +} + +function isThemeLabel(value: UnparsedMobileThemeValue): value is string { + return isString(value) && value.trim().length > 0 && value.trim().length <= 48; +} + +function themeIdFromName(name: string): string { + const normalized = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + return normalized || "custom-theme"; +} + +function parseThemeCollection( + value: UnparsedMobileThemeValue, +): Readonly<{ id: string; label: string }> | undefined { + return isUnparsedMobileThemeRecord(value) && + isString(value.id) && + /^[a-z0-9][a-z0-9.:-]{0,127}$/i.test(value.id) && + isThemeLabel(value.label) + ? { id: value.id, label: value.label.trim() } + : undefined; +} + +function utf8ByteLength(value: string): number { + let bytes = 0; + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + bytes += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + } + return bytes; +} + +function byteToHex(value: number): string { + return Math.round(Math.min(255, Math.max(0, value))) + .toString(16) + .padStart(2, "0"); +} + +function rgbaToHex(red: number, green: number, blue: number, alpha = 1): string { + const opaque = `#${byteToHex(red)}${byteToHex(green)}${byteToHex(blue)}`; + return alpha >= 1 ? opaque : `${opaque}${byteToHex(alpha * 255)}`; +} + +function parseCssNumber(value: string): number | null { + const token = value.trim(); + if (!/^[-+]?(?:\d+(?:\.\d+)?|\.\d+)(?:e[-+]?\d+)?$/i.test(token)) return null; + const number = Number(token); + return Number.isFinite(number) ? number : null; +} + +function parseCssComponent( + value: string, +): { readonly number: number; readonly percent: boolean } | null { + const token = value.trim().toLowerCase(); + if (token === "none") return { number: 0, percent: false }; + const percent = token.endsWith("%"); + const number = parseCssNumber(percent ? token.slice(0, -1) : token); + return number === null ? null : { number, percent }; +} + +function parseClampedAlpha(value: string | undefined): number | null { + if (value === undefined) return 1; + const component = parseCssComponent(value); + if (!component) return null; + return Math.min(1, Math.max(0, component.percent ? component.number / 100 : component.number)); +} + +function parseScaledComponent(value: string, percentageScale: number): number | null { + const component = parseCssComponent(value); + if (!component) return null; + return component.percent ? (component.number * percentageScale) / 100 : component.number; +} + +function parseRgbChannel(value: string): number | null { + const component = parseCssComponent(value); + if (!component) return null; + return component.percent ? (component.number / 100) * 255 : component.number; +} + +function parseAngle(value: string): number | null { + const token = value.trim().toLowerCase(); + const unit = token.match(/(turn|grad|rad|deg)$/)?.[1]; + const number = parseCssNumber(unit ? token.slice(0, -unit.length) : token); + if (number === null) return null; + if (unit === "turn") return number * 360; + if (unit === "grad") return number * 0.9; + if (unit === "rad") return (number * 180) / Math.PI; + if (unit === "deg" || unit === undefined) return number; + return null; +} + +function splitFunctionalColor(value: string): { + readonly channels: ReadonlyArray; + readonly alpha: string | undefined; +} | null { + const slashParts = value.split("/"); + if (slashParts.length > 2) return null; + const body = slashParts[0]?.trim() ?? ""; + const commaSeparated = body.includes(","); + const channels = commaSeparated + ? body.split(",").map((part) => part.trim()) + : body.split(/\s+/).filter(Boolean); + let alpha: string | undefined = slashParts[1]?.trim(); + if (commaSeparated && channels.length === 4 && alpha === undefined) { + alpha = channels.pop(); + } + return { channels, alpha }; +} + +function splitModernFunctionalColor(value: string): { + readonly channels: ReadonlyArray; + readonly alpha: string | undefined; +} | null { + if (value.includes(",")) return null; + return splitFunctionalColor(value); +} + +function parseRgbColor(body: string): string | null { + const parsed = splitFunctionalColor(body); + if (!parsed || parsed.channels.length !== 3) return null; + const channels = parsed.channels.map(parseRgbChannel); + const alpha = parseClampedAlpha(parsed.alpha); + if (!channels.every((channel): channel is number => channel !== null) || alpha === null) { + return null; + } + if (channels.every((channel) => channel >= 0 && channel <= 255)) { + return rgbaToHex(channels[0], channels[1], channels[2], alpha); + } + return oklchToWebHex( + linearSrgbToOklch([ + encodedSrgbChannelToLinear(channels[0] / 255), + encodedSrgbChannelToLinear(channels[1] / 255), + encodedSrgbChannelToLinear(channels[2] / 255), + ]), + alpha, + ); +} + +function hslToRgb(hue: number, saturation: number, lightness: number): [number, number, number] { + const h = ((hue % 360) + 360) % 360; + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const section = h / 60; + const secondary = chroma * (1 - Math.abs((section % 2) - 1)); + const [red, green, blue] = + section < 1 + ? [chroma, secondary, 0] + : section < 2 + ? [secondary, chroma, 0] + : section < 3 + ? [0, chroma, secondary] + : section < 4 + ? [0, secondary, chroma] + : section < 5 + ? [secondary, 0, chroma] + : [chroma, 0, secondary]; + const match = lightness - chroma / 2; + return [(red + match) * 255, (green + match) * 255, (blue + match) * 255]; +} + +function parsePercentage(value: string): number | null { + const component = parseCssComponent(value); + return component?.percent && component.number >= 0 && component.number <= 100 + ? component.number / 100 + : null; +} + +function parseHslColor(body: string): string | null { + const parsed = splitFunctionalColor(body); + if (!parsed || parsed.channels.length !== 3) return null; + const hue = parsed.channels[0].trim() === "none" ? 0 : parseAngle(parsed.channels[0]); + const saturation = parsed.channels[1].trim() === "none" ? 0 : parsePercentage(parsed.channels[1]); + const lightness = parsed.channels[2].trim() === "none" ? 0 : parsePercentage(parsed.channels[2]); + const alpha = parseClampedAlpha(parsed.alpha); + if (hue === null || saturation === null || lightness === null || alpha === null) return null; + const [red, green, blue] = hslToRgb(hue, saturation, lightness); + return rgbaToHex(red, green, blue, alpha); +} + +type OklchColor = Readonly<{ lightness: number; chroma: number; hue: number }>; +type EncodedRgb = readonly [red: number, green: number, blue: number]; +type LinearRgb = readonly [red: number, green: number, blue: number]; +const OKLCH_GAMUT_EPSILON = 0.000001; + +// Mirrors web's Culori -> canonical OKLCH -> sRGB-gamut-mapped hex path locally for React Native. +function encodedSrgbChannelToLinear(channel: number): number { + const absolute = Math.abs(channel); + if (absolute <= 0.04045) return channel / 12.92; + return (Math.sign(channel) || 1) * ((absolute + 0.055) / 1.055) ** 2.4; +} + +function linearChannelToSrgb(channel: number): number { + const value = channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055; + return Math.min(1, Math.max(0, value)) * 255; +} + +function linearSrgbToOklch([red, green, blue]: LinearRgb): OklchColor { + const l = Math.cbrt( + 0.412221469470763 * red + 0.5363325372617348 * green + 0.0514459932675022 * blue, + ); + const m = Math.cbrt( + 0.2119034958178252 * red + 0.6806995506452344 * green + 0.1073969535369406 * blue, + ); + const s = Math.cbrt( + 0.0883024591900564 * red + 0.2817188391361215 * green + 0.6299787016738222 * blue, + ); + const lightness = 0.210454268309314 * l + 0.7936177747023054 * m - 0.0040720430116193 * s; + const a = 1.9779985324311684 * l - 2.4285922420485799 * m + 0.450593709617411 * s; + const b = 0.0259040424655478 * l + 0.7827717124575296 * m - 0.8086757549230774 * s; + return { lightness, chroma: Math.hypot(a, b), hue: (Math.atan2(b, a) * 180) / Math.PI }; +} + +function oklabToOklch(lightness: number, a: number, b: number): OklchColor { + return { + lightness, + chroma: Math.hypot(a, b), + hue: (Math.atan2(b, a) * 180) / Math.PI, + }; +} + +function oklchToLinearSrgb({ lightness, chroma, hue }: OklchColor): LinearRgb { + const hueRadians = (hue * Math.PI) / 180; + const a = chroma * Math.cos(hueRadians); + const b = chroma * Math.sin(hueRadians); + const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ]; +} + +function canonicalColorNumber(value: number, precision: number): number { + const rounded = Math.abs(value) < 10 ** -precision / 2 ? 0 : value; + return Number(rounded.toFixed(precision)); +} + +function canonicalizeOklch(color: OklchColor): OklchColor { + const lightness = Math.min(1, Math.max(0, color.lightness)); + const chroma = Math.max(0, color.chroma); + const hue = chroma < 0.0000005 ? 0 : ((color.hue % 360) + 360) % 360; + return { + lightness: canonicalColorNumber(lightness, 6), + chroma: canonicalColorNumber(chroma, 6), + hue: canonicalColorNumber(hue, 3), + }; +} + +function mapOklchToSrgbGamut(color: OklchColor): OklchColor { + const isInGamut = (chroma: number) => + oklchToLinearSrgb({ ...color, chroma }).every( + (channel) => channel >= -0.0001 && channel <= 1.0001, + ); + if (isInGamut(color.chroma)) return color; + + let low = 0; + let high = color.chroma; + const steps = Math.max( + 1, + Math.ceil(Math.log2(Math.max(color.chroma, OKLCH_GAMUT_EPSILON) / OKLCH_GAMUT_EPSILON)), + ); + for (let step = 0; step < steps; step += 1) { + const middle = (low + high) / 2; + if (isInGamut(middle)) low = middle; + else high = middle; + } + return { ...color, chroma: low }; +} + +function oklchToWebHex(color: OklchColor, alpha: number): string | null { + if (![color.lightness, color.chroma, color.hue, alpha].every(Number.isFinite)) return null; + const canonicalColor = canonicalizeOklch(color); + if (!Number.isFinite(canonicalColor.chroma / OKLCH_GAMUT_EPSILON)) return null; + const canonicalAlpha = canonicalColorNumber(Math.min(1, Math.max(0, alpha)), 4); + const [red, green, blue] = oklchToLinearSrgb(mapOklchToSrgbGamut(canonicalColor)); + return rgbaToHex( + linearChannelToSrgb(red), + linearChannelToSrgb(green), + linearChannelToSrgb(blue), + canonicalAlpha, + ); +} + +function parseHwbColor(body: string): string | null { + const parsed = splitModernFunctionalColor(body); + if (!parsed || parsed.channels.length !== 3) return null; + const hue = parsed.channels[0].trim() === "none" ? 0 : parseAngle(parsed.channels[0]); + let whiteness = parseScaledComponent(parsed.channels[1], 100); + let blackness = parseScaledComponent(parsed.channels[2], 100); + const alpha = parseClampedAlpha(parsed.alpha); + if (hue === null || whiteness === null || blackness === null || alpha === null) return null; + whiteness /= 100; + blackness /= 100; + if (whiteness + blackness > 1) { + const sum = whiteness + blackness; + whiteness /= sum; + blackness /= sum; + } + + const normalizedHue = ((hue % 360) + 360) % 360; + const saturation = blackness === 1 ? 1 : 1 - whiteness / (1 - blackness); + const value = 1 - blackness; + const secondary = Math.abs(((normalizedHue / 60) % 2) - 1); + const sector = Math.floor(normalizedHue / 60); + const low = value * (1 - saturation); + const slope = value * (1 - saturation * secondary); + const encodedRgb: EncodedRgb = + sector === 0 + ? [value, slope, low] + : sector === 1 + ? [slope, value, low] + : sector === 2 + ? [low, value, slope] + : sector === 3 + ? [low, slope, value] + : sector === 4 + ? [slope, low, value] + : [value, low, slope]; + const rgb: LinearRgb = [ + encodedSrgbChannelToLinear(encodedRgb[0]), + encodedSrgbChannelToLinear(encodedRgb[1]), + encodedSrgbChannelToLinear(encodedRgb[2]), + ]; + return oklchToWebHex(linearSrgbToOklch(rgb), alpha); +} + +function labToLinearSrgb(lightness: number, a: number, b: number): LinearRgb { + const kappa = 29 ** 3 / 3 ** 3; + const epsilon = 6 ** 3 / 29 ** 3; + const transform = (value: number) => + value ** 3 > epsilon ? value ** 3 : (116 * value - 16) / kappa; + const fy = (lightness + 16) / 116; + const x = transform(a / 500 + fy) * (0.3457 / 0.3585); + const y = transform(fy); + const z = transform(fy - b / 200) * ((1 - 0.3457 - 0.3585) / 0.3585); + return [ + x * 3.1341359569958707 - y * 1.6173863321612538 - z * 0.4906619460083532, + x * -0.978795502912089 + y * 1.916254567259524 + z * 0.03344273116131949, + x * 0.07195537988411677 - y * 0.2289768264158322 + z * 1.405386058324125, + ]; +} + +function parseLabColor(body: string): string | null { + const parsed = splitModernFunctionalColor(body); + if (!parsed || parsed.channels.length !== 3) return null; + const lightness = parseScaledComponent(parsed.channels[0], 100); + const a = parseScaledComponent(parsed.channels[1], 125); + const b = parseScaledComponent(parsed.channels[2], 125); + const alpha = parseClampedAlpha(parsed.alpha); + if (lightness === null || a === null || b === null || alpha === null) return null; + return oklchToWebHex( + linearSrgbToOklch(labToLinearSrgb(Math.min(100, Math.max(0, lightness)), a, b)), + alpha, + ); +} + +function parseLchColor(body: string): string | null { + const parsed = splitModernFunctionalColor(body); + if (!parsed || parsed.channels.length !== 3) return null; + const lightness = parseScaledComponent(parsed.channels[0], 100); + const chroma = parseScaledComponent(parsed.channels[1], 150); + const hue = parsed.channels[2].trim() === "none" ? 0 : parseAngle(parsed.channels[2]); + const alpha = parseClampedAlpha(parsed.alpha); + if (lightness === null || chroma === null || hue === null || alpha === null) return null; + const hueRadians = (hue * Math.PI) / 180; + return oklchToWebHex( + linearSrgbToOklch( + labToLinearSrgb( + Math.min(100, Math.max(0, lightness)), + Math.max(0, chroma) * Math.cos(hueRadians), + Math.max(0, chroma) * Math.sin(hueRadians), + ), + ), + alpha, + ); +} + +function parseOklabColor(body: string): string | null { + const parsed = splitModernFunctionalColor(body); + if (!parsed || parsed.channels.length !== 3) return null; + const lightness = parseScaledComponent(parsed.channels[0], 1); + const a = parseScaledComponent(parsed.channels[1], 0.4); + const b = parseScaledComponent(parsed.channels[2], 0.4); + const alpha = parseClampedAlpha(parsed.alpha); + if (lightness === null || a === null || b === null || alpha === null) return null; + return oklchToWebHex(oklabToOklch(Math.min(1, Math.max(0, lightness)), a, b), alpha); +} + +function displayP3ToLinearSrgb(red: number, green: number, blue: number): LinearRgb { + const p3Red = encodedSrgbChannelToLinear(red); + const p3Green = encodedSrgbChannelToLinear(green); + const p3Blue = encodedSrgbChannelToLinear(blue); + const x = 0.486570948648216 * p3Red + 0.265667693169093 * p3Green + 0.1982172852343625 * p3Blue; + const y = 0.2289745640697487 * p3Red + 0.6917385218365062 * p3Green + 0.079286914093745 * p3Blue; + const z = 0.0451133818589026 * p3Green + 1.043944368900976 * p3Blue; + return [ + x * 3.2409699419045226 - y * 1.5373831775700939 - z * 0.4986107602930034, + x * -0.9692436362808796 + y * 1.8759675015077204 + z * 0.0415550574071756, + x * 0.0556300796969936 - y * 0.2039769588889765 + z * 1.0569715142428784, + ]; +} + +function parseColorFunction(body: string): string | null { + const parsed = splitModernFunctionalColor(body); + if (!parsed || parsed.channels.length !== 4) return null; + const [profile, ...channelTokens] = parsed.channels; + if (profile !== "display-p3" && profile !== "srgb") return null; + const channels = channelTokens.map((channel) => parseScaledComponent(channel, 1)); + const alpha = parseClampedAlpha(parsed.alpha); + if (!channels.every((channel): channel is number => channel !== null) || alpha === null) { + return null; + } + const [red, green, blue] = channels; + const linearRgb: LinearRgb = + profile === "display-p3" + ? displayP3ToLinearSrgb(red, green, blue) + : [ + encodedSrgbChannelToLinear(red), + encodedSrgbChannelToLinear(green), + encodedSrgbChannelToLinear(blue), + ]; + return oklchToWebHex(linearSrgbToOklch(linearRgb), alpha); +} + +function parseOklchColor(body: string): string | null { + const parsed = splitModernFunctionalColor(body); + if (!parsed || parsed.channels.length !== 3) return null; + const lightness = parseScaledComponent(parsed.channels[0], 1); + const chroma = parseScaledComponent(parsed.channels[1], 0.4); + const hue = parsed.channels[2].trim() === "none" ? 0 : parseAngle(parsed.channels[2]); + const alpha = parseClampedAlpha(parsed.alpha); + if (lightness === null || chroma === null || hue === null || alpha === null) return null; + return oklchToWebHex( + { + lightness: Math.min(1, Math.max(0, lightness)), + chroma: Math.max(0, chroma), + hue, + }, + alpha, + ); +} + +export function normalizeMobileThemeColorLiteral(value: UnparsedMobileThemeValue): string | null { + if (!isString(value)) return null; + const input = value.trim().toLowerCase(); + const named = NAMED_COLOR_BY_NAME.get(input); + if (named) return named; + + const hex = input.match(/^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i)?.[1]; + if (hex) { + return hex.length <= 4 + ? `#${[...hex].map((character) => character.repeat(2)).join("")}` + : `#${hex}`; + } + + const functional = input.match(/^([a-z]+)\(([\s\S]*)\)$/); + if (!functional) return null; + const [, name, body] = functional; + if (name === "rgb" || name === "rgba") return parseRgbColor(body); + if (name === "hsl" || name === "hsla") return parseHslColor(body); + if (name === "hwb") return parseHwbColor(body); + if (name === "lab") return parseLabColor(body); + if (name === "lch") return parseLchColor(body); + if (name === "oklab") return parseOklabColor(body); + if (name === "oklch") return parseOklchColor(body); + if (name === "color") return parseColorFunction(body); + return null; +} + +function isPortableThemeColorRole(value: string): value is PortableThemeColorRole { + return PORTABLE_THEME_COLOR_ROLE_SET.has(value); +} + +function parseThemeColorOverrides(value: UnparsedMobileThemeValue) { + if (!isUnparsedMobileThemeRecord(value)) throw new Error("Theme colors must be objects."); + + const overrides: Partial> = {}; + for (const [role, color] of Object.entries(value)) { + if (!isPortableThemeColorRole(role)) { + throw new Error(`"${role}" is not a supported theme color role.`); + } + const normalized = normalizeMobileThemeColorLiteral(color); + if (!normalized) { + throw new Error( + `The color "${String(color)}" for "${role}" is not supported. Mobile supports hex, named colors, rgb()/rgba(), hsl()/hsla(), hwb(), lab(), lch(), oklab(), oklch(), and color(display-p3 ...)/color(srgb ...).`, + ); + } + overrides[role] = normalized; + } + if (Object.keys(overrides).length === 0) { + throw new Error("Add at least one color role to the theme file."); + } + return overrides; +} + +export function parseMobileThemeFile( + value: UnparsedMobileThemeValue | ImportedMobileTheme, +): ImportedMobileTheme { + if (!isUnparsedMobileThemeRecord(value)) { + throw new Error("Theme files must contain a JSON object."); + } + if (value.version !== MOBILE_THEME_FILE_VERSION) { + throw new Error( + `This theme file uses an unsupported version. Expected ${MOBILE_THEME_FILE_VERSION}.`, + ); + } + + const name = value.name; + const appearance = value.appearance; + if (!isThemeLabel(name)) throw new Error("Theme files need a name (48 characters or fewer)."); + if (!isThemeAppearance(appearance)) { + throw new Error('Theme files need an appearance of "light" or "dark".'); + } + if (!isUnparsedMobileThemeRecord(value.colors)) { + throw new Error("Theme files need a colors object."); + } + + const derivedId = value.id === undefined ? themeIdFromName(name) : null; + const id = + derivedId !== null && RESERVED_THEME_IDS.has(derivedId) + ? `${derivedId}-2` + : (derivedId ?? value.id); + if (!isThemeId(id)) { + throw new Error("Theme ids may only contain lowercase letters, numbers, and hyphens."); + } + if (RESERVED_THEME_IDS.has(id)) throw new Error(`The theme id "${id}" is reserved.`); + + const colors = parseThemeColorOverrides(value.colors); + const variants: Partial> = {}; + if (value.variants !== undefined) { + if (!isUnparsedMobileThemeRecord(value.variants)) { + throw new Error("Theme variants must be an object."); + } + for (const [variantAppearance, variantColors] of Object.entries(value.variants)) { + if (!isThemeAppearance(variantAppearance)) { + throw new Error('Theme variants may only be named "light" or "dark".'); + } + if (variantAppearance === appearance) { + throw new Error(`Theme variants must not repeat the base appearance "${appearance}".`); + } + variants[variantAppearance] = parseThemeColorOverrides(variantColors); + } + } + + const collection = parseThemeCollection(value.collection); + if (value.collection !== undefined && !collection) { + throw new Error("Theme collections need a valid id and label."); + } + + const theme = { + version: MOBILE_THEME_FILE_VERSION, + id, + name: name.trim(), + appearance, + colors, + }; + if (Object.keys(variants).length > 0) Object.assign(theme, { variants }); + if (collection) Object.assign(theme, { collection }); + if (value.managed === true) Object.assign(theme, { managed: true }); + return theme; +} + +export function parseMobileThemeFileJson(value: string): ImportedMobileTheme { + if (utf8ByteLength(value) > MAX_MOBILE_THEME_FILE_BYTES) { + throw new Error("Theme files must be 64 KB or smaller."); + } + let parsed: UnparsedMobileThemeValue; + try { + parsed = decodeUnparsedMobileThemeValue(JSON.parse(value)); + } catch { + throw new Error("Theme files must contain valid JSON."); + } + return parseMobileThemeFile(parsed); +} + +export function addImportedMobileTheme( + current: ReadonlyArray, + theme: ImportedMobileTheme, +): ReadonlyArray { + if (current.some((candidate) => candidate.id === theme.id)) { + throw new Error(`A theme named "${theme.name}" is already installed.`); + } + if (current.length >= MAX_IMPORTED_MOBILE_THEMES) { + throw new Error(`Mobile supports up to ${MAX_IMPORTED_MOBILE_THEMES} imported themes.`); + } + const next = [...current, theme]; + if (utf8ByteLength(JSON.stringify(next)) > MAX_IMPORTED_MOBILE_THEMES_BYTES) { + throw new Error("Imported themes may use up to 256 KB of device storage."); + } + return next; +} + +export function sanitizeImportedMobileThemes( + value: UnparsedMobileThemeValue | ReadonlyArray, +): ReadonlyArray { + if (!Array.isArray(value)) return []; + const themes: ImportedMobileTheme[] = []; + let libraryBytes = 2; + for (const candidate of value) { + if (themes.length >= MAX_IMPORTED_MOBILE_THEMES) break; + try { + const theme = parseMobileThemeFile(candidate); + if (themes.some((existing) => existing.id === theme.id)) continue; + const separatorBytes = themes.length === 0 ? 0 : 1; + const nextLibraryBytes = + libraryBytes + separatorBytes + utf8ByteLength(JSON.stringify(theme)); + if (nextLibraryBytes > MAX_IMPORTED_MOBILE_THEMES_BYTES) continue; + themes.push(theme); + libraryBytes = nextLibraryBytes; + } catch { + continue; + } + } + return themes; +} diff --git a/apps/mobile/src/lib/splashConfig.test.ts b/apps/mobile/src/lib/splashConfig.test.ts new file mode 100644 index 000000000000..3e8d7cedb389 --- /dev/null +++ b/apps/mobile/src/lib/splashConfig.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { createSchemeNeutralSplashConfig, SCHEME_NEUTRAL_SPLASH_BACKGROUND } from "./splashConfig"; + +describe("scheme-neutral splash config", () => { + it("uses one appearance-independent splash variant", () => { + const splash = createSchemeNeutralSplashConfig("./icon.png"); + + expect(splash).toEqual({ + image: "./icon.png", + resizeMode: "contain", + backgroundColor: SCHEME_NEUTRAL_SPLASH_BACKGROUND, + imageWidth: 220, + }); + expect(splash).not.toHaveProperty("dark"); + }); +}); diff --git a/apps/mobile/src/lib/splashConfig.ts b/apps/mobile/src/lib/splashConfig.ts new file mode 100644 index 000000000000..7c58bb94f7c1 --- /dev/null +++ b/apps/mobile/src/lib/splashConfig.ts @@ -0,0 +1,10 @@ +export const SCHEME_NEUTRAL_SPLASH_BACKGROUND = "#737373"; + +export function createSchemeNeutralSplashConfig(image: string) { + return { + image, + resizeMode: "contain" as const, + backgroundColor: SCHEME_NEUTRAL_SPLASH_BACKGROUND, + imageWidth: 220, + }; +} diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index a97252c7b72b..625d615f11b0 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -1,5 +1,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { removeImportedMobileTheme } from "./mobileTheme"; +import { parseMobileThemeFile } from "./mobileThemeFile"; const mocks = vi.hoisted(() => { const values = new Map(); @@ -172,9 +174,168 @@ describe("mobile connection storage", () => { it("loads legacy preferences when SQLite is unavailable", async () => { mocks.setDatabaseFailures(true, true); - await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 })); + await mocks.setItemAsync( + "t3code.preferences", + JSON.stringify({ baseFontSize: 17, planModeEnabled: true }), + ); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17, planModeEnabled: true }); + }); + + it("drops non-canonical synced preference stamps used for lexical LWW ordering", async () => { + mocks.setPreferencesJson( + JSON.stringify({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAt: "2026-08-14T12:00:00Z", + }), + 10, + ); + + await expect(loadPreferences()).resolves.toEqual({ planModeEnabled: true }); + }); + + it("loads per-field synced preference stamps and legacy aggregate stamps", async () => { + mocks.setPreferencesJson( + JSON.stringify({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + syncedClientPreferencesUpdatedAt: "2026-08-14T11:00:00.000Z", + }), + 10, + ); + + await expect(loadPreferences()).resolves.toMatchObject({ + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + syncedClientPreferencesUpdatedAt: "2026-08-14T11:00:00.000Z", + }); + }); + + it("preserves unrelated field stamps when saving a partial stamp map", async () => { + mocks.setPreferencesJson( + JSON.stringify({ + syncedClientPreferencesUpdatedAtByField: { + appearanceMode: "2026-08-14T13:00:00.000Z", + }, + }), + 10, + ); + + await expect( + savePreferencesPatch({ + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + }), + ).resolves.toMatchObject({ + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + appearanceMode: "2026-08-14T13:00:00.000Z", + }, + }); + }); + + it("persists device-local appearance preferences", async () => { + await expect( + savePreferencesPatch({ appearanceMode: "dark", themeId: "ocean" }), + ).resolves.toEqual({ appearanceMode: "dark", themeId: "ocean" }); + + await expect(loadPreferences()).resolves.toEqual({ + appearanceMode: "dark", + themeId: "ocean", + }); + + await expect(savePreferencesPatch({ appearanceMode: "system" })).resolves.toEqual({ + appearanceMode: "system", + themeId: "ocean", + }); + await expect(loadPreferences()).resolves.toEqual({ + appearanceMode: "system", + themeId: "ocean", + }); + }); + + it("drops invalid appearance preferences while loading", async () => { + mocks.setPreferencesJson( + JSON.stringify({ appearanceMode: "sepia", themeId: "custom-theme" }), + 10, + ); + + await expect(loadPreferences()).resolves.toEqual({}); + }); + + it("round-trips validated imported themes and their selection", async () => { + const importedTheme = parseMobileThemeFile({ + version: 1, + id: "northern-lights", + name: "Northern Lights", + appearance: "dark", + colors: { canvas: "#0f172a", text: "#f8fafc", accent: "#60a5fa" }, + }); + + await expect( + savePreferencesPatch({ importedThemes: [importedTheme], themeId: importedTheme.id }), + ).resolves.toEqual({ importedThemes: [importedTheme], themeId: importedTheme.id }); + await expect(loadPreferences()).resolves.toEqual({ + importedThemes: [importedTheme], + themeId: importedTheme.id, + }); + }); + + it("persists the default fallback when removing a selected imported theme", async () => { + const importedTheme = parseMobileThemeFile({ + version: 1, + id: "northern-lights", + name: "Northern Lights", + appearance: "dark", + colors: { canvas: "#0f172a" }, + }); + await savePreferencesPatch({ importedThemes: [importedTheme], themeId: importedTheme.id }); + const patch = removeImportedMobileTheme([importedTheme], importedTheme.id, importedTheme.id); + if (!patch) throw new Error("Expected the imported theme to be removed"); + + await savePreferencesPatch(patch); + + await expect(loadPreferences()).resolves.toEqual({ themeId: "t3-code" }); + }); + + it("preserves the selection when removing a different imported theme", async () => { + const first = parseMobileThemeFile({ + version: 1, + id: "first-theme", + name: "First Theme", + appearance: "light", + colors: { canvas: "#ffffff" }, + }); + const second = parseMobileThemeFile({ + version: 1, + id: "second-theme", + name: "Second Theme", + appearance: "dark", + colors: { canvas: "#000000" }, + }); + await savePreferencesPatch({ importedThemes: [first, second], themeId: second.id }); + const patch = removeImportedMobileTheme([first, second], first.id, second.id); + if (!patch) throw new Error("Expected the imported theme to be removed"); + + await savePreferencesPatch(patch); + + await expect(loadPreferences()).resolves.toEqual({ + importedThemes: [second], + themeId: second.id, + }); + }); + + it("drops a corrupt imported-theme library and its stale selection", async () => { + mocks.setPreferencesJson( + JSON.stringify({ importedThemes: { invalid: true }, themeId: "northern-lights" }), + 10, + ); - await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); + await expect(loadPreferences()).resolves.toEqual({}); }); it("falls back to secure storage when SQLite cannot save preferences", async () => { diff --git a/apps/mobile/src/native/sheet-surface.ts b/apps/mobile/src/native/sheet-surface.ts index eb2e8a8d1898..0d12aa460cc5 100644 --- a/apps/mobile/src/native/sheet-surface.ts +++ b/apps/mobile/src/native/sheet-surface.ts @@ -15,14 +15,6 @@ export const NATIVE_SHEET_SURFACE_CONTENT_STYLE: ViewStyle | undefined = ? undefined : { backgroundColor: NATIVE_SHEET_SURFACE_COLOR }; -/** - * Paint the adaptive background on the presented screen itself. Nested stacks - * can stay transparent over this single surface, so a push never exposes an - * unpainted form-sheet host behind the moving child view controllers. - */ export const FORM_SHEET_PRESENTATION_OPTIONS = { presentation: "formSheet" as const, - ...(NATIVE_SHEET_SURFACE_CONTENT_STYLE === undefined - ? null - : { contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE }), }; diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 1da9c9f7ac6f..afc518cbd1f2 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,16 +5,34 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { + type SidebarProjectGroupingMode, + SyncedClientPreferencesUpdatedAt, + SyncedClientPreferencesUpdatedAtByField, + type SyncedClientPreferencesUpdatedAtByField as SyncedClientPreferencesUpdatedAtByFieldValue, +} from "@t3tools/contracts"; +import { + isMobileAppearanceMode, + isMobileThemeId, + type MobileAppearanceMode, +} from "../lib/mobileTheme"; +import { sanitizeImportedMobileThemes, type ImportedMobileTheme } from "../lib/mobileThemeFile"; import * as MobileDatabase from "./mobile-database"; import * as MobileSecureStorage from "./mobile-secure-storage"; import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; +const isSyncedClientPreferencesUpdatedAt = Schema.is(SyncedClientPreferencesUpdatedAt); +const isSyncedClientPreferencesUpdatedAtByField = Schema.is( + SyncedClientPreferencesUpdatedAtByField, +); export interface Preferences { + readonly appearanceMode?: MobileAppearanceMode; + readonly themeId?: string; + readonly importedThemes?: ReadonlyArray; readonly liveActivitiesEnabled?: boolean; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; @@ -27,6 +45,10 @@ export interface Preferences { readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; readonly autoSettleOnMerge?: boolean; + readonly planModeEnabled?: boolean; + readonly syncedClientPreferencesUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByFieldValue; + /** @deprecated Aggregate-clock cache retained for older persisted blobs. */ + readonly syncedClientPreferencesUpdatedAt?: string; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -35,8 +57,6 @@ export interface Preferences { * default flat list — see `resolveThreadListV2Enabled`. */ readonly legacyThreadListEnabled?: boolean; - /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ - readonly planModeEnabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -78,6 +98,9 @@ export class MobilePreferencesStore extends Context.Service< function sanitizePreferences(parsed: Preferences): Preferences { const preferences: { + appearanceMode?: MobileAppearanceMode; + themeId?: string; + importedThemes?: ReadonlyArray; liveActivitiesEnabled?: boolean; baseFontSize?: number; terminalFontSize?: number | null; @@ -89,10 +112,20 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; autoSettleOnMerge?: boolean; - legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; + syncedClientPreferencesUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByFieldValue; + syncedClientPreferencesUpdatedAt?: string; + legacyThreadListEnabled?: boolean; } = {}; + if (isMobileAppearanceMode(parsed.appearanceMode)) { + preferences.appearanceMode = parsed.appearanceMode; + } + const importedThemes = sanitizeImportedMobileThemes(parsed.importedThemes); + if (importedThemes.length > 0) preferences.importedThemes = importedThemes; + if (isMobileThemeId(parsed.themeId, importedThemes)) { + preferences.themeId = parsed.themeId; + } if (typeof parsed.liveActivitiesEnabled === "boolean") { preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; } @@ -130,12 +163,22 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.autoSettleOnMerge === "boolean") { preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; } - if (typeof parsed.legacyThreadListEnabled === "boolean") { - preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; - } if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if ( + parsed.syncedClientPreferencesUpdatedAtByField !== undefined && + isSyncedClientPreferencesUpdatedAtByField(parsed.syncedClientPreferencesUpdatedAtByField) + ) { + preferences.syncedClientPreferencesUpdatedAtByField = + parsed.syncedClientPreferencesUpdatedAtByField; + } + if (isSyncedClientPreferencesUpdatedAt(parsed.syncedClientPreferencesUpdatedAt)) { + preferences.syncedClientPreferencesUpdatedAt = parsed.syncedClientPreferencesUpdatedAt; + } + if (typeof parsed.legacyThreadListEnabled === "boolean") { + preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; + } return preferences; } @@ -319,7 +362,19 @@ export const make = Effect.fn("MobilePreferencesStore.make")(function* () { try: () => transform(current), catch: (cause) => new MobilePreferencesSaveError({ cause }), }); - const next: Preferences = { ...current, ...patch }; + let next: Preferences = { + ...current, + ...patch, + }; + if (patch.syncedClientPreferencesUpdatedAtByField !== undefined) { + next = { + ...next, + syncedClientPreferencesUpdatedAtByField: { + ...current.syncedClientPreferencesUpdatedAtByField, + ...patch.syncedClientPreferencesUpdatedAtByField, + }, + }; + } const payload = yield* encode(PREFERENCES_KEY, next); yield* saveJson(payload); return next; diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index c53594eb2306..19596f23e34b 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -124,6 +124,71 @@ describe("mobile preferences state", () => { }), ); + it.effect("discards a reconciliation response after a newer local preference write", () => + Effect.gen(function* () { + const savePatch = vi.fn((patch: Partial) => Effect.succeed(patch)); + const state = makePreferencesState({ + load: Effect.succeed({ + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + appearanceMode: "2026-08-14T13:00:00.000Z", + }, + syncedClientPreferencesUpdatedAt: "2026-08-14T12:00:00.000Z", + }), + savePatch, + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + const unmountReconciliation = registry.mount(state.persistReconciledPreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:01:00.000Z", + }, + }); + registry.set(state.updatePreferencesAtom, { + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:02:00.000Z", + appearanceMode: "2026-08-14T13:00:00.000Z", + }, + }); + registry.set(state.persistReconciledPreferencesAtom, { + expectedUpdatedAtByField: { planModeEnabled: "2026-08-14T12:01:00.000Z" }, + patch: { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:01:00.000Z", + }, + }, + }); + + expect( + Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom))), + ).toMatchObject({ + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:02:00.000Z", + }, + }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(savePatch).toHaveBeenCalledTimes(2); + }), + ); + + unmountReconciliation(); + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + it.effect("falls back to empty preferences when secure storage cannot be read", () => Effect.gen(function* () { const state = makePreferencesState({ @@ -269,4 +334,86 @@ describe("mobile preferences state", () => { registry.dispose(); }), ); + + it.effect("does not persist a failed preference stamp through a concurrent write", () => + Effect.gen(function* () { + const failedStamp = "2026-08-15T12:01:00.000Z"; + const firstSaveStarted = deferred(); + const releaseFirstSave = deferred(); + let saveCount = 0; + let persisted: Preferences = { + baseFontSize: 16, + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-15T12:00:00.000Z", + }, + }; + const state = makePreferencesState({ + load: Effect.succeed(persisted), + savePatch: (patch) => + Effect.gen(function* () { + saveCount += 1; + if (saveCount === 1) { + yield* Effect.sync(firstSaveStarted.resolve); + yield* Effect.promise(() => releaseFirstSave.promise); + return yield* Effect.fail( + new MobilePreferencesSaveError({ cause: new Error("write failed") }), + ); + } + let next = { + ...persisted, + ...patch, + }; + if (patch.syncedClientPreferencesUpdatedAtByField !== undefined) { + next = { + ...next, + syncedClientPreferencesUpdatedAtByField: { + ...persisted.syncedClientPreferencesUpdatedAtByField, + ...patch.syncedClientPreferencesUpdatedAtByField, + }, + }; + } + persisted = next; + return persisted; + }), + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { planModeEnabled: failedStamp }, + }); + yield* Effect.promise(() => firstSaveStarted.promise); + registry.set(state.updatePreferencesAtom, { + baseFontSize: 18, + syncedClientPreferencesUpdatedAtByField: { + appearanceMode: "2026-08-15T12:02:00.000Z", + }, + }); + releaseFirstSave.resolve(); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect(saveCount).toBe(2); + expect(persisted).toEqual({ + baseFontSize: 18, + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-15T12:00:00.000Z", + appearanceMode: "2026-08-15T12:02:00.000Z", + }, + }); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); }); diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index d173cf55be5a..5ee21dbc9940 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -1,5 +1,9 @@ import * as Effect from "effect/Effect"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { + SYNCED_CLIENT_PREFERENCE_FIELDS, + type SyncedClientPreferencesUpdatedAtByField, +} from "@t3tools/contracts"; import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; import * as Runtime from "../lib/runtime"; @@ -15,6 +19,11 @@ interface OptimisticPreferences { readonly versions: Partial>; } +export interface ReconciledPreferencesPatch { + readonly expectedUpdatedAtByField: SyncedClientPreferencesUpdatedAtByField; + readonly patch: Partial; +} + /** * Owns the device preference blob for the lifetime of the app registry. * Optimistic patches are kept separately so writes made while persistence is @@ -58,14 +67,27 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime, get) => { + const preferences = get(preferencesAtom); + const normalizedPatch: Partial = + patch.syncedClientPreferencesUpdatedAtByField === undefined + ? patch + : { + ...patch, + syncedClientPreferencesUpdatedAtByField: { + ...(AsyncResult.isSuccess(preferences) + ? preferences.value.syncedClientPreferencesUpdatedAtByField + : undefined), + ...patch.syncedClientPreferencesUpdatedAtByField, + }, + }; const version = ++nextPatchVersion; const current = get(optimisticPatchAtom); const versions = { ...current.versions }; - for (const key of Object.keys(patch) as Array) { + for (const key of Object.keys(normalizedPatch) as Array) { versions[key] = version; } get.set(optimisticPatchAtom, { - values: { ...current.values, ...patch }, + values: { ...current.values, ...normalizedPatch }, versions, }); return MobilePreferencesStore.pipe( @@ -76,7 +98,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime; const currentVersions = { ...optimistic.versions } as Record; - for (const key of Object.keys(patch) as Array) { + for (const key of Object.keys(normalizedPatch) as Array) { if (optimistic.versions[key] === version) { delete values[key]; delete currentVersions[key]; @@ -93,7 +115,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime; const currentVersions = { ...optimistic.versions } as Record; - for (const key of Object.keys(patch) as Array) { + for (const key of Object.keys(normalizedPatch) as Array) { if (optimistic.versions[key] === version) { delete values[key]; delete currentVersions[key]; @@ -114,7 +136,28 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime { + const current = get(preferencesAtom); + if ( + !AsyncResult.isSuccess(current) || + SYNCED_CLIENT_PREFERENCE_FIELDS.some((field) => { + const expectedUpdatedAt = input.expectedUpdatedAtByField[field]; + return ( + expectedUpdatedAt !== undefined && + (current.value.syncedClientPreferencesUpdatedAtByField?.[field] ?? + current.value.syncedClientPreferencesUpdatedAt) !== expectedUpdatedAt + ); + }) + ) { + return Effect.void; + } + get.set(updatePreferencesAtom, input.patch); + return Effect.void; + }) + .pipe(Atom.withLabel("mobile:preferences:persist-reconciled")); + + return { preferencesAtom, updatePreferencesAtom, persistReconciledPreferencesAtom } as const; } const mobilePreferencesRuntime = Atom.runtime(Runtime.runtimeContextLayer); @@ -122,3 +165,5 @@ export const mobilePreferencesState = createMobilePreferencesState(mobilePrefere export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom; export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom; +export const persistReconciledMobilePreferencesAtom = + mobilePreferencesState.persistReconciledPreferencesAtom; diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts new file mode 100644 index 000000000000..3a06630b2666 --- /dev/null +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -0,0 +1,614 @@ +import { + EnvironmentId, + getSyncedClientPreferenceUpdatedAt, + nextSyncedClientPreferencesUpdatedAt, + SYNCED_CLIENT_PREFERENCE_FIELDS, + type PatchSyncedClientPreferencesRequest, + type SyncedClientPreferenceField, + type SyncedClientPreferences, + type SyncedClientPreferencesPatch, + type SyncedClientPreferencesUpdatedAtByField, +} from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import * as Schema from "effect/Schema"; + +import type { Preferences } from "../persistence/mobile-preferences"; + +const SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; +const SYNCED_CLIENT_PREFERENCE_RECONCILIATION_MAX_ATTEMPTS = 3; +const isString = Schema.is(Schema.String); + +type SyncedClientPreferenceRetryScheduler = (retry: () => void, delayMs: number) => () => void; + +const scheduleSyncedClientPreferenceRetry: SyncedClientPreferenceRetryScheduler = ( + retry, + delayMs, +) => { + const timer = setTimeout(retry, delayMs); + return () => clearTimeout(timer); +}; + +export interface EnvironmentPreferenceState { + readonly environmentId: EnvironmentId; + readonly preferences: SyncedClientPreferences | undefined; + readonly canPatch?: boolean; +} + +export interface SyncedClientPreferencePatchTarget { + readonly environmentId: EnvironmentId; + readonly input: PatchSyncedClientPreferencesRequest; +} + +export interface LocalSyncedClientPreferencesState { + readonly values: Partial; + readonly updatedAtByField?: SyncedClientPreferencesUpdatedAtByField; + readonly legacyUpdatedAt?: string; +} + +export interface LocalSyncedClientPreferencesPatch { + readonly values: SyncedClientPreferencesPatch; + readonly updatedAtByField: SyncedClientPreferencesUpdatedAtByField; +} + +interface EnvironmentPreferenceCandidate { + readonly source: EnvironmentId; + readonly updatedAt: string; +} + +function compareEnvironmentPreferenceCandidates( + left: EnvironmentPreferenceCandidate, + right: EnvironmentPreferenceCandidate, +): number { + return left.updatedAt.localeCompare(right.updatedAt) || left.source.localeCompare(right.source); +} + +const PlanModePreferenceReconciliationIdentity = Schema.Tuple([ + Schema.String, + EnvironmentId, + Schema.Boolean, +]); +type PlanModePreferenceReconciliationIdentity = + typeof PlanModePreferenceReconciliationIdentity.Type; +const decodePlanModePreferenceReconciliationIdentity = Schema.decodeUnknownSync( + Schema.fromJsonString(PlanModePreferenceReconciliationIdentity), +); + +function parsePlanModePreferenceReconciliationKey( + key: string, +): PlanModePreferenceReconciliationIdentity | undefined { + return key === "" ? undefined : decodePlanModePreferenceReconciliationIdentity(key); +} + +function comparePlanModePreferenceReconciliationIdentities( + left: PlanModePreferenceReconciliationIdentity | undefined, + right: PlanModePreferenceReconciliationIdentity | undefined, +): number { + if (left === undefined) return right === undefined ? 0 : -1; + if (right === undefined) return 1; + return compareEnvironmentPreferenceCandidates( + { updatedAt: left[0], source: left[1] }, + { updatedAt: right[0], source: right[1] }, + ); +} + +export function createPlanModePreferenceReconciliationKey( + states: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly connectionState: EnvironmentConnectionPhase; + readonly shellStatus: EnvironmentShellStatus; + readonly preferences: SyncedClientPreferences | undefined; + }>, +): string { + // Readiness is a monotonic watermark over the deterministic live winner. A newer + // stamp always closes gating; at the same stamp, a higher environment id closes it + // only when its value differs. Removals, fallback states, and same-value churn cannot. + const winner = states.reduce( + (currentWinner, { environmentId, connectionState, shellStatus, preferences }) => { + if (connectionState !== "connected" || shellStatus !== "live") return currentWinner; + const value = preferences?.planModeEnabled; + const updatedAt = getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"); + if (value === undefined || updatedAt === undefined) return currentWinner; + const candidate = [updatedAt, environmentId, value] as const; + return comparePlanModePreferenceReconciliationIdentities(candidate, currentWinner) > 0 + ? candidate + : currentWinner; + }, + undefined, + ); + return winner === undefined ? "" : JSON.stringify(winner); +} + +export function advancePlanModePreferenceReconciliationKey( + appliedKey: string | null, + currentKey: string, +): string { + if (appliedKey === null) return currentKey; + return comparePlanModePreferenceReconciliationIdentities( + parsePlanModePreferenceReconciliationKey(currentKey), + parsePlanModePreferenceReconciliationKey(appliedKey), + ) >= 0 + ? currentKey + : appliedKey; +} + +export function isPlanModePreferenceReconciliationReady(input: { + readonly connectionsLoaded: boolean; + readonly environmentCount: number; + readonly currentKey: string; + readonly appliedKey: string | null; +}): boolean { + if (!input.connectionsLoaded) return false; + if (input.environmentCount === 0) return true; + if (input.appliedKey === null) return false; + const current = parsePlanModePreferenceReconciliationKey(input.currentKey); + const applied = parsePlanModePreferenceReconciliationKey(input.appliedKey); + if (comparePlanModePreferenceReconciliationIdentities(current, applied) < 0) return true; + if (current === undefined || applied === undefined) return current === applied; + return current[0] === applied[0] && current[2] === applied[2]; +} + +export function hasPlanModePreferenceReconciliationAttempted( + states: ReadonlyArray<{ + readonly connectionState: EnvironmentConnectionPhase; + readonly shellStatus: EnvironmentShellStatus; + }>, +): boolean { + return ( + states.some( + ({ connectionState, shellStatus }) => + connectionState === "connected" && shellStatus === "live", + ) || + states.every( + ({ connectionState }) => + connectionState === "available" || + connectionState === "error" || + connectionState === "offline", + ) + ); +} + +type MutableSyncedClientPreferencesPatch = { + -readonly [Field in SyncedClientPreferenceField]?: SyncedClientPreferencesPatch[Field]; +}; +type MutableSyncedClientPreferencesUpdatedAtByField = { + -readonly [Field in SyncedClientPreferenceField]?: string; +}; + +function setPreferenceValue( + patch: MutableSyncedClientPreferencesPatch, + field: Field, + value: SyncedClientPreferencesPatch[Field], +): void { + patch[field] = value; +} + +function setPreferenceUpdatedAt( + updatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField, + field: Field, + updatedAt: string, +): void { + updatedAtByField[field] = updatedAt; +} + +function localPreferenceUpdatedAt( + local: LocalSyncedClientPreferencesState, + field: SyncedClientPreferenceField, +): string | undefined { + if (local.values[field] === undefined) return undefined; + return local.updatedAtByField?.[field] ?? local.legacyUpdatedAt; +} + +export function createSyncedClientPreferenceReconciliationController( + field: SyncedClientPreferenceField, + scheduleRetry: SyncedClientPreferenceRetryScheduler = scheduleSyncedClientPreferenceRetry, +) { + const reconciliationKey = ( + value: SyncedClientPreferencesPatch[SyncedClientPreferenceField] | undefined, + updatedAt: string | undefined, + ) => + value === undefined || updatedAt === undefined ? undefined : JSON.stringify([updatedAt, value]); + const targetReconciliationKey = (target: SyncedClientPreferencePatchTarget) => + reconciliationKey(target.input.patch[field], target.input.updatedAt); + + interface Reconciliation { + readonly target: SyncedClientPreferencePatchTarget; + readonly key: string | undefined; + attempt: number; + patch: () => Promise; + persist: (patch: Partial) => void; + cancelRetry?: () => void; + } + + interface EnvironmentReconciliation { + reconciliation?: Reconciliation; + settledKey?: string; + } + + const environmentReconciliations = new Map(); + const cancel = (environmentId: EnvironmentId) => { + const state = environmentReconciliations.get(environmentId); + state?.reconciliation?.cancelRetry?.(); + if (state !== undefined) state.reconciliation = undefined; + }; + const settleFailure = (reconciliation: Reconciliation) => { + const { environmentId } = reconciliation.target; + const state = environmentReconciliations.get(environmentId); + if (state?.reconciliation !== reconciliation) return; + if (reconciliation.attempt >= SYNCED_CLIENT_PREFERENCE_RECONCILIATION_MAX_ATTEMPTS) { + state.reconciliation = undefined; + return; + } + const delayMs = 1_000 * 2 ** (reconciliation.attempt - 1); + reconciliation.cancelRetry = scheduleRetry(() => { + reconciliation.cancelRetry = undefined; + if (environmentReconciliations.get(environmentId)?.reconciliation === reconciliation) { + dispatch(reconciliation); + } + }, delayMs); + }; + const dispatch = (reconciliation: Reconciliation) => { + reconciliation.attempt += 1; + void reconciliation.patch().then( + (preferences) => { + const { environmentId } = reconciliation.target; + const state = environmentReconciliations.get(environmentId); + if (state?.reconciliation !== reconciliation) return; + if (preferences === null) { + settleFailure(reconciliation); + return; + } + state.reconciliation = undefined; + state.settledKey = reconciliation.key; + const localPatch = canonicalSyncedClientPreferencesPatch(preferences, [field]); + if (localPatch !== null) reconciliation.persist(localPatch); + }, + () => settleFailure(reconciliation), + ); + }; + + return { + setActiveEnvironmentIds(environmentIds: ReadonlyArray) { + const nextEnvironmentIds = new Set(environmentIds); + for (const environmentId of environmentReconciliations.keys()) { + if (nextEnvironmentIds.has(environmentId)) continue; + cancel(environmentId); + environmentReconciliations.delete(environmentId); + } + for (const environmentId of nextEnvironmentIds) { + if (!environmentReconciliations.has(environmentId)) { + environmentReconciliations.set(environmentId, {}); + } + } + }, + observe( + environmentId: EnvironmentId, + value: SyncedClientPreferencesPatch[SyncedClientPreferenceField] | undefined, + updatedAt: string | undefined, + ) { + const reconciliation = environmentReconciliations.get(environmentId)?.reconciliation; + const observedKey = reconciliationKey(value, updatedAt); + if (observedKey !== undefined && reconciliation?.key === observedKey) cancel(environmentId); + }, + reconcile(input: { + readonly target: SyncedClientPreferencePatchTarget; + readonly patch: ( + target: SyncedClientPreferencePatchTarget, + ) => Promise>; + readonly persist: (patch: Partial) => void; + readonly normalizeThemeId?: (themeId: string) => string; + }) { + const { environmentId } = input.target; + const state = environmentReconciliations.get(environmentId); + const key = targetReconciliationKey(input.target); + if (state === undefined || (key !== undefined && state.settledKey === key)) { + return; + } + const current = state.reconciliation; + if (key !== undefined && current?.key === key) { + current.patch = async () => { + const result = await input.patch(input.target); + return result._tag === "Success" ? result.value : null; + }; + current.persist = (patch) => + input.persist( + patch.themeId === undefined + ? patch + : { ...patch, themeId: input.normalizeThemeId?.(patch.themeId) ?? patch.themeId }, + ); + return; + } + if (current !== undefined) cancel(environmentId); + state.settledKey = undefined; + const reconciliation: Reconciliation = { + target: input.target, + key, + attempt: 0, + patch: async () => { + const result = await input.patch(input.target); + return result._tag === "Success" ? result.value : null; + }, + persist: (patch) => + input.persist( + patch.themeId === undefined + ? patch + : { ...patch, themeId: input.normalizeThemeId?.(patch.themeId) ?? patch.themeId }, + ), + }; + state.reconciliation = reconciliation; + dispatch(reconciliation); + }, + reset() { + for (const environmentId of environmentReconciliations.keys()) cancel(environmentId); + environmentReconciliations.clear(); + }, + }; +} + +export function canonicalSyncedClientPreferencesPatch( + preferences: SyncedClientPreferences, + fields: ReadonlyArray = SYNCED_CLIENT_PREFERENCE_FIELDS, +): Partial | null { + const values: MutableSyncedClientPreferencesPatch = {}; + const updatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField = {}; + for (const field of fields) { + const value = preferences[field]; + const updatedAt = getSyncedClientPreferenceUpdatedAt(preferences, field); + if (value === undefined || updatedAt === undefined) continue; + setPreferenceValue(values, field, value); + setPreferenceUpdatedAt(updatedAtByField, field, updatedAt); + } + return Object.keys(values).length === 0 + ? null + : { ...values, syncedClientPreferencesUpdatedAtByField: updatedAtByField }; +} + +export function nextMobileSyncedPreferencesUpdatedAt( + localUpdatedAts: ReadonlyArray, + now: string, + authoritativeUpdatedAts: ReadonlyArray = [], +): string { + const maximumUpdatedAt = Date.parse(now) + SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS; + return nextSyncedClientPreferencesUpdatedAt( + [ + ...localUpdatedAts.filter( + (candidate) => candidate !== undefined && Date.parse(candidate) <= maximumUpdatedAt, + ), + ...authoritativeUpdatedAts, + ], + now, + ); +} + +export function createSyncedClientPreferencesWrite(input: { + readonly patch: SyncedClientPreferencesPatch; + readonly connectedEnvironmentIds: ReadonlyArray; + readonly currentUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByField; + readonly legacyCurrentUpdatedAt?: string; + readonly authoritativePreferences?: ReadonlyArray; + readonly now: string; +}) { + const fields = SYNCED_CLIENT_PREFERENCE_FIELDS.filter( + (field) => input.patch[field] !== undefined, + ); + const updatedAt = nextMobileSyncedPreferencesUpdatedAt( + fields.map((field) => input.currentUpdatedAtByField?.[field] ?? input.legacyCurrentUpdatedAt), + input.now, + input.authoritativePreferences?.flatMap((preferences) => + fields.map((field) => getSyncedClientPreferenceUpdatedAt(preferences, field)), + ), + ); + const updatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField = { + ...input.currentUpdatedAtByField, + }; + for (const field of fields) setPreferenceUpdatedAt(updatedAtByField, field, updatedAt); + const request = { patch: input.patch, updatedAt }; + return { + localPatch: { values: input.patch, updatedAtByField }, + environmentPatches: input.connectedEnvironmentIds.map((environmentId) => ({ + environmentId, + input: request, + })), + }; +} + +export function createPlanModePreferenceWrite(input: { + readonly value: boolean; + readonly connectedEnvironmentIds: ReadonlyArray; + readonly currentUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByField; + readonly legacyCurrentUpdatedAt?: string; + readonly authoritativePreferences?: ReadonlyArray; + readonly now: string; +}) { + const write = createSyncedClientPreferencesWrite({ + patch: { planModeEnabled: input.value }, + connectedEnvironmentIds: input.connectedEnvironmentIds, + currentUpdatedAtByField: input.currentUpdatedAtByField, + legacyCurrentUpdatedAt: input.legacyCurrentUpdatedAt, + authoritativePreferences: input.authoritativePreferences, + now: input.now, + }); + return { + localPatch: { + planModeEnabled: input.value, + syncedClientPreferencesUpdatedAtByField: write.localPatch.updatedAtByField, + }, + environmentPatches: write.environmentPatches, + }; +} + +export function createSyncedClientPreferenceWriteController(field: SyncedClientPreferenceField) { + let latestRequestedUpdatedAt: string | undefined; + let settledRequestedUpdatedAt: string | undefined; + + return { + create(input: Parameters[0]) { + const persistedUpdatedAt = input.currentUpdatedAtByField?.[field]; + const currentUpdatedAt = + latestRequestedUpdatedAt === undefined || + (persistedUpdatedAt !== undefined && persistedUpdatedAt > latestRequestedUpdatedAt) + ? persistedUpdatedAt + : latestRequestedUpdatedAt; + const currentUpdatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField = { + ...input.currentUpdatedAtByField, + }; + if (currentUpdatedAt !== undefined) { + setPreferenceUpdatedAt(currentUpdatedAtByField, field, currentUpdatedAt); + } + const write = createSyncedClientPreferencesWrite({ + ...input, + currentUpdatedAtByField, + }); + latestRequestedUpdatedAt = write.localPatch.updatedAtByField[field]; + settledRequestedUpdatedAt = undefined; + return write; + }, + settle(input: { + readonly target: SyncedClientPreferencePatchTarget; + readonly result: AtomCommandResult; + readonly normalizeThemeId?: (themeId: string) => string; + }): Partial | null { + if ( + input.target.input.updatedAt !== latestRequestedUpdatedAt || + input.target.input.updatedAt === settledRequestedUpdatedAt || + input.result._tag === "Failure" + ) { + return null; + } + settledRequestedUpdatedAt = input.target.input.updatedAt; + const patch = canonicalSyncedClientPreferencesPatch(input.result.value, [field]); + return patch?.themeId === undefined + ? patch + : { ...patch, themeId: input.normalizeThemeId?.(patch.themeId) ?? patch.themeId }; + }, + }; +} + +export function reconcileSyncedClientPreferences(input: { + readonly local: LocalSyncedClientPreferencesState; + readonly environments: ReadonlyArray; + readonly now: string; + readonly fields?: ReadonlyArray; + readonly normalizeThemeId?: (themeId: string) => string; +}) { + if (input.environments.length === 0) { + return { localPatch: null, environmentPatches: [] }; + } + + const localValues: MutableSyncedClientPreferencesPatch = {}; + const localUpdatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField = { + ...input.local.updatedAtByField, + }; + const environmentPatches: SyncedClientPreferencePatchTarget[] = []; + let localChanged = false; + const hasPatchableEnvironment = input.environments.some( + (environment) => environment.canPatch !== false, + ); + const normalizePreferenceValue = ( + field: SyncedClientPreferenceField, + value: SyncedClientPreferencesPatch[SyncedClientPreferenceField] | undefined, + ) => + field === "themeId" && isString(value) ? (input.normalizeThemeId?.(value) ?? value) : value; + + for (const field of input.fields ?? SYNCED_CLIENT_PREFERENCE_FIELDS) { + const localValue = normalizePreferenceValue(field, input.local.values[field]); + const localUpdatedAt = localPreferenceUpdatedAt(input.local, field); + const environmentCandidates = input.environments.flatMap((environment) => { + const value = normalizePreferenceValue(field, environment.preferences?.[field]); + const updatedAt = getSyncedClientPreferenceUpdatedAt(environment.preferences, field); + return value === undefined || + updatedAt === undefined || + (environment.canPatch === false && localUpdatedAt !== undefined) + ? [] + : [{ source: environment.environmentId, value, updatedAt }]; + }); + environmentCandidates.sort(compareEnvironmentPreferenceCandidates); + const latestEnvironment = environmentCandidates.at(-1); + const latestObservedEnvironmentUpdatedAt = latestEnvironment?.updatedAt; + const localUpdatedAtRebase = latestObservedEnvironmentUpdatedAt ?? input.now; + const boundedLocalUpdatedAt = + hasPatchableEnvironment && + localUpdatedAt !== undefined && + localUpdatedAt > localUpdatedAtRebase && + Date.parse(localUpdatedAt) > + Date.parse(input.now) + SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS + ? nextMobileSyncedPreferencesUpdatedAt([], localUpdatedAtRebase, [localUpdatedAtRebase]) + : localUpdatedAt; + // Exact remote ties use environment id for deterministic convergence. + const localWins = + localValue !== undefined && + boundedLocalUpdatedAt !== undefined && + (latestEnvironment === undefined || boundedLocalUpdatedAt > latestEnvironment.updatedAt); + const value = localWins ? localValue : (latestEnvironment?.value ?? localValue); + if (value === undefined) continue; + const updatedAt = + (localWins ? boundedLocalUpdatedAt : latestEnvironment?.updatedAt) ?? input.now; + + if (localValue !== value || localUpdatedAt !== updatedAt) { + setPreferenceValue(localValues, field, value); + setPreferenceUpdatedAt(localUpdatedAtByField, field, updatedAt); + localChanged = true; + } + + for (const environment of input.environments) { + if (environment.canPatch === false) continue; + if ( + normalizePreferenceValue(field, environment.preferences?.[field]) === value && + getSyncedClientPreferenceUpdatedAt(environment.preferences, field) === updatedAt + ) { + continue; + } + const patch: MutableSyncedClientPreferencesPatch = {}; + setPreferenceValue(patch, field, value); + environmentPatches.push({ + environmentId: environment.environmentId, + input: { patch, updatedAt }, + }); + } + } + + return { + localPatch: localChanged + ? { values: localValues, updatedAtByField: localUpdatedAtByField } + : null, + environmentPatches, + }; +} + +export function reconcilePlanModePreferences(input: { + readonly localPlanModeEnabled: boolean | undefined; + readonly localUpdatedAt: string | undefined; + readonly environments: ReadonlyArray; + readonly now: string; +}) { + let local: LocalSyncedClientPreferencesState = { + values: + input.localPlanModeEnabled === undefined + ? {} + : { planModeEnabled: input.localPlanModeEnabled }, + }; + if (input.localUpdatedAt !== undefined) { + local = { + ...local, + updatedAtByField: { planModeEnabled: input.localUpdatedAt }, + }; + } + const reconciliation = reconcileSyncedClientPreferences({ + local, + environments: input.environments, + now: input.now, + fields: ["planModeEnabled"], + }); + const planModeEnabled = reconciliation.localPatch?.values.planModeEnabled; + return { + localPatch: + planModeEnabled === undefined || reconciliation.localPatch === null + ? null + : { + planModeEnabled, + syncedClientPreferencesUpdatedAtByField: reconciliation.localPatch.updatedAtByField, + }, + environmentPatches: reconciliation.environmentPatches, + }; +} diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts new file mode 100644 index 000000000000..94686eb75527 --- /dev/null +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -0,0 +1,1304 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + advancePlanModePreferenceReconciliationKey, + createPlanModePreferenceReconciliationKey, + createPlanModePreferenceWrite, + createSyncedClientPreferenceReconciliationController, + createSyncedClientPreferenceWriteController, + createSyncedClientPreferencesWrite, + hasPlanModePreferenceReconciliationAttempted, + isPlanModePreferenceReconciliationReady, + nextMobileSyncedPreferencesUpdatedAt, + reconcilePlanModePreferences, + reconcileSyncedClientPreferences, +} from "./synced-client-preferences-model"; + +const environmentId = (value: string) => EnvironmentId.make(value); +const livePlanModeEnvironment = (id: string, value: boolean, updatedAt: string) => ({ + environmentId: environmentId(id), + connectionState: "connected" as const, + shellStatus: "live" as const, + preferences: { planModeEnabled: value, updatedAt }, +}); + +describe("synced client preferences", () => { + const flushReconciliation = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + const makeRetryScheduler = () => { + const scheduled: Array<{ + readonly delayMs: number; + readonly run: () => void; + readonly cancelled: () => boolean; + }> = []; + const schedule = (retry: () => void, delayMs: number) => { + let cancelled = false; + scheduled.push({ + delayMs, + run: () => { + if (!cancelled) retry(); + }, + cancelled: () => cancelled, + }); + return () => { + cancelled = true; + }; + }; + return { schedule, scheduled }; + }; + + it("uses the device preference immediately when the loaded catalog has no environments", () => { + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 0, + currentKey: "[]", + appliedKey: null, + }), + ).toBe(true); + }); + + it("waits for catalog hydration before applying the no-environment fallback", () => { + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: false, + environmentCount: 0, + currentKey: "[]", + appliedKey: null, + }), + ).toBe(false); + }); + + it("waits for an environment reconciliation to apply", () => { + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: "current", + appliedKey: null, + }), + ).toBe(false); + }); + + it("opens gating after the current environment reconciliation applies", () => { + const currentKey = createPlanModePreferenceReconciliationKey([ + livePlanModeEnvironment("environment-1", true, "2026-08-14T12:00:00.000Z"), + ]); + + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey, + appliedKey: currentKey, + }), + ).toBe(true); + }); + + it("keeps send gating open while an unrelated environment reconnects", () => { + const liveEnvironment = { + environmentId: environmentId("live"), + connectionState: "connected", + shellStatus: "live", + preferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + } as const; + const connectingKey = createPlanModePreferenceReconciliationKey([ + liveEnvironment, + { + environmentId: environmentId("flapping"), + connectionState: "connecting", + shellStatus: "cached", + preferences: undefined, + }, + ]); + const reconnectingKey = createPlanModePreferenceReconciliationKey([ + liveEnvironment, + { + environmentId: environmentId("flapping"), + connectionState: "reconnecting", + shellStatus: "cached", + preferences: undefined, + }, + ]); + + expect( + hasPlanModePreferenceReconciliationAttempted([ + liveEnvironment, + { connectionState: "reconnecting", shellStatus: "cached" }, + ]), + ).toBe(true); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 2, + currentKey: reconnectingKey, + appliedKey: connectingKey, + }), + ).toBe(true); + }); + + it("keeps the applied reconciliation state when an offline environment leaves", () => { + const remainingOfflineEnvironment = { + environmentId: environmentId("remaining-offline"), + connectionState: "offline", + shellStatus: "cached", + preferences: undefined, + } as const; + const offlineKey = createPlanModePreferenceReconciliationKey([ + { + environmentId: environmentId("offline"), + connectionState: "offline", + shellStatus: "cached", + preferences: undefined, + }, + remainingOfflineEnvironment, + ]); + const remainingKey = createPlanModePreferenceReconciliationKey([remainingOfflineEnvironment]); + + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: remainingKey, + appliedKey: offlineKey, + }), + ).toBe(true); + expect(advancePlanModePreferenceReconciliationKey(offlineKey, remainingKey)).toBe(offlineKey); + }); + + it("does not accept a stale live shell while its environment is reconnecting", () => { + expect( + hasPlanModePreferenceReconciliationAttempted([ + { connectionState: "reconnecting", shellStatus: "live" }, + ]), + ).toBe(false); + }); + + it("uses the device fallback after the first offline reconciliation attempt", () => { + const offlineState = { + environmentId: environmentId("offline"), + connectionState: "offline", + shellStatus: "cached", + preferences: undefined, + } as const; + const offlineKey = createPlanModePreferenceReconciliationKey([offlineState]); + + expect(hasPlanModePreferenceReconciliationAttempted([offlineState])).toBe(true); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: offlineKey, + appliedKey: null, + }), + ).toBe(false); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: offlineKey, + appliedKey: offlineKey, + }), + ).toBe(true); + }); + + it("reconciles a newly live newer preference without blocking reconnect churn", () => { + const environment = { + environmentId: environmentId("environment-1"), + shellStatus: "cached", + preferences: undefined, + } as const; + const offlineKey = createPlanModePreferenceReconciliationKey([ + { ...environment, connectionState: "offline" }, + ]); + const connectingKey = createPlanModePreferenceReconciliationKey([ + { ...environment, connectionState: "connecting" }, + ]); + const reconnectingKey = createPlanModePreferenceReconciliationKey([ + { ...environment, connectionState: "reconnecting" }, + ]); + const liveKey = createPlanModePreferenceReconciliationKey([ + { + ...environment, + connectionState: "connected", + shellStatus: "live", + preferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ]); + + expect(connectingKey).toBe(reconnectingKey); + expect(connectingKey).toBe(offlineKey); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: reconnectingKey, + appliedKey: offlineKey, + }), + ).toBe(true); + expect(liveKey).not.toBe(offlineKey); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: liveKey, + appliedKey: offlineKey, + }), + ).toBe(false); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: liveKey, + appliedKey: advancePlanModePreferenceReconciliationKey(offlineKey, liveKey), + }), + ).toBe(true); + }); + + it("keeps gating open for a newly live preference older than the applied watermark", () => { + const currentKey = createPlanModePreferenceReconciliationKey([ + livePlanModeEnvironment("older", false, "2026-08-14T12:00:00.000Z"), + ]); + const appliedKey = createPlanModePreferenceReconciliationKey([ + livePlanModeEnvironment("newer", true, "2026-08-14T12:01:00.000Z"), + ]); + + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey, + appliedKey, + }), + ).toBe(true); + }); + + it("reconciles a newly live equal-stamp winner with a different value", () => { + const updatedAt = "2026-08-14T12:00:00.000Z"; + const initialEnvironment = livePlanModeEnvironment("environment-1", false, updatedAt); + const appliedKey = createPlanModePreferenceReconciliationKey([initialEnvironment]); + const currentKey = createPlanModePreferenceReconciliationKey([ + initialEnvironment, + livePlanModeEnvironment("environment-2", true, updatedAt), + ]); + + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 2, + currentKey, + appliedKey, + }), + ).toBe(false); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 2, + currentKey, + appliedKey: advancePlanModePreferenceReconciliationKey(appliedKey, currentKey), + }), + ).toBe(true); + }); + + it("keeps gating open for a newly live equal-stamp winner with the same value", () => { + const updatedAt = "2026-08-14T12:00:00.000Z"; + const initialEnvironment = livePlanModeEnvironment("environment-1", false, updatedAt); + const appliedKey = createPlanModePreferenceReconciliationKey([initialEnvironment]); + const currentKey = createPlanModePreferenceReconciliationKey([ + initialEnvironment, + livePlanModeEnvironment("environment-2", false, updatedAt), + ]); + + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 2, + currentKey, + appliedKey, + }), + ).toBe(true); + }); + + it("bounds excessively future-skewed local stamps", () => { + expect( + nextMobileSyncedPreferencesUpdatedAt( + ["2026-08-14T12:05:00.001Z"], + "2026-08-14T12:00:00.000Z", + ), + ).toBe("2026-08-14T12:00:00.000Z"); + expect( + nextMobileSyncedPreferencesUpdatedAt( + ["2026-08-14T12:05:00.000Z"], + "2026-08-14T12:00:00.000Z", + ), + ).toBe("2026-08-14T12:05:00.001Z"); + }); + + it("advances past authoritative environment stamps on a slow device clock", () => { + expect( + nextMobileSyncedPreferencesUpdatedAt([], "2026-08-14T12:00:00.000Z", [ + "2026-08-14T13:00:00.000Z", + ]), + ).toBe("2026-08-14T13:00:00.001Z"); + }); + + it("adopts the environment plan mode into the device cache on connect", () => { + expect( + reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T11:00:00.000Z", + environments: [ + { + environmentId: environmentId("environment-1"), + preferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ + localPatch: { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + }, + environmentPatches: [], + }); + }); + + it("keeps a stamped device preference when a newer environment is read-only", () => { + expect( + reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T11:00:00.000Z", + environments: [ + { + environmentId: environmentId("read-only"), + canPatch: false, + preferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ localPatch: null, environmentPatches: [] }); + }); + + it("fans a mobile toggle out to every connected environment", () => { + const write = createPlanModePreferenceWrite({ + value: true, + connectedEnvironmentIds: [environmentId("environment-1"), environmentId("environment-2")], + currentUpdatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + authoritativePreferences: [ + { + planModeEnabled: false, + updatedAt: "2026-08-14T12:02:00.000Z", + }, + ], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(write.localPatch).toEqual({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:02:00.001Z", + }, + }); + expect(write.environmentPatches).toEqual([ + { + environmentId: environmentId("environment-1"), + input: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:02:00.001Z", + }, + }, + { + environmentId: environmentId("environment-2"), + input: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:02:00.001Z", + }, + }, + ]); + }); + + it("ignores a stale toggle ack after a newer same-millisecond write", () => { + const controller = createSyncedClientPreferenceWriteController("planModeEnabled"); + const environment = environmentId("environment-1"); + const first = controller.create({ + patch: { planModeEnabled: true }, + connectedEnvironmentIds: [environment], + now: "2026-08-14T12:00:00.000Z", + }); + const second = controller.create({ + patch: { planModeEnabled: false }, + connectedEnvironmentIds: [environment], + now: "2026-08-14T12:00:00.000Z", + }); + + expect(second.localPatch.updatedAtByField.planModeEnabled).toBe("2026-08-14T12:00:00.001Z"); + expect( + controller.settle({ + target: second.environmentPatches[0]!, + result: AsyncResult.success({ + planModeEnabled: false, + updatedAt: second.environmentPatches[0]!.input.updatedAt, + }), + }), + ).toEqual({ + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: second.localPatch.updatedAtByField, + }); + expect( + controller.settle({ + target: first.environmentPatches[0]!, + result: AsyncResult.success({ + planModeEnabled: true, + updatedAt: first.environmentPatches[0]!.input.updatedAt, + }), + }), + ).toBeNull(); + }); + + it("advances past a local clock reconciled after the previous write", () => { + const controller = createSyncedClientPreferenceWriteController("planModeEnabled"); + const environment = environmentId("environment-1"); + controller.create({ + patch: { planModeEnabled: true }, + connectedEnvironmentIds: [environment], + now: "2026-08-14T12:00:00.000Z", + }); + + const write = controller.create({ + patch: { planModeEnabled: false }, + connectedEnvironmentIds: [environment], + currentUpdatedAtByField: { planModeEnabled: "2026-08-14T12:05:00.000Z" }, + now: "2026-08-14T12:01:00.000Z", + }); + + expect(write.localPatch.updatedAtByField.planModeEnabled).toBe("2026-08-14T12:05:00.001Z"); + }); + + it("settles a multi-environment toggle from only the first response", () => { + const controller = createSyncedClientPreferenceWriteController("planModeEnabled"); + const write = controller.create({ + patch: { planModeEnabled: true }, + connectedEnvironmentIds: [environmentId("environment-1"), environmentId("environment-2")], + now: "2026-08-14T12:00:00.000Z", + }); + + expect( + controller.settle({ + target: write.environmentPatches[1]!, + result: AsyncResult.success({ + planModeEnabled: true, + updatedAt: "2026-08-14T12:01:00.000Z", + }), + }), + ).toEqual({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:01:00.000Z", + }, + }); + expect( + controller.settle({ + target: write.environmentPatches[0]!, + result: AsyncResult.success({ + planModeEnabled: false, + updatedAt: "2026-08-14T11:59:00.000Z", + }), + }), + ).toBeNull(); + }); + + it("normalizes a theme acknowledgment without changing other field stamps", () => { + const controller = createSyncedClientPreferenceWriteController("themeId"); + const write = controller.create({ + patch: { themeId: "custom-theme" }, + connectedEnvironmentIds: [environmentId("environment-1")], + currentUpdatedAtByField: { + appearanceMode: "2026-08-14T11:00:00.000Z", + }, + now: "2026-08-14T12:00:00.000Z", + }); + + expect( + controller.settle({ + target: write.environmentPatches[0]!, + result: AsyncResult.success({ + themeId: "unknown-on-this-device", + updatedAtByField: { themeId: "2026-08-14T12:01:00.000Z" }, + updatedAt: "2026-08-14T12:01:00.000Z", + }), + normalizeThemeId: () => "t3-code", + }), + ).toEqual({ + themeId: "t3-code", + syncedClientPreferencesUpdatedAtByField: { + themeId: "2026-08-14T12:01:00.000Z", + }, + }); + }); + + it("keeps offline toggles device-local", () => { + expect( + createPlanModePreferenceWrite({ + value: false, + connectedEnvironmentIds: [], + now: "2026-08-14T12:00:00.000Z", + }), + ).toEqual({ + localPatch: { + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + }, + environmentPatches: [], + }); + }); + + it("stamps only fields included in a partial local write", () => { + expect( + createSyncedClientPreferencesWrite({ + patch: { planModeEnabled: true }, + connectedEnvironmentIds: [environmentId("environment-1")], + currentUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + appearanceMode: "2026-08-14T13:00:00.000Z", + }, + now: "2026-08-14T12:30:00.000Z", + }), + ).toEqual({ + localPatch: { + values: { planModeEnabled: true }, + updatedAtByField: { + planModeEnabled: "2026-08-14T12:30:00.000Z", + appearanceMode: "2026-08-14T13:00:00.000Z", + }, + }, + environmentPatches: [ + { + environmentId: environmentId("environment-1"), + input: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:30:00.000Z", + }, + }, + ], + }); + }); + + it("reconciles stale environments to the most recent stamped value", () => { + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T10:00:00.000Z", + environments: [ + { + environmentId: environmentId("environment-1"), + preferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T11:00:00.000Z", + }, + }, + { + environmentId: environmentId("environment-2"), + preferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(reconciliation.localPatch).toMatchObject({ planModeEnabled: true }); + expect(reconciliation.environmentPatches).toHaveLength(1); + expect(reconciliation.environmentPatches[0]?.environmentId).toBe( + environmentId("environment-1"), + ); + expect(reconciliation.environmentPatches[0]?.input.patch.planModeEnabled).toBe(true); + }); + + it("reconciles the deterministic equal-stamp winner after topology changes", async () => { + const updatedAt = "2026-08-14T12:00:00.000Z"; + const lowerEnvironmentId = environmentId("environment-1"); + const higherEnvironmentId = environmentId("environment-2"); + const initial = reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: updatedAt, + environments: [{ environmentId: lowerEnvironmentId, preferences: undefined }], + now: updatedAt, + }); + const controller = createSyncedClientPreferenceReconciliationController("planModeEnabled"); + const patch = vi.fn(async (target: (typeof initial.environmentPatches)[number]) => + AsyncResult.success({ + planModeEnabled: target.input.patch.planModeEnabled, + updatedAt: target.input.updatedAt, + }), + ); + controller.setActiveEnvironmentIds([lowerEnvironmentId]); + controller.reconcile({ target: initial.environmentPatches[0]!, patch, persist: vi.fn() }); + await flushReconciliation(); + + const environments = [ + { + environmentId: lowerEnvironmentId, + preferences: { planModeEnabled: false, updatedAt }, + }, + { + environmentId: higherEnvironmentId, + preferences: { planModeEnabled: true, updatedAt }, + }, + ]; + const ascending = reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: updatedAt, + environments, + now: updatedAt, + }); + const descending = reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: updatedAt, + environments: [environments[1]!, environments[0]!], + now: updatedAt, + }); + controller.reconcile({ target: ascending.environmentPatches[0]!, patch, persist: vi.fn() }); + await flushReconciliation(); + + const expected = { + localPatch: { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { planModeEnabled: updatedAt }, + }, + environmentPatches: [ + { + environmentId: lowerEnvironmentId, + input: { patch: { planModeEnabled: true }, updatedAt }, + }, + ], + }; + expect({ + ascending, + descending, + patchedValues: patch.mock.calls.map(([target]) => target), + }).toEqual({ + ascending: expected, + descending: expected, + patchedValues: [ + { + environmentId: lowerEnvironmentId, + input: { patch: { planModeEnabled: false }, updatedAt }, + }, + { + environmentId: lowerEnvironmentId, + input: { patch: { planModeEnabled: true }, updatedAt }, + }, + ], + }); + }); + + it("reconciles when ES2023 change-by-copy array methods are unavailable", () => { + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, "toSorted"); + Reflect.defineProperty(Array.prototype, "toSorted", { + configurable: true, + value: undefined, + }); + + try { + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T10:00:00.000Z", + environments: [ + { + environmentId: environmentId("older"), + preferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T11:00:00.000Z", + }, + }, + { + environmentId: environmentId("newer"), + preferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(reconciliation.localPatch?.planModeEnabled).toBe(true); + } finally { + if (descriptor === undefined) { + Reflect.deleteProperty(Array.prototype, "toSorted"); + } else { + Reflect.defineProperty(Array.prototype, "toSorted", descriptor); + } + } + }); + + it("reuses the winning stamp across pre-ack reconciliation passes", () => { + const environments = [ + { + environmentId: environmentId("environment-1"), + preferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T11:00:00.000Z", + }, + }, + { + environmentId: environmentId("environment-2"), + preferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ]; + const first = reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T10:00:00.000Z", + environments, + now: "2026-08-14T12:01:00.000Z", + }); + const second = reconcilePlanModePreferences({ + localPlanModeEnabled: first.localPatch?.planModeEnabled, + localUpdatedAt: first.localPatch?.syncedClientPreferencesUpdatedAtByField?.planModeEnabled, + environments, + now: "2026-08-14T12:01:01.000Z", + }); + + expect(first.environmentPatches[0]?.input.updatedAt).toBe("2026-08-14T12:00:00.000Z"); + expect(second.environmentPatches[0]?.input.updatedAt).toBe( + first.environmentPatches[0]?.input.updatedAt, + ); + }); + + it("does not advance plan mode past a newer non-plan preference stamp", () => { + const environments = [ + { + environmentId: environmentId("appearance-only"), + preferences: { + appearanceMode: "dark" as const, + updatedAt: "2026-08-14T13:00:00.000Z", + }, + }, + { + environmentId: environmentId("plan-source"), + preferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ]; + const first = reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T11:00:00.000Z", + environments, + now: "2026-08-14T12:59:00.000Z", + }); + const second = reconcilePlanModePreferences({ + localPlanModeEnabled: first.localPatch?.planModeEnabled, + localUpdatedAt: first.localPatch?.syncedClientPreferencesUpdatedAtByField?.planModeEnabled, + environments, + now: "2026-08-14T13:01:00.000Z", + }); + + expect(first.environmentPatches).toHaveLength(1); + expect(first.environmentPatches[0]?.input.updatedAt).toBe("2026-08-14T12:00:00.000Z"); + expect(second.environmentPatches[0]?.input.updatedAt).toBe( + first.environmentPatches[0]?.input.updatedAt, + ); + }); + + it("adopts a newer appearance while pushing only an offline plan write", () => { + const reconciliation = reconcileSyncedClientPreferences({ + local: { + values: { planModeEnabled: true, appearanceMode: "light" }, + updatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.001Z", + appearanceMode: "2026-08-14T11:00:00.000Z", + }, + }, + environments: [ + { + environmentId: environmentId("environment-1"), + preferences: { + planModeEnabled: false, + appearanceMode: "dark", + updatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + appearanceMode: "2026-08-14T13:00:00.000Z", + }, + updatedAt: "2026-08-14T13:00:00.000Z", + }, + }, + ], + now: "2026-08-14T14:00:00.000Z", + }); + + expect(reconciliation.localPatch).toEqual({ + values: { appearanceMode: "dark" }, + updatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.001Z", + appearanceMode: "2026-08-14T13:00:00.000Z", + }, + }); + expect(reconciliation.environmentPatches).toEqual([ + { + environmentId: environmentId("environment-1"), + input: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.001Z", + }, + }, + ]); + }); + + it("stabilizes unavailable remote theme ids at the local fallback", () => { + const environment = { + environmentId: environmentId("environment-1"), + preferences: { + themeId: "remote-custom-theme", + updatedAtByField: { themeId: "2026-08-14T13:00:00.000Z" }, + updatedAt: "2026-08-14T13:00:00.000Z", + }, + } as const; + const normalizeThemeId = () => "t3-code"; + const first = reconcileSyncedClientPreferences({ + local: { + values: { themeId: "t3-code" }, + updatedAtByField: { themeId: "2026-08-14T12:00:00.000Z" }, + }, + environments: [environment], + now: "2026-08-14T14:00:00.000Z", + normalizeThemeId, + }); + const second = reconcileSyncedClientPreferences({ + local: { + values: first.localPatch?.values ?? {}, + updatedAtByField: first.localPatch?.updatedAtByField, + }, + environments: [environment], + now: "2026-08-14T14:00:00.000Z", + normalizeThemeId, + }); + + expect(first.localPatch).toEqual({ + values: { themeId: "t3-code" }, + updatedAtByField: { themeId: "2026-08-14T13:00:00.000Z" }, + }); + expect(second.localPatch).toBeNull(); + expect(second.environmentPatches).toEqual([]); + }); + + it("patches only stale environments after a peer converges", () => { + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: true, + localUpdatedAt: "2026-08-14T12:00:00.000Z", + environments: [ + { + environmentId: environmentId("current"), + preferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + { + environmentId: environmentId("stale"), + preferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T11:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(reconciliation.environmentPatches.map((target) => target.environmentId)).toEqual([ + environmentId("stale"), + ]); + }); + + it("preserves a newer local stamp when a later environment has an intermediate stamp", () => { + const localUpdatedAt = "2026-08-14T12:02:00.000Z"; + const first = reconcilePlanModePreferences({ + localPlanModeEnabled: true, + localUpdatedAt, + environments: [ + { + environmentId: environmentId("observed"), + preferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:03:00.000Z", + }); + const second = reconcilePlanModePreferences({ + localPlanModeEnabled: first.localPatch?.planModeEnabled ?? true, + localUpdatedAt: + first.localPatch?.syncedClientPreferencesUpdatedAtByField?.planModeEnabled ?? + localUpdatedAt, + environments: [ + { + environmentId: environmentId("later"), + preferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }, + ], + now: "2026-08-14T12:03:00.000Z", + }); + + expect([first, second]).toEqual([ + { + localPatch: null, + environmentPatches: [ + { + environmentId: environmentId("observed"), + input: { + patch: { planModeEnabled: true }, + updatedAt: localUpdatedAt, + }, + }, + ], + }, + { + localPatch: null, + environmentPatches: [ + { + environmentId: environmentId("later"), + input: { + patch: { planModeEnabled: true }, + updatedAt: localUpdatedAt, + }, + }, + ], + }, + ]); + }); + + it("bounds a future-skewed local stamp just after the newest observed environment stamp", () => { + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: true, + localUpdatedAt: "2099-01-01T00:00:00.000Z", + environments: [ + { + environmentId: environmentId("environment-1"), + canPatch: true, + preferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(reconciliation.localPatch).toEqual({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.001Z", + }, + }); + expect(reconciliation.environmentPatches).toEqual([ + { + environmentId: environmentId("environment-1"), + input: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.001Z", + }, + }, + ]); + }); + + it("bounds a future-skewed local stamp before seeding an empty environment", () => { + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: true, + localUpdatedAt: "2099-01-01T00:00:00.000Z", + environments: [ + { + environmentId: environmentId("environment-1"), + canPatch: true, + preferences: undefined, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(reconciliation).toEqual({ + localPatch: { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:01:00.001Z", + }, + }, + environmentPatches: [ + { + environmentId: environmentId("environment-1"), + input: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:01:00.001Z", + }, + }, + ], + }); + }); + + it("clears pending reconciliation from an older canonical patch ack", async () => { + const target = { + environmentId: environmentId("environment-1"), + input: { + patch: { planModeEnabled: true }, + updatedAt: "2099-01-01T00:00:00.000Z", + }, + } as const; + const canonical = { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:30.000Z", + } as const; + + const controller = createSyncedClientPreferenceReconciliationController("planModeEnabled"); + const persist = vi.fn(); + controller.setActiveEnvironmentIds([target.environmentId]); + controller.reconcile({ + target, + patch: async () => AsyncResult.success(canonical), + persist, + }); + await flushReconciliation(); + + const next = reconcilePlanModePreferences({ + localPlanModeEnabled: canonical.planModeEnabled, + localUpdatedAt: canonical.updatedAt, + environments: [ + { + environmentId: target.environmentId, + canPatch: true, + preferences: canonical, + }, + ], + now: "2099-01-01T00:00:01.000Z", + }); + + expect(persist).toHaveBeenCalledWith({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { planModeEnabled: canonical.updatedAt }, + }); + expect(next.environmentPatches).toEqual([]); + }); + + it("does not seed a connected environment without patch scope", () => { + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: true, + localUpdatedAt: undefined, + environments: [ + { + environmentId: environmentId("read-only"), + canPatch: false, + preferences: undefined, + }, + ], + now: "2026-08-14T12:00:00.000Z", + }); + + expect(reconciliation.environmentPatches).toEqual([]); + }); + + it("keeps a newer local choice when the environment is read-only", () => { + expect( + reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T12:01:00.000Z", + environments: [ + { + environmentId: environmentId("read-only"), + canPatch: false, + preferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ localPatch: null, environmentPatches: [] }); + }); + + it("retries failed reconciliation and succeeds within the cap", async () => { + const environment = environmentId("environment-1"); + const target = { + environmentId: environment, + input: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + } as const; + const { schedule, scheduled } = makeRetryScheduler(); + const controller = createSyncedClientPreferenceReconciliationController( + "planModeEnabled", + schedule, + ); + const patch = vi + .fn() + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail("offline"))) + .mockResolvedValueOnce( + AsyncResult.success({ + planModeEnabled: true, + updatedAt: target.input.updatedAt, + }), + ); + const persist = vi.fn(); + + controller.setActiveEnvironmentIds([environment]); + controller.reconcile({ target, patch, persist }); + await flushReconciliation(); + + expect(patch).toHaveBeenCalledTimes(1); + expect(scheduled).toHaveLength(1); + expect(scheduled[0]?.delayMs).toBe(1_000); + + scheduled[0]?.run(); + await flushReconciliation(); + + expect(patch).toHaveBeenCalledTimes(2); + expect(persist).toHaveBeenCalledWith({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: target.input.updatedAt, + }, + }); + }); + + it("stops retrying automatically after exhausting reconciliation retries", async () => { + const environment = environmentId("environment-1"); + const target = { + environmentId: environment, + input: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + } as const; + const { schedule, scheduled } = makeRetryScheduler(); + const controller = createSyncedClientPreferenceReconciliationController( + "planModeEnabled", + schedule, + ); + const patch = vi.fn().mockResolvedValue(AsyncResult.failure(Cause.fail("offline"))); + + controller.setActiveEnvironmentIds([environment]); + controller.reconcile({ target, patch, persist: vi.fn() }); + await flushReconciliation(); + scheduled[0]?.run(); + await flushReconciliation(); + scheduled[1]?.run(); + await flushReconciliation(); + + expect(patch).toHaveBeenCalledTimes(3); + expect(scheduled.map(({ delayMs }) => delayMs)).toEqual([1_000, 2_000]); + }); + + it("retries an exhausted reconciliation on a later trigger", async () => { + const environment = environmentId("environment-1"); + const target = { + environmentId: environment, + input: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + } as const; + const { schedule, scheduled } = makeRetryScheduler(); + const controller = createSyncedClientPreferenceReconciliationController( + "planModeEnabled", + schedule, + ); + const patch = vi + .fn() + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail("offline"))) + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail("offline"))) + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail("offline"))) + .mockResolvedValueOnce( + AsyncResult.success({ + planModeEnabled: true, + updatedAt: target.input.updatedAt, + }), + ); + const persist = vi.fn(); + + controller.setActiveEnvironmentIds([environment]); + controller.reconcile({ target, patch, persist }); + await flushReconciliation(); + scheduled[0]?.run(); + await flushReconciliation(); + scheduled[1]?.run(); + await flushReconciliation(); + + expect(patch).toHaveBeenCalledTimes(3); + + controller.reconcile({ target, patch, persist }); + await flushReconciliation(); + + expect(patch).toHaveBeenCalledTimes(4); + expect(persist).toHaveBeenCalledWith({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: target.input.updatedAt, + }, + }); + }); + + it.each(["disconnect", "unmount"] as const)( + "cancels a scheduled reconciliation retry on %s", + async (lifecycleExit) => { + const environment = environmentId("environment-1"); + const target = { + environmentId: environment, + input: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + } as const; + const { schedule, scheduled } = makeRetryScheduler(); + const controller = createSyncedClientPreferenceReconciliationController( + "planModeEnabled", + schedule, + ); + const patch = vi.fn().mockResolvedValue(AsyncResult.failure(Cause.fail("offline"))); + + controller.setActiveEnvironmentIds([environment]); + controller.reconcile({ target, patch, persist: vi.fn() }); + await flushReconciliation(); + if (lifecycleExit === "disconnect") { + controller.setActiveEnvironmentIds([]); + } else { + controller.reset(); + } + + expect(scheduled[0]?.cancelled()).toBe(true); + scheduled[0]?.run(); + await flushReconciliation(); + expect(patch).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts new file mode 100644 index 000000000000..95cc1d643b49 --- /dev/null +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -0,0 +1,316 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { + AuthOrchestrationOperateScope, + getSyncedClientPreferenceUpdatedAt, + SYNCED_CLIENT_PREFERENCE_FIELDS, + type SyncedClientPreferenceField, + type SyncedClientPreferencesPatch, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo } from "react"; + +import { environmentCatalog } from "../connection/catalog"; +import { + DEFAULT_MOBILE_APPEARANCE_MODE, + DEFAULT_MOBILE_THEME_ID, + isMobileThemeId, + type MobileAppearanceMode, +} from "../lib/mobileTheme"; +import type { ImportedMobileTheme } from "../lib/mobileThemeFile"; +import { environmentShell } from "./shell"; +import { environmentPresentations } from "./presentation"; +import { + mobilePreferencesAtom, + persistReconciledMobilePreferencesAtom, + updateMobilePreferencesAtom, +} from "./preferences"; +import { serverEnvironment } from "./server"; +import { environmentSession } from "./session"; +import { useAtomCommand } from "./use-atom-command"; +import { + advancePlanModePreferenceReconciliationKey, + createPlanModePreferenceReconciliationKey, + createSyncedClientPreferenceReconciliationController, + createSyncedClientPreferenceWriteController, + hasPlanModePreferenceReconciliationAttempted, + isPlanModePreferenceReconciliationReady, + reconcileSyncedClientPreferences, +} from "./synced-client-preferences-model"; + +const connectedEnvironmentPreferenceStatesAtom = Atom.make((get) => { + const catalog = get(environmentCatalog.catalogValueAtom); + const presentations = get(environmentPresentations.presentationsAtom); + const states = [...presentations.entries()].map(([environmentId, presentation]) => { + const shell = get(environmentShell.stateValueAtom(environmentId)); + const session = get(environmentSession.sessionStateValueAtom(environmentId)); + return { + environmentId, + connectionState: presentation.connection.phase, + shell, + canPatch: + session?.authenticated === true && + session.scopes?.includes(AuthOrchestrationOperateScope) === true, + }; + }); + const reconciliationKey = createPlanModePreferenceReconciliationKey( + states.map(({ environmentId, connectionState, shell }) => ({ + environmentId, + connectionState, + shellStatus: shell.status, + preferences: + shell.snapshot._tag === "Some" ? shell.snapshot.value.syncedClientPreferences : undefined, + })), + ); + return { + connectionsLoaded: catalog.isReady, + connectedEnvironmentIds: states + .filter((state) => state.connectionState === "connected" && state.canPatch) + .map((state) => state.environmentId), + reconciliationKey, + states, + } as const; +}).pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:connected-environment-states")); + +function useConnectedEnvironmentPreferenceStates() { + return useAtomValue(connectedEnvironmentPreferenceStatesAtom); +} + +const planModePreferenceReconciledKeyAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:preferences:plan-mode-reconciled-key"), +); + +export function usePlanModePreferenceReconciliationReady(): boolean { + const appliedKey = useAtomValue(planModePreferenceReconciledKeyAtom); + const { connectionsLoaded, reconciliationKey, states } = + useConnectedEnvironmentPreferenceStates(); + return isPlanModePreferenceReconciliationReady({ + connectionsLoaded, + environmentCount: states.length, + currentKey: reconciliationKey, + appliedKey, + }); +} + +function resolveLocalThemeId( + themeId: string, + importedThemes: ReadonlyArray, + writtenThemeId?: string, +) { + return themeId === writtenThemeId || isMobileThemeId(themeId, importedThemes) + ? themeId + : DEFAULT_MOBILE_THEME_ID; +} + +export function useSyncedClientPreferences(): void { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const persistReconciledPreferences = useAtomSet(persistReconciledMobilePreferencesAtom); + const { connectionsLoaded, reconciliationKey, states } = + useConnectedEnvironmentPreferenceStates(); + const reconciledKey = useAtomValue(planModePreferenceReconciledKeyAtom); + const setReconciledKey = useAtomSet(planModePreferenceReconciledKeyAtom); + const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { + label: "synced client preferences reconciliation", + reportFailure: false, + }); + const reconciliationControllers = useMemo( + () => ({ + planModeEnabled: createSyncedClientPreferenceReconciliationController("planModeEnabled"), + appearanceMode: createSyncedClientPreferenceReconciliationController("appearanceMode"), + themeId: createSyncedClientPreferenceReconciliationController("themeId"), + }), + [], + ); + + useEffect( + () => () => { + for (const controller of Object.values(reconciliationControllers)) controller.reset(); + }, + [reconciliationControllers], + ); + + useEffect(() => { + const liveStates = states.filter( + ({ connectionState, shell }) => connectionState === "connected" && shell.status === "live", + ); + const activeEnvironmentIds = liveStates + .filter(({ canPatch }) => canPatch) + .map(({ environmentId }) => environmentId); + for (const controller of Object.values(reconciliationControllers)) { + controller.setActiveEnvironmentIds(activeEnvironmentIds); + } + for (const { environmentId, shell } of liveStates) { + const preferences = + shell.snapshot._tag === "Some" ? shell.snapshot.value.syncedClientPreferences : undefined; + for (const field of SYNCED_CLIENT_PREFERENCE_FIELDS) { + reconciliationControllers[field].observe( + environmentId, + preferences?.[field], + getSyncedClientPreferenceUpdatedAt(preferences, field), + ); + } + } + if (!connectionsLoaded) { + setReconciledKey(null); + return; + } + const nextReconciledKey = advancePlanModePreferenceReconciliationKey( + reconciledKey, + reconciliationKey, + ); + if (states.length === 0) { + setReconciledKey(nextReconciledKey); + return; + } + if (!AsyncResult.isSuccess(preferencesResult)) return; + const reconciliationAttempted = hasPlanModePreferenceReconciliationAttempted( + states.map(({ connectionState, shell }) => ({ + connectionState, + shellStatus: shell.status, + })), + ); + if (!reconciliationAttempted) return; + if (liveStates.length === 0) { + // A loaded catalog with only terminal offline states has no server value + // to apply. The device value governs until an environment reconnects. + setReconciledKey(nextReconciledKey); + return; + } + const importedThemes = preferencesResult.value.importedThemes ?? []; + const normalizeThemeId = (themeId: string) => resolveLocalThemeId(themeId, importedThemes); + const reconciliation = reconcileSyncedClientPreferences({ + local: { + values: { + planModeEnabled: preferencesResult.value.planModeEnabled ?? false, + appearanceMode: preferencesResult.value.appearanceMode ?? DEFAULT_MOBILE_APPEARANCE_MODE, + themeId: preferencesResult.value.themeId ?? DEFAULT_MOBILE_THEME_ID, + }, + updatedAtByField: preferencesResult.value.syncedClientPreferencesUpdatedAtByField, + legacyUpdatedAt: preferencesResult.value.syncedClientPreferencesUpdatedAt, + }, + environments: liveStates.map(({ environmentId, shell, canPatch }) => ({ + environmentId, + canPatch, + preferences: + shell.snapshot._tag === "Some" ? shell.snapshot.value.syncedClientPreferences : undefined, + })), + now: new Date().toISOString(), + normalizeThemeId, + }); + if (reconciliation.localPatch !== null) { + const localValues = { ...reconciliation.localPatch.values }; + if (localValues.themeId !== undefined) { + localValues.themeId = normalizeThemeId(localValues.themeId); + } + savePreferences({ + ...localValues, + syncedClientPreferencesUpdatedAtByField: reconciliation.localPatch.updatedAtByField, + }); + } + for (const target of reconciliation.environmentPatches) { + const field = SYNCED_CLIENT_PREFERENCE_FIELDS.find( + (candidate) => target.input.patch[candidate] !== undefined, + ); + if (field === undefined) continue; + reconciliationControllers[field].reconcile({ + target, + patch: patchPreferences, + persist: (patch) => + persistReconciledPreferences({ + expectedUpdatedAtByField: { [field]: target.input.updatedAt }, + patch, + }), + normalizeThemeId, + }); + } + setReconciledKey(nextReconciledKey); + }, [ + connectionsLoaded, + patchPreferences, + persistReconciledPreferences, + preferencesResult, + reconciledKey, + reconciliationKey, + reconciliationControllers, + savePreferences, + setReconciledKey, + states, + ]); +} + +function useUpdateSyncedClientPreference(field: SyncedClientPreferenceField) { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const { connectedEnvironmentIds, states } = useConnectedEnvironmentPreferenceStates(); + const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { + label: "synced client preferences update", + reportFailure: false, + }); + const writeController = useMemo( + () => createSyncedClientPreferenceWriteController(field), + [field], + ); + + return useCallback( + (patch: SyncedClientPreferencesPatch) => { + const current = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {}; + const write = writeController.create({ + patch, + connectedEnvironmentIds, + currentUpdatedAtByField: current.syncedClientPreferencesUpdatedAtByField, + legacyCurrentUpdatedAt: current.syncedClientPreferencesUpdatedAt, + authoritativePreferences: states.map(({ shell }) => + shell.snapshot._tag === "Some" ? shell.snapshot.value.syncedClientPreferences : undefined, + ), + now: new Date().toISOString(), + }); + savePreferences({ + ...write.localPatch.values, + syncedClientPreferencesUpdatedAtByField: write.localPatch.updatedAtByField, + }); + void Promise.allSettled( + write.environmentPatches.map(async (target) => { + const result = await patchPreferences(target); + const importedThemes = current.importedThemes ?? []; + const localPatch = writeController.settle({ + target, + result, + normalizeThemeId: (themeId) => + resolveLocalThemeId(themeId, importedThemes, patch.themeId), + }); + if (localPatch !== null) savePreferences(localPatch); + }), + ); + }, + [ + connectedEnvironmentIds, + patchPreferences, + preferencesResult, + savePreferences, + states, + writeController, + ], + ); +} + +export function useUpdatePlanModePreference() { + const updatePreference = useUpdateSyncedClientPreference("planModeEnabled"); + return useCallback( + (value: boolean) => updatePreference({ planModeEnabled: value }), + [updatePreference], + ); +} + +export function useUpdateAppearanceModePreference() { + const updatePreference = useUpdateSyncedClientPreference("appearanceMode"); + return useCallback( + (value: MobileAppearanceMode) => updatePreference({ appearanceMode: value }), + [updatePreference], + ); +} + +export function useUpdateThemePreference() { + const updatePreference = useUpdateSyncedClientPreference("themeId"); + return useCallback((themeId: string) => updatePreference({ themeId }), [updatePreference]); +} diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index eede506976a7..505ff93c1031 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -17,6 +17,7 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { resolveComposerInteractionMode } from "../features/threads/plan-mode"; import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; @@ -89,11 +90,28 @@ export interface ThreadSettingsSnapshot { export function resolveQueuedThreadSettings( message: QueuedThreadMessage, thread: ThreadSettingsSnapshot, + planModeEnabled: boolean, ): ThreadSettingsSnapshot { return { modelSelection: message.modelSelection ?? thread.modelSelection, runtimeMode: message.runtimeMode ?? thread.runtimeMode, - interactionMode: message.interactionMode ?? thread.interactionMode, + interactionMode: resolveComposerInteractionMode({ + interactionMode: message.interactionMode ?? thread.interactionMode, + planModeEnabled, + }), + }; +} + +export function resolveQueuedThreadSendDecision( + message: QueuedThreadMessage, + thread: ThreadSettingsSnapshot, + readPlanModeEnabled: () => boolean, +) { + const planModeEnabled = readPlanModeEnabled(); + return { + settings: resolveQueuedThreadSettings(message, thread, planModeEnabled), + readStartTurnInteractionMode: () => + resolveQueuedThreadSettings(message, thread, readPlanModeEnabled()).interactionMode, }; } diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index b12ad2dc5843..7e9383796d80 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -17,6 +17,7 @@ import { modelSelectionsEqual, resolveThreadOutboxDeliveryAction, resolveThreadOutboxFailureAction, + resolveQueuedThreadSendDecision, resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, @@ -98,11 +99,15 @@ describe("thread outbox", () => { selectedMessage, ); expect( - resolveQueuedThreadSettings(legacyMessage, { - modelSelection: selectedMessage.modelSelection, - runtimeMode: selectedMessage.runtimeMode, - interactionMode: selectedMessage.interactionMode, - }), + resolveQueuedThreadSettings( + legacyMessage, + { + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }, + true, + ), ).toEqual({ modelSelection: selectedMessage.modelSelection, runtimeMode: selectedMessage.runtimeMode, @@ -110,6 +115,60 @@ describe("thread outbox", () => { }); }); + it("forces queued plan-mode settings to default while legacy plan mode is disabled", () => { + const message = { + ...queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }), + interactionMode: "plan", + } satisfies QueuedThreadMessage; + const thread = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "approval-required", + interactionMode: "plan", + } as const; + + expect(resolveQueuedThreadSettings(message, thread, false).interactionMode).toBe("default"); + expect(resolveQueuedThreadSettings(message, thread, true).interactionMode).toBe("plan"); + }); + + it("uses one live interaction mode for metadata and turn delivery", () => { + let planModeEnabled = true; + let preferenceReads = 0; + const message = { + ...queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }), + interactionMode: "plan", + } satisfies QueuedThreadMessage; + const thread = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "approval-required", + interactionMode: "plan", + } as const; + const sendDecision = resolveQueuedThreadSendDecision(message, thread, () => { + preferenceReads += 1; + return planModeEnabled; + }); + + expect(sendDecision.settings.interactionMode).toBe("plan"); + expect(preferenceReads).toBe(1); + planModeEnabled = false; + const deliveryInteractionMode = sendDecision.readStartTurnInteractionMode(); + planModeEnabled = true; + + expect(deliveryInteractionMode).toBe("default"); + expect(preferenceReads).toBe(2); + }); + it("compares model options as part of the queued settings change", () => { const base = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 721c82a0e38e..87a6a872437d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -41,6 +41,11 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; +import { + resolveComposerEnqueueInteractionMode, + resolveComposerInteractionMode, +} from "../features/threads/plan-mode"; +import { usePlanModePreferenceState } from "../features/threads/use-plan-mode-enabled"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -78,6 +83,8 @@ export function useThreadComposerState() { const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = + usePlanModePreferenceState(); useEffect(() => { ensureComposerDraftsLoaded(); @@ -102,7 +109,10 @@ export function useThreadComposerState() { const selectedThread = selectedThreadDetail ?? selectedThreadShell; const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; - const interactionMode = selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null; + const interactionMode = resolveComposerInteractionMode({ + interactionMode: selectedDraft?.interactionMode ?? selectedThread?.interactionMode, + planModeEnabled, + }); const selectedThreadSessionActivity = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; @@ -142,6 +152,12 @@ export function useThreadComposerState() { if (text.length === 0 && attachments.length === 0) { return null; } + const enqueueInteractionMode = resolveComposerEnqueueInteractionMode({ + interactionMode: draft.interactionMode ?? thread.interactionMode, + planModeEnabled, + preferenceLoaded: planModePreferenceLoaded, + }); + if (enqueueInteractionMode === null) return null; const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); @@ -159,7 +175,7 @@ export function useThreadComposerState() { attachments, modelSelection: draft.modelSelection ?? thread.modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, - interactionMode: draft.interactionMode ?? thread.interactionMode, + interactionMode: enqueueInteractionMode, createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey); @@ -175,7 +191,7 @@ export function useThreadComposerState() { ); }); return messageId; - }, [selectedThreadDetail, selectedThreadShell]); + }, [planModeEnabled, planModePreferenceLoaded, selectedThreadDetail, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 68c973ff97e3..2e6b8c590e80 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -15,12 +15,18 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { scopedThreadKey } from "../lib/scopedEntities"; -import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; +import { + resolveComposerInteractionMode, + resolvePlanModeEnabled, +} from "../features/threads/plan-mode"; +import { usePlanModePreferenceState } from "../features/threads/use-plan-mode-enabled"; import { toUploadChatImageAttachments } from "../lib/composerImages"; +import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; +import { scopedThreadKey } from "../lib/scopedEntities"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; import { useProjects, useThreadShells } from "./entities"; +import { mobilePreferencesAtom } from "./preferences"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, @@ -31,7 +37,7 @@ import { modelSelectionsEqual, resolveThreadOutboxDeliveryAction, resolveThreadOutboxFailureAction, - resolveQueuedThreadSettings, + resolveQueuedThreadSendDecision, threadOutboxRetryDelayMs, type QueuedThreadCreation, type QueuedThreadMessage, @@ -85,6 +91,13 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma return CommandId.make(`${message.commandId}:${setting}`); } +function readPlanModeEnabled(): boolean { + const preferences = appAtomRegistry.get(mobilePreferencesAtom); + return resolvePlanModeEnabled( + AsyncResult.isSuccess(preferences) ? preferences.value.planModeEnabled : undefined, + ); +} + export function useThreadOutboxDrain(): void { const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -102,6 +115,7 @@ export function useThreadOutboxDrain(): void { const shellStatuses = useThreadOutboxShellStatuses(); const threads = useThreadShells(); const projects = useProjects(); + const { loaded: planModePreferenceLoaded } = usePlanModePreferenceState(); const { connectedEnvironments } = useRemoteConnectionStatus(); const [retryTick, setRetryTick] = useState(0); const retryAttemptRef = useRef(new Map()); @@ -167,7 +181,11 @@ export function useThreadOutboxDrain(): void { const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { - const settings = resolveQueuedThreadSettings(queuedMessage, thread); + const { settings, readStartTurnInteractionMode } = resolveQueuedThreadSendDecision( + queuedMessage, + thread, + readPlanModeEnabled, + ); const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage); if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { @@ -201,13 +219,14 @@ export function useThreadOutboxDrain(): void { } } - if (settings.interactionMode !== thread.interactionMode) { + const interactionMode = readStartTurnInteractionMode(); + if (interactionMode !== thread.interactionMode) { const interactionResult = await setThreadInteractionMode({ environmentId: queuedMessage.environmentId, input: { commandId: settingsCommandId(queuedMessage, "interaction-mode"), threadId: queuedMessage.threadId, - interactionMode: settings.interactionMode, + interactionMode, createdAt: queuedMessage.createdAt, }, }); @@ -230,7 +249,7 @@ export function useThreadOutboxDrain(): void { }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, - interactionMode: settings.interactionMode, + interactionMode, createdAt: queuedMessage.createdAt, }, }); @@ -251,6 +270,7 @@ export function useThreadOutboxDrain(): void { creation: QueuedThreadCreation, projectCwd: string, ) => { + const planModeEnabled = readPlanModeEnabled(); const modelSelection = queuedMessage.modelSelection; if (modelSelection === undefined) { return false; @@ -269,7 +289,10 @@ export function useThreadOutboxDrain(): void { attachments: queuedMessage.attachments, modelSelection, runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE, - interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + interactionMode: resolveComposerInteractionMode({ + interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + planModeEnabled, + }), workspaceMode: creation.workspaceMode, branch: creation.branch, worktreePath: creation.worktreePath, @@ -283,7 +306,8 @@ export function useThreadOutboxDrain(): void { ); useEffect(() => { - if (dispatchingQueuedMessageId !== null) { + // Resolve queued interaction modes only after persisted preferences load. + if (!planModePreferenceLoaded || dispatchingQueuedMessageId !== null) { return; } @@ -414,6 +438,7 @@ export function useThreadOutboxDrain(): void { connectedEnvironments, dispatchingQueuedMessageId, editingQueuedMessageIds, + planModePreferenceLoaded, projects, queuedMessagesByThreadKey, retryTick, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 36f348d6370a..d095b46a2f29 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -39,6 +39,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, + [WS_METHODS.syncedClientPreferencesGet]: AuthOrchestrationReadScope, + [WS_METHODS.syncedClientPreferencesPatch]: AuthOrchestrationOperateScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index fe093c451e25..628dd9070ca9 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -81,6 +81,8 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getSyncedClientPreferences: () => + Effect.die("CheckpointDiffQuery should not request synced client preferences"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -190,6 +192,8 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getSyncedClientPreferences: () => + Effect.die("CheckpointDiffQuery should not request synced client preferences"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -274,6 +278,8 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getSyncedClientPreferences: () => + Effect.die("CheckpointDiffQuery should not request synced client preferences"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -343,6 +349,8 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getSyncedClientPreferences: () => + Effect.die("CheckpointDiffQuery should not request synced client preferences"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -397,6 +405,8 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getSyncedClientPreferences: () => + Effect.die("CheckpointDiffQuery should not request synced client preferences"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 1b89d6d4d8a8..7c9d7a1a2528 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -21,6 +21,7 @@ import { describe, expect, it } from "vite-plus/test"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { @@ -60,7 +61,7 @@ async function createOrchestrationSystem() { Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), @@ -69,9 +70,15 @@ async function createOrchestrationSystem() { const runtime = ManagedRuntime.make(orchestrationLayer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); + const commandReceipts = await runtime.runPromise( + Effect.service(OrchestrationCommandReceiptRepository), + ); return { engine, readModel: () => runtime.runPromise(snapshotQuery.getSnapshot()), + shellSnapshot: () => runtime.runPromise(snapshotQuery.getShellSnapshot()), + commandReceipt: (commandId: CommandId) => + runtime.runPromise(commandReceipts.getByCommandId({ commandId })), run: (effect: Effect.Effect) => runtime.runPromise(effect), dispose: () => runtime.dispose(), }; @@ -93,6 +100,74 @@ const hasMetricSnapshot = ( ); describe("OrchestrationEngine", () => { + it("receipts and projects synced client preference patches with LWW stamps", async () => { + const system = await createOrchestrationSystem(); + const freshCommandId = CommandId.make("client-preferences-fresh"); + const staleCommandId = CommandId.make("client-preferences-stale"); + + try { + const freshResult = await system.run( + system.engine.dispatch({ + type: "client-preferences.patch", + commandId: freshCommandId, + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }), + ); + const freshReceipt = Option.getOrThrow(await system.commandReceipt(freshCommandId)); + const events = Array.from( + await system.run(Stream.runCollect(system.engine.readEvents(0, 10))), + ); + + expect(freshReceipt).toMatchObject({ + commandId: freshCommandId, + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + resultSequence: freshResult.sequence, + status: "accepted", + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "client-preferences.patched", + sequence: freshResult.sequence, + payload: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + await expect(system.shellSnapshot()).resolves.toMatchObject({ + syncedClientPreferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + + const staleResult = await system.run( + system.engine.dispatch({ + type: "client-preferences.patch", + commandId: staleCommandId, + patch: { planModeEnabled: false }, + updatedAt: "2026-08-14T11:59:59.000Z", + }), + ); + expect(Option.getOrThrow(await system.commandReceipt(staleCommandId))).toMatchObject({ + resultSequence: staleResult.sequence, + status: "accepted", + }); + await expect(system.shellSnapshot()).resolves.toMatchObject({ + snapshotSequence: staleResult.sequence, + syncedClientPreferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + } finally { + await system.dispose(); + } + }); + it("bootstraps command handling from persisted projections without reading the full snapshot", async () => { let nextSequence = 8; const eventStore: OrchestrationEventStoreShape = { @@ -189,6 +264,7 @@ describe("OrchestrationEngine", () => { threads: [], updatedAt: projectionSnapshot.updatedAt, }), + getSyncedClientPreferences: () => Effect.succeed(undefined), getArchivedShellSnapshot: () => Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index da79b4395acb..a08176e39b73 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,9 +1,4 @@ -import type { - OrchestrationEvent, - OrchestrationReadModel, - ProjectId, - ThreadId, -} from "@t3tools/contracts"; +import type { OrchestrationEvent, OrchestrationReadModel } from "@t3tools/contracts"; import { OrchestrationCommand } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -58,23 +53,25 @@ interface CommandEnvelope { startedAtMs: number; } -function commandToAggregateRef(command: OrchestrationCommand): { - readonly aggregateKind: "project" | "thread"; - readonly aggregateId: ProjectId | ThreadId; -} { +function commandToAggregateRef(command: OrchestrationCommand) { switch (command.type) { + case "client-preferences.patch": + return { + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + } as const; case "project.create": case "project.meta.update": case "project.delete": return { aggregateKind: "project", aggregateId: command.projectId, - }; + } as const; default: return { aggregateKind: "thread", aggregateId: command.threadId, - }; + } as const; } } diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..b9282cfb2345 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -159,6 +159,12 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { `; assert.deepEqual(messageRows, [{ messageId: "message-1", text: "hello" }]); + const syncedPreferenceRows = yield* sql<{ readonly singletonId: number }>` + SELECT singleton_id AS "singletonId" + FROM projection_synced_client_preferences + `; + assert.deepEqual(syncedPreferenceRows, []); + const stateRows = yield* sql<{ readonly projector: string; readonly lastAppliedSequence: number; @@ -1171,6 +1177,117 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta ); it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { + it.effect( + "rebuilds synced preferences deterministically from duplicate and out-of-order events", + () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + + yield* eventStore.append({ + type: "client-preferences.patched", + eventId: EventId.make("preferences-fresh"), + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + occurredAt: "2026-08-14T12:00:00.000Z", + commandId: CommandId.make("preferences-fresh"), + causationEventId: null, + correlationId: CommandId.make("preferences-fresh"), + metadata: {}, + payload: { + patch: { planModeEnabled: true, appearanceMode: "dark" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + yield* eventStore.append({ + type: "client-preferences.patched", + eventId: EventId.make("preferences-appearance-fresh"), + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + occurredAt: "2026-08-14T13:00:00.000Z", + commandId: CommandId.make("preferences-appearance-fresh"), + causationEventId: null, + correlationId: CommandId.make("preferences-appearance-fresh"), + metadata: {}, + payload: { + patch: { appearanceMode: "light" }, + updatedAt: "2026-08-14T13:00:00.000Z", + }, + }); + const equalStampEvent = yield* eventStore.append({ + type: "client-preferences.patched", + eventId: EventId.make("preferences-equal-stamp"), + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + occurredAt: "2026-08-14T12:00:00.000Z", + commandId: CommandId.make("preferences-equal-stamp"), + causationEventId: null, + correlationId: CommandId.make("preferences-equal-stamp"), + metadata: {}, + payload: { + patch: { planModeEnabled: false }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + yield* eventStore.append({ + type: "client-preferences.patched", + eventId: EventId.make("preferences-appearance-stale"), + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + occurredAt: "2026-08-14T12:30:00.000Z", + commandId: CommandId.make("preferences-appearance-stale"), + causationEventId: null, + correlationId: CommandId.make("preferences-appearance-stale"), + metadata: {}, + payload: { + patch: { appearanceMode: "dark" }, + updatedAt: "2026-08-14T12:30:00.000Z", + }, + }); + + yield* projectionPipeline.bootstrap; + yield* projectionPipeline.projectEvent(equalStampEvent); + yield* projectionPipeline.projectEvent(equalStampEvent); + + const readPreferences = () => + sql<{ + readonly planModeEnabled: number; + readonly planModeEnabledUpdatedAt: string; + readonly appearanceMode: string | null; + readonly appearanceModeUpdatedAt: string; + readonly updatedAt: string; + }>` + SELECT + plan_mode_enabled AS "planModeEnabled", + plan_mode_enabled_updated_at AS "planModeEnabledUpdatedAt", + appearance_mode AS "appearanceMode", + appearance_mode_updated_at AS "appearanceModeUpdatedAt", + updated_at AS "updatedAt" + FROM projection_synced_client_preferences + `; + const expected = [ + { + planModeEnabled: 0, + planModeEnabledUpdatedAt: "2026-08-14T12:00:00.000Z", + appearanceMode: "light", + appearanceModeUpdatedAt: "2026-08-14T13:00:00.000Z", + updatedAt: "2026-08-14T13:00:00.000Z", + }, + ]; + assert.deepEqual(yield* readPreferences(), expected); + + yield* sql`DELETE FROM projection_synced_client_preferences`; + yield* sql` + DELETE FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.syncedClientPreferences} + `; + yield* projectionPipeline.bootstrap; + + assert.deepEqual(yield* readPreferences(), expected); + }), + ); + it.effect("resumes from projector last_applied_sequence without replaying older events", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..3b606127d73f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -56,6 +56,7 @@ import { } from "../../attachmentStore.ts"; export const ORCHESTRATION_PROJECTOR_NAMES = { + syncedClientPreferences: "projection.synced-client-preferences", projects: "projection.projects", threads: "projection.threads", threadMessages: "projection.thread-messages", @@ -1606,7 +1607,111 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + const applySyncedClientPreferencesProjection: ProjectorDefinition["apply"] = (event) => { + if (event.type !== "client-preferences.patched") { + return Effect.void; + } + const planModeEnabled = + event.payload.patch.planModeEnabled === undefined + ? null + : Number(event.payload.patch.planModeEnabled); + const planModeEnabledUpdatedAt = + event.payload.patch.planModeEnabled === undefined ? null : event.payload.updatedAt; + const appearanceMode = event.payload.patch.appearanceMode ?? null; + const appearanceModeUpdatedAt = + event.payload.patch.appearanceMode === undefined ? null : event.payload.updatedAt; + const themeId = event.payload.patch.themeId ?? null; + const themeIdUpdatedAt = + event.payload.patch.themeId === undefined ? null : event.payload.updatedAt; + return sql` + INSERT INTO projection_synced_client_preferences ( + singleton_id, + plan_mode_enabled, + plan_mode_enabled_updated_at, + appearance_mode, + appearance_mode_updated_at, + theme_id, + theme_id_updated_at, + updated_at + ) + VALUES ( + 1, + ${planModeEnabled}, + ${planModeEnabledUpdatedAt}, + ${appearanceMode}, + ${appearanceModeUpdatedAt}, + ${themeId}, + ${themeIdUpdatedAt}, + ${event.payload.updatedAt} + ) + ON CONFLICT (singleton_id) + DO UPDATE SET + plan_mode_enabled = CASE + WHEN excluded.plan_mode_enabled_updated_at IS NOT NULL + AND ( + plan_mode_enabled_updated_at IS NULL + OR excluded.plan_mode_enabled_updated_at >= plan_mode_enabled_updated_at + ) + THEN excluded.plan_mode_enabled + ELSE plan_mode_enabled + END, + plan_mode_enabled_updated_at = CASE + WHEN excluded.plan_mode_enabled_updated_at IS NOT NULL + AND ( + plan_mode_enabled_updated_at IS NULL + OR excluded.plan_mode_enabled_updated_at >= plan_mode_enabled_updated_at + ) + THEN excluded.plan_mode_enabled_updated_at + ELSE plan_mode_enabled_updated_at + END, + appearance_mode = CASE + WHEN excluded.appearance_mode_updated_at IS NOT NULL + AND ( + appearance_mode_updated_at IS NULL + OR excluded.appearance_mode_updated_at >= appearance_mode_updated_at + ) + THEN excluded.appearance_mode + ELSE appearance_mode + END, + appearance_mode_updated_at = CASE + WHEN excluded.appearance_mode_updated_at IS NOT NULL + AND ( + appearance_mode_updated_at IS NULL + OR excluded.appearance_mode_updated_at >= appearance_mode_updated_at + ) + THEN excluded.appearance_mode_updated_at + ELSE appearance_mode_updated_at + END, + theme_id = CASE + WHEN excluded.theme_id_updated_at IS NOT NULL + AND ( + theme_id_updated_at IS NULL + OR excluded.theme_id_updated_at >= theme_id_updated_at + ) + THEN excluded.theme_id + ELSE theme_id + END, + theme_id_updated_at = CASE + WHEN excluded.theme_id_updated_at IS NOT NULL + AND ( + theme_id_updated_at IS NULL + OR excluded.theme_id_updated_at >= theme_id_updated_at + ) + THEN excluded.theme_id_updated_at + ELSE theme_id_updated_at + END, + updated_at = MAX(updated_at, excluded.updated_at) + `.pipe( + Effect.asVoid, + Effect.mapError(toPersistenceSqlError("ProjectionPipeline.syncedClientPreferences:upsert")), + ); + }; + const projectors: ReadonlyArray = [ + { + name: ORCHESTRATION_PROJECTOR_NAMES.syncedClientPreferences, + apply: applySyncedClientPreferencesProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.projects, apply: applyProjectsProjection, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c6c5ad1d7e8c..22d175e82ffa 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -24,6 +24,7 @@ import { type OrchestrationThreadShell, ModelSelection, ProjectId, + type SyncedClientPreferences, ThreadId, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; @@ -115,6 +116,15 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), }); const ProjectionStateDbRowSchema = ProjectionState; +const ProjectionSyncedClientPreferencesRowSchema = Schema.Struct({ + planModeEnabled: Schema.NullOr(Schema.Number), + planModeEnabledUpdatedAt: Schema.NullOr(IsoDateTime), + appearanceMode: Schema.NullOr(Schema.Literals(["system", "light", "dark"])), + appearanceModeUpdatedAt: Schema.NullOr(IsoDateTime), + themeId: Schema.NullOr(Schema.String), + themeIdUpdatedAt: Schema.NullOr(IsoDateTime), + updatedAt: IsoDateTime, +}); const ProjectionCountsRowSchema = Schema.Struct({ projectCount: Schema.Number, threadCount: Schema.Number, @@ -193,6 +203,7 @@ const ProjectionFullThreadDiffContextRowSchema = Schema.Struct({ }); const REQUIRED_SNAPSHOT_PROJECTORS = [ + ORCHESTRATION_PROJECTOR_NAMES.syncedClientPreferences, ORCHESTRATION_PROJECTOR_NAMES.projects, ORCHESTRATION_PROJECTOR_NAMES.threads, ORCHESTRATION_PROJECTOR_NAMES.threadMessages, @@ -209,6 +220,46 @@ function maxIso(left: string | null, right: string): string { return left > right ? left : right; } +function mapSyncedClientPreferences( + row: Option.Option>, +): SyncedClientPreferences | undefined { + return Option.match(row, { + onNone: () => undefined, + onSome: (value) => { + let preferences: SyncedClientPreferences = { + updatedAt: value.updatedAt, + }; + if (value.planModeEnabled !== null) { + preferences = { ...preferences, planModeEnabled: value.planModeEnabled !== 0 }; + } + if (value.appearanceMode !== null) { + preferences = { ...preferences, appearanceMode: value.appearanceMode }; + } + if (value.themeId !== null) { + preferences = { ...preferences, themeId: value.themeId }; + } + + let updatedAtByField: NonNullable = {}; + if (value.planModeEnabledUpdatedAt !== null) { + updatedAtByField = { + ...updatedAtByField, + planModeEnabled: value.planModeEnabledUpdatedAt, + }; + } + if (value.appearanceModeUpdatedAt !== null) { + updatedAtByField = { + ...updatedAtByField, + appearanceMode: value.appearanceModeUpdatedAt, + }; + } + if (value.themeIdUpdatedAt !== null) { + updatedAtByField = { ...updatedAtByField, themeId: value.themeIdUpdatedAt }; + } + return { ...preferences, updatedAtByField }; + }, + }); +} + function escapeLikePattern(value: string): string { return value.replaceAll("!", "!!").replaceAll("%", "!%").replaceAll("_", "!_"); } @@ -763,6 +814,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getSyncedClientPreferencesRow = SqlSchema.findOneOption({ + Request: Schema.Void, + Result: ProjectionSyncedClientPreferencesRowSchema, + execute: () => + sql` + SELECT + plan_mode_enabled AS "planModeEnabled", + plan_mode_enabled_updated_at AS "planModeEnabledUpdatedAt", + appearance_mode AS "appearanceMode", + appearance_mode_updated_at AS "appearanceModeUpdatedAt", + theme_id AS "themeId", + theme_id_updated_at AS "themeIdUpdatedAt", + updated_at AS "updatedAt" + FROM projection_synced_client_preferences + WHERE singleton_id = 1 + `, + }); + + const readSyncedClientPreferences = (operation: string) => + getSyncedClientPreferencesRow(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + `ProjectionSnapshotQuery.${operation}:query`, + `ProjectionSnapshotQuery.${operation}:decodeRow`, + ), + ), + Effect.map(mapSyncedClientPreferences), + ); + const readProjectionCounts = SqlSchema.findOne({ Request: Schema.Void, Result: ProjectionCountsRowSchema, @@ -1517,6 +1597,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + readSyncedClientPreferences("getSnapshot:getSyncedClientPreferences"), ]), ) .pipe( @@ -1531,6 +1612,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointRows, latestTurnRows, stateRows, + syncedClientPreferences, ]) => Effect.gen(function* () { const messagesByThread = new Map>(); @@ -1541,6 +1623,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const latestTurnByThread = new Map(); let updatedAt: string | null = null; + if (syncedClientPreferences !== undefined) { + updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); + } for (const row of projectRows) { updatedAt = maxIso(updatedAt, row.updatedAt); @@ -1717,6 +1802,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, + syncedClientPreferences, updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -1787,13 +1873,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + readSyncedClientPreferences("getCommandReadModel:getSyncedClientPreferences"), ]), ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + proposedPlanRows, + sessionRows, + latestTurnRows, + stateRows, + syncedClientPreferences, + ]) => Effect.sync(() => { let updatedAt: string | null = null; + if (syncedClientPreferences !== undefined) { + updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); + } const projects: OrchestrationProject[] = []; const threads: OrchestrationThread[] = []; @@ -1925,6 +2023,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, + syncedClientPreferences, updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", } satisfies OrchestrationReadModel; }), @@ -1981,96 +2080,110 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + readSyncedClientPreferences("getShellSnapshot:getSyncedClientPreferences"), ]), ) .pipe( - Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => - Effect.gen(function* () { - let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); + Effect.flatMap( + ([ + projectRows, + threadRows, + sessionRows, + latestTurnRows, + stateRows, + syncedClientPreferences, + ]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + if (syncedClientPreferences !== undefined) { + updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows); - const latestTurnByThread = new Map( - latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), - ); - const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), - ); - - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects: Arr.filterMap(projectRows, (row) => - row.deletedAt === null - ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), - ) - : Result.failVoid, - ), - threads: Arr.filterMap(threadRows, (row) => - row.deletedAt === null - ? Result.succeed({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, - ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - } satisfies OrchestrationThreadShell) - : Result.failVoid, - ), - updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; + const repositoryIdentities = + yield* resolveRepositoryIdentitiesForProjects(projectRows); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: Arr.filterMap(projectRows, (row) => + row.deletedAt === null + ? Result.succeed( + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ) + : Result.failVoid, ), - ), - ); - }), + threads: Arr.filterMap(threadRows, (row) => + row.deletedAt === null + ? Result.succeed({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + } satisfies OrchestrationThreadShell) + : Result.failVoid, + ), + syncedClientPreferences, + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + ), + ), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -2124,97 +2237,110 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + readSyncedClientPreferences("getArchivedShellSnapshot:getSyncedClientPreferences"), ]), ) .pipe( - Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => - Effect.gen(function* () { - let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); + Effect.flatMap( + ([ + projectRows, + threadRows, + sessionRows, + latestTurnRows, + stateRows, + syncedClientPreferences, + ]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + if (syncedClientPreferences !== undefined) { + updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); - const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( - projectRows.filter((row) => activeProjectIds.has(row.projectId)), - ); - const latestTurnByThread = new Map( - latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), - ); - const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), - ); - - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects: Arr.filterMap(projectRows, (row) => - row.deletedAt === null && activeProjectIds.has(row.projectId) - ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), - ) - : Result.failVoid, - ), - threads: threadRows.map( - (row): OrchestrationThreadShell => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, - ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - }), - ), - updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; + const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( + projectRows.filter((row) => activeProjectIds.has(row.projectId)), + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: Arr.filterMap(projectRows, (row) => + row.deletedAt === null && activeProjectIds.has(row.projectId) + ? Result.succeed( + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ) + : Result.failVoid, ), - ), - ); - }), + threads: threadRows.map( + (row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + }), + ), + syncedClientPreferences, + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + ), + ), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -2239,6 +2365,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { })), ); + const getSyncedClientPreferences: ProjectionSnapshotQuery["Service"]["getSyncedClientPreferences"] = + () => readSyncedClientPreferences("getSyncedClientPreferences"); + const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => readProjectionCounts(undefined).pipe( Effect.mapError( @@ -2815,6 +2944,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getCommandReadModel, getSnapshot, getShellSnapshot, + getSyncedClientPreferences, getArchivedShellSnapshot, searchThreads, getSnapshotSequence, diff --git a/apps/server/src/orchestration/Normalizer.test.ts b/apps/server/src/orchestration/Normalizer.test.ts index 113b6e1baeed..e2d2f31b1c8b 100644 --- a/apps/server/src/orchestration/Normalizer.test.ts +++ b/apps/server/src/orchestration/Normalizer.test.ts @@ -70,4 +70,29 @@ describe("canonicalizeClientCommandTimestamps", () => { expect(result.createdAt).toBe(serverReceivedAt); expect(result.bootstrap?.createThread?.createdAt).toBe(serverReceivedAt); }); + + it("falls back to server time for an excessively future-skewed preference stamp", () => { + const command: ClientOrchestrationCommand = { + type: "client-preferences.patch", + commandId: CommandId.make("command-preferences-future"), + patch: { planModeEnabled: true }, + updatedAt: "2026-07-18T00:05:00.001Z", + }; + + expect(canonicalizeClientCommandTimestamps(command, serverReceivedAt)).toEqual({ + ...command, + updatedAt: serverReceivedAt, + }); + }); + + it("preserves a preference stamp within the allowed future skew", () => { + const command: ClientOrchestrationCommand = { + type: "client-preferences.patch", + commandId: CommandId.make("command-preferences-allowed-skew"), + patch: { planModeEnabled: true }, + updatedAt: "2026-07-18T00:05:00.000Z", + }; + + expect(canonicalizeClientCommandTimestamps(command, serverReceivedAt)).toEqual(command); + }); }); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 24c65900b296..ec4e6dbee281 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -15,6 +15,8 @@ import { ServerConfig } from "../config.ts"; import { parseBase64DataUrl } from "../imageMime.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +export const SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; + export const canonicalizeClientCommandTimestamps = ( command: ClientOrchestrationCommand, receivedAt: IsoDateTime, @@ -27,6 +29,16 @@ export const canonicalizeClientCommandTimestamps = ( } : command; + if (canonicalCommand.type === "client-preferences.patch") { + const maximumUpdatedAt = + DateTime.toEpochMillis(DateTime.makeUnsafe(receivedAt)) + + SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS; + return DateTime.toEpochMillis(DateTime.makeUnsafe(canonicalCommand.updatedAt)) > + maximumUpdatedAt + ? { ...canonicalCommand, updatedAt: receivedAt } + : canonicalCommand; + } + if (canonicalCommand.type !== "thread.turn.start" || !canonicalCommand.bootstrap?.createThread) { return canonicalCommand; } diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 0a00253a2285..11f837c76f19 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -20,6 +20,7 @@ import type { OrchestrationThreadDetailWindow, OrchestrationThreadShell, ProjectId, + SyncedClientPreferences, ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -86,6 +87,12 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** Read only the singleton cross-client preferences projection. */ + readonly getSyncedClientPreferences: () => Effect.Effect< + SyncedClientPreferences | undefined, + ProjectionRepositoryError + >; + /** * Read archived thread shell summaries for the archive page. * diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6aa..2d36b1cfff15 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -224,6 +224,21 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" Crypto.Crypto > { switch (command.type) { + case "client-preferences.patch": + return { + ...(yield* withEventBase({ + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + occurredAt: command.updatedAt, + commandId: command.commandId, + })), + type: "client-preferences.patched", + payload: { + patch: command.patch, + updatedAt: command.updatedAt, + }, + }; + case "project.create": { yield* requireProjectAbsent({ readModel, diff --git a/apps/server/src/orchestration/projector.synced-client-preferences.test.ts b/apps/server/src/orchestration/projector.synced-client-preferences.test.ts new file mode 100644 index 000000000000..f8541c5020cd --- /dev/null +++ b/apps/server/src/orchestration/projector.synced-client-preferences.test.ts @@ -0,0 +1,106 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + type OrchestrationEvent, + type SyncedClientPreferencesPatch, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function preferenceEvent(input: { + readonly sequence: number; + readonly updatedAt: string; + readonly patch: SyncedClientPreferencesPatch; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`preferences-${input.sequence}`), + type: "client-preferences.patched", + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + occurredAt: input.updatedAt, + commandId: CommandId.make(`preferences-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { patch: input.patch, updatedAt: input.updatedAt }, + }; +} + +it.effect("merges synced preferences by field when a later event is globally stale", () => + Effect.gen(function* () { + const withAppearance = yield* projectEvent( + createEmptyReadModel("2026-08-14T10:00:00.000Z"), + preferenceEvent({ + sequence: 1, + updatedAt: "2026-08-14T13:00:00.000Z", + patch: { appearanceMode: "dark" }, + }), + ); + const withPlan = yield* projectEvent( + withAppearance, + preferenceEvent({ + sequence: 2, + updatedAt: "2026-08-14T12:00:00.000Z", + patch: { planModeEnabled: true }, + }), + ); + + assert.deepEqual(withPlan.syncedClientPreferences, { + planModeEnabled: true, + appearanceMode: "dark", + updatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + appearanceMode: "2026-08-14T13:00:00.000Z", + themeId: "2026-08-14T13:00:00.000Z", + }, + updatedAt: "2026-08-14T13:00:00.000Z", + }); + assert.strictEqual(withPlan.updatedAt, "2026-08-14T13:00:00.000Z"); + }), +); + +it.effect("backfills field clocks before patching legacy synced preferences", () => + Effect.gen(function* () { + const legacyUpdatedAt = "2026-08-14T10:00:00.000Z"; + const legacyModel = { + ...createEmptyReadModel(legacyUpdatedAt), + syncedClientPreferences: { + planModeEnabled: false, + appearanceMode: "light" as const, + themeId: "legacy-theme", + updatedAt: legacyUpdatedAt, + }, + }; + const withPlan = yield* projectEvent( + legacyModel, + preferenceEvent({ + sequence: 1, + updatedAt: "2026-08-14T12:00:00.000Z", + patch: { planModeEnabled: true }, + }), + ); + const withAppearance = yield* projectEvent( + withPlan, + preferenceEvent({ + sequence: 2, + updatedAt: "2026-08-14T11:00:00.000Z", + patch: { appearanceMode: "dark" }, + }), + ); + + assert.deepEqual(withAppearance.syncedClientPreferences, { + planModeEnabled: true, + appearanceMode: "dark", + themeId: "legacy-theme", + updatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + appearanceMode: "2026-08-14T11:00:00.000Z", + themeId: legacyUpdatedAt, + }, + updatedAt: "2026-08-14T12:00:00.000Z", + }); + }), +); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..d4c7547a3414 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,5 +1,7 @@ import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { + ClientPreferencesPatchedPayload, + getSyncedClientPreferenceUpdatedAt, OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, @@ -205,6 +207,68 @@ export function projectEvent( }; switch (event.type) { + case "client-preferences.patched": + return decodeForEvent( + ClientPreferencesPatchedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const current = + nextBase.syncedClientPreferences === undefined + ? undefined + : { + ...nextBase.syncedClientPreferences, + updatedAtByField: { + planModeEnabled: + nextBase.syncedClientPreferences.updatedAtByField?.planModeEnabled ?? + nextBase.syncedClientPreferences.updatedAt, + appearanceMode: + nextBase.syncedClientPreferences.updatedAtByField?.appearanceMode ?? + nextBase.syncedClientPreferences.updatedAt, + themeId: + nextBase.syncedClientPreferences.updatedAtByField?.themeId ?? + nextBase.syncedClientPreferences.updatedAt, + }, + }; + const appliesPlanMode = + payload.patch.planModeEnabled !== undefined && + (getSyncedClientPreferenceUpdatedAt(current, "planModeEnabled") ?? payload.updatedAt) <= + payload.updatedAt; + const appliesAppearanceMode = + payload.patch.appearanceMode !== undefined && + (getSyncedClientPreferenceUpdatedAt(current, "appearanceMode") ?? payload.updatedAt) <= + payload.updatedAt; + const appliesThemeId = + payload.patch.themeId !== undefined && + (getSyncedClientPreferenceUpdatedAt(current, "themeId") ?? payload.updatedAt) <= + payload.updatedAt; + return { + ...nextBase, + updatedAt: model.updatedAt > event.occurredAt ? model.updatedAt : event.occurredAt, + syncedClientPreferences: { + ...current, + ...(appliesPlanMode ? { planModeEnabled: payload.patch.planModeEnabled } : undefined), + ...(appliesAppearanceMode + ? { appearanceMode: payload.patch.appearanceMode } + : undefined), + ...(appliesThemeId ? { themeId: payload.patch.themeId } : undefined), + updatedAtByField: { + ...current?.updatedAtByField, + ...(appliesPlanMode ? { planModeEnabled: payload.updatedAt } : undefined), + ...(appliesAppearanceMode ? { appearanceMode: payload.updatedAt } : undefined), + ...(appliesThemeId ? { themeId: payload.updatedAt } : undefined), + }, + updatedAt: + current !== undefined && current.updatedAt > payload.updatedAt + ? current.updatedAt + : payload.updatedAt, + }, + }; + }), + ); + case "project.created": return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => { diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 18d0e9aa578b..97843487e3b0 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -9,6 +9,7 @@ import { OrchestrationEventMetadata, OrchestrationEventType, ProjectId, + SyncedClientPreferencesAggregateId, ThreadId, } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -35,7 +36,7 @@ const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMeta const AppendEventRequestSchema = Schema.Struct({ eventId: EventId, aggregateKind: OrchestrationAggregateKind, - streamId: Schema.Union([ProjectId, ThreadId]), + streamId: Schema.Union([SyncedClientPreferencesAggregateId, ProjectId, ThreadId]), type: OrchestrationEventType, causationEventId: Schema.NullOr(EventId), correlationId: Schema.NullOr(CommandId), @@ -51,7 +52,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ eventId: EventId, type: OrchestrationEventType, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([SyncedClientPreferencesAggregateId, ProjectId, ThreadId]), occurredAt: IsoDateTime, commandId: Schema.NullOr(CommandId), causationEventId: Schema.NullOr(EventId), diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..20743e69e08e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_ProjectionSyncedClientPreferences.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "ProjectionSyncedClientPreferences", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts new file mode 100644 index 000000000000..2db38e2ed07c --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts @@ -0,0 +1,99 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { migrationManifest, runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("041_ProjectionSyncedClientPreferences", (it) => { + it.effect("creates the projection and seeds its cursor at the existing event-log head", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 40 }); + yield* sql` + INSERT INTO orchestration_events ( + sequence, + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + 7, + 'event-before-synced-preferences-projector', + 'project', + 'project-existing', + 1, + 'project.created', + '2026-08-14T12:00:00.000Z', + 'client', + '{}', + '{}' + ) + `; + yield* sql` + CREATE TABLE projection_synced_client_preferences ( + singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), + plan_mode_enabled INTEGER, + plan_mode_enabled_updated_at TEXT, + appearance_mode TEXT, + appearance_mode_updated_at TEXT, + theme_id TEXT, + theme_id_updated_at TEXT, + updated_at TEXT NOT NULL + ) + `; + yield* runMigrations({ toMigrationInclusive: 41 }); + yield* runMigrations({ toMigrationInclusive: 41 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_synced_client_preferences) + `; + const rows = yield* sql<{ readonly singletonId: number }>` + SELECT singleton_id AS "singletonId" + FROM projection_synced_client_preferences + `; + const projectorState = yield* sql<{ + readonly projector: string; + readonly lastAppliedSequence: number; + }>` + SELECT + projector, + last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = 'projection.synced-client-preferences' + `; + + assert.deepEqual( + columns.map((column) => column.name), + [ + "singleton_id", + "plan_mode_enabled", + "plan_mode_enabled_updated_at", + "appearance_mode", + "appearance_mode_updated_at", + "theme_id", + "theme_id_updated_at", + "updated_at", + ], + ); + assert.deepEqual(rows, []); + assert.deepEqual(projectorState, [ + { + projector: "projection.synced-client-preferences", + lastAppliedSequence: 7, + }, + ]); + assert.deepEqual(migrationManifest.at(-1), [41, "ProjectionSyncedClientPreferences"]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.ts b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.ts new file mode 100644 index 000000000000..7e95b7f37d0b --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.ts @@ -0,0 +1,30 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_synced_client_preferences ( + singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), + plan_mode_enabled INTEGER, + plan_mode_enabled_updated_at TEXT, + appearance_mode TEXT, + appearance_mode_updated_at TEXT, + theme_id TEXT, + theme_id_updated_at TEXT, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + SELECT + 'projection.synced-client-preferences', + COALESCE(MAX(sequence), 0), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + FROM orchestration_events + WHERE true + ON CONFLICT (projector) DO NOTHING + `; +}); diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts index 1498984827e5..07fb70e1ead4 100644 --- a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts @@ -13,6 +13,7 @@ import { OrchestrationAggregateKind, OrchestrationCommandReceiptStatus, ProjectId, + SyncedClientPreferencesAggregateId, ThreadId, } from "@t3tools/contracts"; import * as Option from "effect/Option"; @@ -25,7 +26,7 @@ import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; export const OrchestrationCommandReceipt = Schema.Struct({ commandId: CommandId, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([SyncedClientPreferencesAggregateId, ProjectId, ThreadId]), acceptedAt: IsoDateTime, resultSequence: NonNegativeInt, status: OrchestrationCommandReceiptStatus, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 5c5da4666b0d..074e33583b01 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -29,6 +29,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getSyncedClientPreferences: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 1281b2f70fe8..af2d69ac4088 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -205,6 +205,7 @@ describe("ProviderSessionReaper", () => { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getSyncedClientPreferences: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 89f903c4f895..cb7768b31245 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -789,6 +789,7 @@ const buildAppUnderTest = (options?: { threads: [], updatedAt: "1970-01-01T00:00:00.000Z", }), + getSyncedClientPreferences: () => Effect.succeed(undefined), getArchivedShellSnapshot: () => Effect.succeed({ snapshotSequence: 0, @@ -5985,6 +5986,87 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("gates incremental client preference patches for legacy shell subscribers", () => + Effect.gen(function* () { + const canonicalPreferences = { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + let patchedEvent: OrchestrationEvent | null = null; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + if (command.type === "client-preferences.patch") { + patchedEvent = { + sequence: 1, + eventId: EventId.make("event-client-preferences-patched"), + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + occurredAt: command.updatedAt, + commandId: command.commandId, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "client-preferences.patched", + payload: { + patch: command.patch, + updatedAt: command.updatedAt, + }, + }; + } + return { sequence: 1 }; + }), + latestSequence: Effect.succeed(1), + readEvents: () => (patchedEvent === null ? Stream.empty : Stream.make(patchedEvent)), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => Effect.die("patch replay must not load a shell snapshot"), + getSyncedClientPreferences: () => Effect.succeed(canonicalPreferences), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const ack = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.syncedClientPreferencesPatch]({ + patch: { planModeEnabled: true }, + updatedAt: canonicalPreferences.updatedAt, + }), + ), + ); + const legacyItems = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.take(1), Stream.runCollect), + ), + ); + const capableItems = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + clientPreferencesStreamItem: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + assert.deepEqual(ack, canonicalPreferences); + assert.deepEqual(legacyItems, [{ kind: "synchronized" }]); + assert.deepEqual(capableItems[0], { + kind: "client-preferences-updated", + sequence: 1, + preferences: canonicalPreferences, + }); + assert.deepEqual(capableItems[1], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("marks an empty shell catch-up replay as synchronized when requested", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e3f7e482b2e0..898f1a7981ab 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -80,6 +80,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getSyncedClientPreferences: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => @@ -138,6 +139,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getSyncedClientPreferences: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -195,6 +197,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getSyncedClientPreferences: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -246,6 +249,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getSyncedClientPreferences: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 56ea24a4a8b8..632b97a29d56 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -543,10 +543,12 @@ const makeWsRpcLayer = ( }); }; - const toShellStreamEvent = ( + const toShellStreamItem = ( event: OrchestrationEvent, - ): Effect.Effect, never, never> => { + ): Effect.Effect, never, never> => { switch (event.type) { + case "client-preferences.patched": + return clientPreferencesUpdated(event.sequence); case "project.created": case "project.meta-updated": return projectUpsertOrRemove(event.payload.projectId, event.sequence); @@ -583,7 +585,7 @@ const makeWsRpcLayer = ( // If both attempts fail, log and drop the stream item; treating an error as // a missing row would incorrectly remove a still-active aggregate. const retryShellProjectionRead = ( - aggregateKind: "project" | "thread", + aggregateKind: "client-preferences" | "project" | "thread", aggregateId: string, read: Effect.Effect, ): Effect.Effect, never, never> => @@ -600,6 +602,27 @@ const makeWsRpcLayer = ( Effect.orElseSucceed(() => Option.none()), ); + const clientPreferencesUpdated = ( + sequence: number, + ): Effect.Effect, never, never> => + retryShellProjectionRead( + "client-preferences", + "client-preferences", + projectionSnapshotQuery.getSyncedClientPreferences(), + ).pipe( + Effect.map( + Option.flatMap((preferences) => + preferences === undefined + ? Option.none() + : Option.some({ + kind: "client-preferences-updated", + sequence, + preferences, + }), + ), + ), + ); + const projectUpsertOrRemove = ( projectId: ProjectId, sequence: number, @@ -669,7 +692,7 @@ const makeWsRpcLayer = ( ); // Turn a batch of domain events into shell stream items, coalescing by - // aggregate first. `toShellStreamEvent` re-reads the *current* projected + // aggregate first. `toShellStreamItem` re-reads the *current* projected // shell for an aggregate, so within a batch only the latest event per // aggregate matters: a burst of streaming `thread.message-sent` deltas for // one thread collapses into a single shell refetch, and an unrelated @@ -683,7 +706,7 @@ const makeWsRpcLayer = ( const SHELL_REFETCH_CONCURRENCY = 8; const coalesceShellEvents = ( events: ReadonlyArray, - ): Effect.Effect, never, never> => + ): Effect.Effect, never, never> => Effect.gen(function* () { if (events.length === 0) { return []; @@ -695,7 +718,7 @@ const makeWsRpcLayer = ( const survivors = Array.from(latestByAggregate.values()).sort( (left, right) => left.sequence - right.sequence, ); - const shellEvents = yield* Effect.forEach(survivors, toShellStreamEvent, { + const shellEvents = yield* Effect.forEach(survivors, toShellStreamItem, { concurrency: SHELL_REFETCH_CONCURRENCY, }); return shellEvents.flatMap((option) => (Option.isSome(option) ? [option.value] : [])); @@ -709,7 +732,7 @@ const makeWsRpcLayer = ( const SHELL_COALESCE_MAX_CHUNK = 512; const coalesceShellStream = ( stream: Stream.Stream, - ): Stream.Stream => + ): Stream.Stream => stream.pipe( Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), Stream.mapEffect(coalesceShellEvents), @@ -1209,6 +1232,14 @@ const makeWsRpcLayer = ( { startImmediately: true }, ); const bufferedLiveStream = coalesceShellLiveStream(Stream.fromQueue(liveBuffer)); + const compatibleShellStream = ( + stream: Stream.Stream, + ): Stream.Stream => + input.clientPreferencesStreamItem === true + ? stream + : stream.pipe( + Stream.filter((item) => item.kind !== "client-preferences-updated"), + ); const loadSnapshot = projectionSnapshotQuery.getShellSnapshot().pipe( Effect.tapError((cause) => @@ -1257,9 +1288,11 @@ const makeWsRpcLayer = ( // no-afterSequence path does. if (replayGap < 0 || replayGap > SHELL_RESUME_MAX_GAP) { const snapshot = yield* loadSnapshot; - return Stream.concat( - Stream.make({ kind: "snapshot" as const, snapshot }), - synchronizedThenLive, + return compatibleShellStream( + Stream.concat( + Stream.make({ kind: "snapshot" as const, snapshot }), + synchronizedThenLive, + ), ); } const catchUpStream = coalesceShellStream( @@ -1277,16 +1310,18 @@ const makeWsRpcLayer = ( }), ), ); - return Stream.concat(catchUpStream, synchronizedThenLive); + return compatibleShellStream(Stream.concat(catchUpStream, synchronizedThenLive)); } const snapshot = yield* loadSnapshot; - return Stream.concat( - Stream.make({ - kind: "snapshot" as const, - snapshot, - }), - synchronizedThenLive, + return compatibleShellStream( + Stream.concat( + Stream.make({ + kind: "snapshot" as const, + snapshot, + }), + synchronizedThenLive, + ), ); }), { "rpc.aggregate": "orchestration" }, @@ -1531,6 +1566,50 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.syncedClientPreferencesGet]: (_input) => + observeRpcEffect( + WS_METHODS.syncedClientPreferencesGet, + projectionSnapshotQuery.getSyncedClientPreferences().pipe( + Effect.map((preferences) => preferences ?? null), + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load synced client preferences", + cause, + }), + ), + ), + { "rpc.aggregate": "client-preferences" }, + ), + [WS_METHODS.syncedClientPreferencesPatch]: ({ patch, updatedAt }) => + observeRpcEffect( + WS_METHODS.syncedClientPreferencesPatch, + Effect.gen(function* () { + const normalizedCommand = yield* normalizeDispatchCommand({ + type: "client-preferences.patch", + commandId: yield* serverCommandId("client-preferences-patch"), + patch, + updatedAt, + }); + yield* dispatchNormalizedCommand(normalizedCommand); + const preferences = yield* projectionSnapshotQuery.getSyncedClientPreferences().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load patched client preferences", + cause, + }), + ), + ); + if (preferences === undefined) { + return yield* new OrchestrationGetSnapshotError({ + message: "Synced client preferences projection is missing after patch", + }); + } + return preferences; + }), + { "rpc.aggregate": "client-preferences" }, + ), [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( WS_METHODS.serverDiscoverSourceControl, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d57a4da1c2f0..129ac8736d76 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -59,10 +59,13 @@ import { readAppearanceModePreference, readThemeHalves, readThemePreference, - useTheme, } from "../../hooks/useTheme"; import { useLocalStorage } from "../../hooks/useLocalStorage"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { + usePrimarySettings, + useSyncedTheme, + useUpdatePrimarySettings, +} from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { @@ -459,7 +462,7 @@ export function useSettingsRestore(onRestored?: () => void) { setThemeHalf, clearThemeHalves, themeHalves, - } = useTheme(); + } = useSyncedTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -954,7 +957,7 @@ export function AppearanceSettingsPanel() { setThemeHalf, theme, themeHalves, - } = useTheme(); + } = useSyncedTheme(); const customThemes = useCustomThemes(); const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); const settings = usePrimarySettings(); diff --git a/apps/web/src/components/settings/ThemeEditorHost.tsx b/apps/web/src/components/settings/ThemeEditorHost.tsx index faf2d770e90d..8ac7610be249 100644 --- a/apps/web/src/components/settings/ThemeEditorHost.tsx +++ b/apps/web/src/components/settings/ThemeEditorHost.tsx @@ -1,6 +1,6 @@ import { useCallback } from "react"; -import { useTheme } from "../../hooks/useTheme"; +import { useSyncedTheme } from "../../hooks/useSettings"; import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { ThemeEditorPanel } from "./ThemeEditorPanel"; @@ -14,7 +14,7 @@ import { useThemeEditorStore } from "./themeEditorStore"; export function ThemeEditorHost() { const session = useThemeEditorStore((store) => store.session); const closeThemeEditor = useThemeEditorStore((store) => store.closeThemeEditor); - const { theme, setTheme, themeHalves, refreshTheme } = useTheme(); + const { theme, setTheme, themeHalves, refreshTheme } = useSyncedTheme(); // The panel reports which path it actually took: a theme removed while its // editor is open resolves to null there, so the save becomes a create even diff --git a/apps/web/src/hooks/synced-client-preferences.test.ts b/apps/web/src/hooks/synced-client-preferences.test.ts new file mode 100644 index 000000000000..913d84465f36 --- /dev/null +++ b/apps/web/src/hooks/synced-client-preferences.test.ts @@ -0,0 +1,1076 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + createSyncedClientPreferenceHydrationController, + createSyncedClientPreferenceWrite, + createSyncedClientPreferencesSliceAtom, + resolveSyncedClientPreferenceHydrationAction, + type SyncedClientPreferenceHydrationInput, + useSyncedClientPreferenceHydrationEffect, +} from "./synced-client-preferences"; + +function createHookTestRoot() { + const noop = () => {}; + class TestHTMLElement { + readonly nodeType = 1; + } + class TestHtmlIFrameElement extends TestHTMLElement {} + const document = { + nodeType: 9, + addEventListener: noop, + removeEventListener: noop, + defaultView: globalThis, + activeElement: null, + body: null, + documentElement: null, + }; + const container = { + nodeType: 1, + nodeName: "DIV", + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener: noop, + removeEventListener: noop, + firstChild: null, + lastChild: null, + parentNode: null, + textContent: "", + }; + Object.assign(document, { body: container, documentElement: container }); + vi.stubGlobal("window", globalThis); + vi.stubGlobal("document", document); + vi.stubGlobal("HTMLElement", TestHTMLElement); + vi.stubGlobal("HTMLIFrameElement", TestHtmlIFrameElement); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + // SAFETY: this hook-only fake supplies every Element field ReactDOM reads in these tests. + return createRoot(container as Element & typeof container); +} + +describe("synced client preferences", () => { + it("adopts an environment value over the local cache", () => { + expect( + resolveSyncedClientPreferenceHydrationAction({ + field: "planModeEnabled", + clientHydrated: true, + clientValue: false, + serverPreferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + seedPending: false, + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ + type: "adopt", + value: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }); + }); + + it("seeds a missing environment value once from the local cache", () => { + expect( + resolveSyncedClientPreferenceHydrationAction({ + field: "planModeEnabled", + clientHydrated: true, + clientValue: true, + serverPreferences: { + appearanceMode: "dark", + updatedAt: "2026-08-14T12:00:00.000Z", + }, + seedPending: false, + now: "2026-08-14T11:00:00.000Z", + }), + ).toEqual({ + type: "seed", + value: true, + updatedAt: "2026-08-14T11:00:00.000Z", + }); + expect( + resolveSyncedClientPreferenceHydrationAction({ + field: "planModeEnabled", + clientHydrated: true, + clientValue: true, + serverPreferences: undefined, + seedPending: true, + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ type: "none" }); + }); + + it("writes one stamped value to the local and environment stores", () => { + expect( + createSyncedClientPreferenceWrite({ + field: "planModeEnabled", + value: false, + serverPreferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + now: "2026-08-14T12:01:00.000Z", + }).request, + ).toEqual({ + patch: { planModeEnabled: false }, + updatedAt: "2026-08-14T12:01:00.000Z", + }); + }); + + it("advances writes from the plan clock instead of a newer appearance clock", () => { + expect( + createSyncedClientPreferenceWrite({ + field: "planModeEnabled", + value: false, + serverPreferences: { + planModeEnabled: true, + appearanceMode: "dark", + updatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + appearanceMode: "2026-08-14T13:00:00.000Z", + }, + updatedAt: "2026-08-14T13:00:00.000Z", + }, + now: "2026-08-14T12:30:00.000Z", + }).request, + ).toMatchObject({ updatedAt: "2026-08-14T12:30:00.000Z" }); + }); + + it("preserves an opaque server theme across an unrelated plan write", () => { + expect( + createSyncedClientPreferenceWrite({ + field: "planModeEnabled", + value: true, + serverPreferences: { + planModeEnabled: false, + themeId: "custom-from-mobile", + updatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + themeId: "2026-08-14T13:00:00.000Z", + }, + updatedAt: "2026-08-14T13:00:00.000Z", + }, + now: "2026-08-14T12:30:00.000Z", + }).request, + ).toEqual({ + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:30:00.000Z", + }); + }); + + it("advances theme writes from the theme clock instead of a newer appearance clock", () => { + expect( + createSyncedClientPreferenceWrite({ + field: "themeId", + value: "ocean", + serverPreferences: { + appearanceMode: "dark", + themeId: "iris", + updatedAtByField: { + appearanceMode: "2026-08-14T13:00:00.000Z", + themeId: "2026-08-14T12:00:00.000Z", + }, + updatedAt: "2026-08-14T13:00:00.000Z", + }, + now: "2026-08-14T12:30:00.000Z", + }), + ).toEqual({ + request: { + patch: { themeId: "ocean" }, + updatedAt: "2026-08-14T12:30:00.000Z", + }, + }); + }); + + it("adopts appearance with its field stamp, independent of newer theme state", () => { + expect( + resolveSyncedClientPreferenceHydrationAction({ + field: "appearanceMode", + clientHydrated: true, + clientValue: "light", + serverPreferences: { + appearanceMode: "dark", + themeId: "ocean", + updatedAtByField: { + appearanceMode: "2026-08-14T12:00:00.000Z", + themeId: "2026-08-14T13:00:00.000Z", + }, + updatedAt: "2026-08-14T13:00:00.000Z", + }, + seedPending: false, + now: "2026-08-14T13:01:00.000Z", + }), + ).toEqual({ + type: "adopt", + value: "dark", + updatedAt: "2026-08-14T12:00:00.000Z", + }); + }); + + it("does not synchronize a device-local theme without a built-in client value", () => { + expect( + resolveSyncedClientPreferenceHydrationAction({ + field: "themeId", + clientHydrated: true, + clientValue: undefined, + serverPreferences: { + themeId: "iris", + updatedAt: "2026-08-14T12:00:00.000Z", + }, + seedPending: false, + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ type: "none" }); + }); + + it("does not re-adopt stale server state while a local write is pending", () => { + expect( + resolveSyncedClientPreferenceHydrationAction({ + field: "planModeEnabled", + clientHydrated: true, + clientValue: false, + serverPreferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + seedPending: false, + writePending: { + value: false, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ type: "none" }); + }); + + it("keeps divergent values stable across primary and secondary synchronization", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); + const persisted: boolean[] = []; + let localValue = false; + const persist = (value: boolean) => { + if (localValue === value) return; + localValue = value; + persisted.push(value); + }; + const patch = vi.fn(async () => + AsyncResult.success({ + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }), + ); + const primaryOwner = Symbol(); + const secondaryOwner = Symbol(); + let deactivatePrimary: (() => void) | undefined; + for (let render = 0; render < 10; render += 1) { + deactivatePrimary?.(); + deactivatePrimary = controller.synchronize( + { + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch, + persist, + }, + primaryOwner, + ); + controller.synchronize( + { + environmentId: secondaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T12:02:00.000Z", + }, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch, + persist, + }, + secondaryOwner, + ); + } + deactivatePrimary?.(); + + expect(localValue).toBe(true); + expect(persisted).toEqual([true]); + expect(patch).not.toHaveBeenCalled(); + }); + + it("settles a pending write from an older canonical ack without re-patching", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); + let localValue = false; + const persist = (value: boolean) => { + localValue = value; + }; + const canonical = { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:30.000Z", + } as const; + const previous = { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const patch = vi.fn(async () => AsyncResult.success(canonical)); + + controller.synchronize({ + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: previous, + canPatch: true, + now: "2026-08-14T12:00:01.000Z", + patch, + persist, + }); + persist(false); + controller.write({ + environmentId: primaryEnvironmentId, + value: false, + serverPreferences: previous, + canPatch: true, + now: "2099-01-01T00:00:00.000Z", + patch, + persist, + }); + await Promise.resolve(); + + controller.synchronize({ + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: previous, + canPatch: true, + now: "2099-01-01T00:00:01.000Z", + patch, + persist, + }); + for (let render = 0; render < 3; render += 1) { + controller.synchronize({ + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: canonical, + canPatch: true, + now: "2099-01-01T00:00:01.000Z", + patch, + persist, + }); + } + await Promise.resolve(); + + expect(patch).toHaveBeenCalledTimes(1); + expect(localValue).toBe(false); + }); + + it("does not seed preferences without orchestration operate scope", () => { + const primaryEnvironmentId = EnvironmentId.make("read-only"); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); + const patch = vi.fn(async () => + AsyncResult.success({ + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + }), + ); + + for (let render = 0; render < 3; render += 1) { + controller.synchronize({ + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: false, + live: true, + serverPreferences: undefined, + canPatch: false, + now: "2026-08-14T12:00:00.000Z", + patch, + persist: vi.fn(), + }); + } + controller.write({ + environmentId: primaryEnvironmentId, + value: true, + serverPreferences: undefined, + canPatch: false, + now: "2026-08-14T12:01:00.000Z", + patch, + persist: vi.fn(), + }); + + expect(patch).not.toHaveBeenCalled(); + }); + + it("keeps the local fallback when server preferences are read-only", () => { + const primaryEnvironmentId = EnvironmentId.make("read-only"); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); + const persist = vi.fn(); + const patch = vi.fn(); + + controller.synchronize({ + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: false, + live: true, + serverPreferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + canPatch: false, + now: "2026-08-14T12:01:00.000Z", + patch, + persist, + }); + + expect(persist).not.toHaveBeenCalled(); + expect(patch).not.toHaveBeenCalled(); + }); + + it("uploads an offline write when patch access becomes available", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); + const previous = { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const patch = vi.fn(async (target) => + AsyncResult.success({ + planModeEnabled: target.input.patch.planModeEnabled, + updatedAt: target.input.updatedAt, + }), + ); + + controller.write({ + environmentId: primaryEnvironmentId, + value: true, + serverPreferences: previous, + canPatch: false, + now: "2026-08-14T12:01:00.000Z", + patch, + persist: vi.fn(), + }); + controller.synchronize({ + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: true, + live: true, + serverPreferences: previous, + canPatch: true, + now: "2026-08-14T12:02:00.000Z", + patch, + persist: vi.fn(), + }); + await Promise.resolve(); + + expect(patch).toHaveBeenCalledWith({ + environmentId: primaryEnvironmentId, + input: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }); + }); + + it.each(["write", "seed"] as const)("retries a failed %s patch", async (kind) => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const scheduledRetries: Array<() => void> = []; + const controller = createSyncedClientPreferenceHydrationController( + "planModeEnabled", + (retry) => { + scheduledRetries.push(retry); + return vi.fn(); + }, + ); + const previous = { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const patch = vi + .fn() + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail("offline"))) + .mockImplementation(async (target) => + AsyncResult.success({ + planModeEnabled: target.input.patch.planModeEnabled, + updatedAt: target.input.updatedAt, + }), + ); + const hydrationInput = { + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: true, + live: true, + serverPreferences: kind === "seed" ? undefined : previous, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch, + persist: vi.fn(), + } satisfies SyncedClientPreferenceHydrationInput<"planModeEnabled", string>; + + controller.synchronize(hydrationInput); + if (kind === "write") { + controller.write({ + environmentId: primaryEnvironmentId, + value: true, + serverPreferences: previous, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch, + persist: hydrationInput.persist, + }); + } + await Promise.resolve(); + + expect(scheduledRetries).toHaveLength(1); + scheduledRetries[0]?.(); + await Promise.resolve(); + expect(patch).toHaveBeenCalledTimes(2); + controller.reset(); + }); + + it("bounds automatic patch retries", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const scheduledRetries: Array<{ readonly delayMs: number; readonly run: () => void }> = []; + const controller = createSyncedClientPreferenceHydrationController( + "planModeEnabled", + (retry, delayMs) => { + scheduledRetries.push({ delayMs, run: retry }); + return vi.fn(); + }, + ); + const previous = { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const patch = vi.fn().mockResolvedValue(AsyncResult.failure(Cause.fail("offline"))); + const hydrationInput = { + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: false, + live: true, + serverPreferences: previous, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch, + persist: vi.fn(), + } satisfies SyncedClientPreferenceHydrationInput<"planModeEnabled", string>; + + controller.synchronize(hydrationInput); + controller.write({ + environmentId: primaryEnvironmentId, + value: true, + serverPreferences: previous, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch, + persist: hydrationInput.persist, + }); + await Promise.resolve(); + scheduledRetries[0]?.run(); + await Promise.resolve(); + scheduledRetries[1]?.run(); + await Promise.resolve(); + + expect(patch).toHaveBeenCalledTimes(3); + expect(scheduledRetries.map(({ delayMs }) => delayMs)).toEqual([1_000, 2_000]); + controller.reset(); + }); + + it("resets an exhausted retry budget only for a new theme write", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const scheduledRetries: Array<() => void> = []; + const controller = createSyncedClientPreferenceHydrationController("themeId", (retry) => { + scheduledRetries.push(retry); + return vi.fn(); + }); + const previous = { + themeId: "iris", + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const patch = vi.fn().mockResolvedValue(AsyncResult.failure(Cause.fail("offline"))); + const hydrationInput = { + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: "ocean", + live: true, + serverPreferences: previous, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch, + persist: vi.fn(), + } satisfies SyncedClientPreferenceHydrationInput<"themeId", string>; + + controller.synchronize(hydrationInput); + controller.write({ + environmentId: primaryEnvironmentId, + value: "ocean", + serverPreferences: previous, + canPatch: true, + now: hydrationInput.now, + patch, + persist: hydrationInput.persist, + }); + await Promise.resolve(); + scheduledRetries[0]?.(); + await Promise.resolve(); + scheduledRetries[1]?.(); + await Promise.resolve(); + + controller.synchronize({ + ...hydrationInput, + now: "2026-08-14T12:02:00.000Z", + }); + await Promise.resolve(); + + expect(patch).toHaveBeenCalledTimes(3); + + controller.write({ + environmentId: primaryEnvironmentId, + value: "t3", + serverPreferences: previous, + canPatch: true, + now: "2026-08-14T12:03:00.000Z", + patch, + persist: hydrationInput.persist, + }); + await Promise.resolve(); + + expect(patch).toHaveBeenCalledTimes(4); + controller.reset(); + }); + + it("does not retry or persist a failed patch after changing primaries", async () => { + const previousPrimaryEnvironmentId = EnvironmentId.make("previous-primary"); + const nextPrimaryEnvironmentId = EnvironmentId.make("next-primary"); + const scheduledRetries: Array<() => void> = []; + const controller = createSyncedClientPreferenceHydrationController( + "planModeEnabled", + (retry) => { + let cancelled = false; + scheduledRetries.push(() => { + if (!cancelled) retry(); + }); + return () => { + cancelled = true; + }; + }, + ); + const patch = vi + .fn["patch"]>() + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail("offline"))) + .mockResolvedValue( + AsyncResult.success({ + planModeEnabled: true, + updatedAt: "2026-08-14T12:02:00.000Z", + }), + ); + const persist = vi.fn(); + const input = { + environmentId: previousPrimaryEnvironmentId, + primaryEnvironmentId: previousPrimaryEnvironmentId, + clientHydrated: true, + clientValue: false, + live: true, + serverPreferences: undefined, + canPatch: true, + now: "2026-08-14T12:00:00.000Z", + patch, + persist, + } satisfies SyncedClientPreferenceHydrationInput<"planModeEnabled", string>; + const owner = Symbol(); + const deactivate = controller.synchronize(input, owner); + await Promise.resolve(); + expect(scheduledRetries).toHaveLength(1); + + deactivate?.(); + controller.synchronize( + { + ...input, + environmentId: nextPrimaryEnvironmentId, + primaryEnvironmentId: nextPrimaryEnvironmentId, + serverPreferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }, + owner, + ); + scheduledRetries[0]?.(); + await Promise.resolve(); + + expect(patch).toHaveBeenCalledOnce(); + expect(persist).not.toHaveBeenCalled(); + }); + + it.each([ + { switchPrimary: true, expectedPersisted: [] }, + { switchPrimary: false, expectedPersisted: [true] }, + ])( + "persists a successful patch only while its environment remains active ($switchPrimary)", + async ({ switchPrimary, expectedPersisted }) => { + const previousPrimaryEnvironmentId = EnvironmentId.make("previous-primary"); + const nextPrimaryEnvironmentId = EnvironmentId.make("next-primary"); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); + let resolvePatch!: ( + result: Awaited< + ReturnType["patch"]> + >, + ) => void; + const patch = vi.fn["patch"]>( + () => + new Promise((resolve) => { + resolvePatch = resolve; + }), + ); + const persisted: boolean[] = []; + const persist = (value: boolean) => persisted.push(value); + const input = { + environmentId: previousPrimaryEnvironmentId, + primaryEnvironmentId: previousPrimaryEnvironmentId, + clientHydrated: true, + clientValue: true, + live: true, + serverPreferences: undefined, + canPatch: true, + now: "2026-08-14T12:00:00.000Z", + patch, + persist, + } satisfies SyncedClientPreferenceHydrationInput<"planModeEnabled", never>; + const owner = Symbol(); + const deactivate = controller.synchronize(input, owner); + expect(patch).toHaveBeenCalledOnce(); + + if (switchPrimary) { + deactivate?.(); + controller.synchronize( + { + ...input, + environmentId: nextPrimaryEnvironmentId, + primaryEnvironmentId: nextPrimaryEnvironmentId, + clientValue: false, + serverPreferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }, + owner, + ); + } + + resolvePatch( + AsyncResult.success({ + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }), + ); + await Promise.resolve(); + + expect(persisted).toEqual(expectedPersisted); + if (!switchPrimary) deactivate?.(); + }, + ); + + it("does not adopt stale shell preferences after an inactive patch settles", async () => { + type HydrationInput = SyncedClientPreferenceHydrationInput<"planModeEnabled", never>; + + const environmentId = EnvironmentId.make("primary"); + const nextPrimaryEnvironmentId = EnvironmentId.make("next-primary"); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); + const previous = { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const stale = { + planModeEnabled: false, + updatedAt: "2026-08-14T12:01:00.000Z", + } as const; + let resolvePatch!: (result: Awaited>) => void; + const patch = vi.fn( + () => + new Promise((resolve) => { + resolvePatch = resolve; + }), + ); + let localValue = false; + const persisted: boolean[] = []; + const persist = (value: boolean) => { + localValue = value; + persisted.push(value); + }; + const HydrationHook = ({ input }: { input: HydrationInput }) => { + useSyncedClientPreferenceHydrationEffect(controller, input); + return null; + }; + const root = createHookTestRoot(); + + try { + const input = { + environmentId, + primaryEnvironmentId: environmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: previous, + canPatch: true, + now: previous.updatedAt, + patch, + persist, + } satisfies HydrationInput; + + await act(async () => { + root.render(createElement(HydrationHook, { input })); + }); + + localValue = true; + controller.write({ + environmentId, + value: localValue, + serverPreferences: previous, + canPatch: true, + now: "2026-08-14T12:02:00.000Z", + patch, + persist, + }); + await act(async () => { + root.render( + createElement(HydrationHook, { + input: { + ...input, + environmentId: nextPrimaryEnvironmentId, + primaryEnvironmentId: nextPrimaryEnvironmentId, + clientValue: false, + serverPreferences: stale, + }, + }), + ); + }); + await act(async () => { + resolvePatch( + AsyncResult.success({ + planModeEnabled: true, + updatedAt: "2026-08-14T12:02:00.000Z", + }), + ); + await Promise.resolve(); + }); + + expect(persisted).toEqual([]); + + await act(async () => { + root.render( + createElement(HydrationHook, { + input: { + ...input, + clientValue: localValue, + serverPreferences: stale, + now: "2026-08-14T12:03:00.000Z", + }, + }), + ); + }); + + expect(localValue).toBe(true); + expect(persisted).toEqual([]); + expect(patch).toHaveBeenCalledOnce(); + } finally { + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + } + }); + + it("keeps the latest rapid toggle when responses settle out of order", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); + const previous = { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const targets: Array< + Parameters["patch"]>[0] + > = []; + const resolvePatches: Array< + ( + result: Awaited< + ReturnType["patch"]> + >, + ) => void + > = []; + const patch: SyncedClientPreferenceHydrationInput<"planModeEnabled", never>["patch"] = ( + target, + ) => + new Promise((resolve) => { + targets.push(target); + resolvePatches.push(resolve); + }); + const persisted: boolean[] = []; + const persist = (value: boolean) => persisted.push(value); + + controller.synchronize({ + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: false, + live: true, + serverPreferences: previous, + canPatch: true, + now: previous.updatedAt, + patch, + persist, + }); + controller.write({ + environmentId: primaryEnvironmentId, + value: true, + serverPreferences: previous, + canPatch: true, + now: previous.updatedAt, + patch, + persist, + }); + controller.write({ + environmentId: primaryEnvironmentId, + value: false, + serverPreferences: previous, + canPatch: true, + now: previous.updatedAt, + patch, + persist, + }); + + expect(targets.map((target) => target.input.updatedAt)).toEqual([ + "2026-08-14T12:00:00.001Z", + "2026-08-14T12:00:00.002Z", + ]); + resolvePatches[1]?.( + AsyncResult.success({ planModeEnabled: false, updatedAt: targets[1]!.input.updatedAt }), + ); + await Promise.resolve(); + resolvePatches[0]?.( + AsyncResult.success({ planModeEnabled: true, updatedAt: targets[0]!.input.updatedAt }), + ); + await Promise.resolve(); + + expect(persisted).toEqual([false]); + }); + + it("ignores a seed acknowledgement after a newer write is queued", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); + const targets: Array< + Parameters["patch"]>[0] + > = []; + const resolvePatches: Array< + ( + result: Awaited< + ReturnType["patch"]> + >, + ) => void + > = []; + const patch: SyncedClientPreferenceHydrationInput<"planModeEnabled", never>["patch"] = ( + target, + ) => + new Promise((resolve) => { + targets.push(target); + resolvePatches.push(resolve); + }); + const persisted: boolean[] = []; + const persist = (value: boolean) => persisted.push(value); + + controller.synchronize({ + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: false, + live: true, + serverPreferences: undefined, + canPatch: true, + now: "2026-08-14T12:00:00.000Z", + patch, + persist, + }); + controller.write({ + environmentId: primaryEnvironmentId, + value: true, + serverPreferences: undefined, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch, + persist, + }); + + resolvePatches[0]?.( + AsyncResult.success({ planModeEnabled: false, updatedAt: targets[0]!.input.updatedAt }), + ); + await Promise.resolve(); + expect(persisted).toEqual([]); + + resolvePatches[1]?.( + AsyncResult.success({ planModeEnabled: true, updatedAt: targets[1]!.input.updatedAt }), + ); + await Promise.resolve(); + expect(persisted).toEqual([true]); + }); + + it("keeps the synced preference atom stable across thread-only shell updates", () => { + const preferences = { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const shellStateAtom = Atom.make({ + status: "live", + error: Option.none(), + snapshot: Option.some({ + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-08-14T12:00:00.000Z", + syncedClientPreferences: preferences, + }), + }); + const sliceAtom = createSyncedClientPreferencesSliceAtom(shellStateAtom); + const registry = AtomRegistry.make(); + const unmount = registry.mount(sliceAtom); + const first = registry.get(sliceAtom); + + registry.set(shellStateAtom, { + status: "live", + error: Option.none(), + snapshot: Option.some({ + snapshotSequence: 2, + projects: [], + threads: [], + updatedAt: "2026-08-14T12:00:01.000Z", + syncedClientPreferences: preferences, + }), + }); + + expect(registry.get(sliceAtom)).toBe(first); + unmount(); + }); +}); diff --git a/apps/web/src/hooks/synced-client-preferences.ts b/apps/web/src/hooks/synced-client-preferences.ts new file mode 100644 index 000000000000..52ca290b3978 --- /dev/null +++ b/apps/web/src/hooks/synced-client-preferences.ts @@ -0,0 +1,509 @@ +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; +import { + getSyncedClientPreferenceUpdatedAt, + nextSyncedClientPreferencesUpdatedAt, + type EnvironmentId, + type PatchSyncedClientPreferencesRequest, + type SyncedClientPreferenceField, + type SyncedClientPreferences, + type SyncedClientPreferencesPatch, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useMemo } from "react"; + +export const SHELL_NOT_LIVE = Symbol("shell-not-live"); + +export function createSyncedClientPreferencesSliceAtom( + shellStateAtom: Atom.Atom, +) { + return Atom.make((get): SyncedClientPreferences | undefined | typeof SHELL_NOT_LIVE => { + const shell = get(shellStateAtom); + if (shell.status !== "live") return SHELL_NOT_LIVE; + return Option.getOrNull(shell.snapshot)?.syncedClientPreferences; + }); +} + +type SyncedClientPreferenceValue = Exclude< + SyncedClientPreferences[Field], + undefined +>; + +type MutableSyncedClientPreferencesPatch = { + -readonly [Field in SyncedClientPreferenceField]?: SyncedClientPreferencesPatch[Field]; +}; + +function syncedClientPreferencePatch( + field: Field, + value: SyncedClientPreferenceValue, +): SyncedClientPreferencesPatch { + const patch: MutableSyncedClientPreferencesPatch = {}; + patch[field] = value; + return patch; +} + +function syncedClientPreferenceValue( + preferences: SyncedClientPreferences | undefined, + field: Field, +): SyncedClientPreferenceValue | undefined { + if (preferences === undefined) return undefined; + // SAFETY: SyncedClientPreferenceField names the same value-bearing keys in both owner contracts. + return preferences[field] as SyncedClientPreferenceValue | undefined; +} + +export function createSyncedClientPreferenceWrite< + Field extends SyncedClientPreferenceField, +>(input: { + readonly field: Field; + readonly value: SyncedClientPreferenceValue; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly pendingUpdatedAt?: string; + readonly now: string; +}) { + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt(input.serverPreferences, input.field); + const currentUpdatedAt = + input.pendingUpdatedAt !== undefined && + (serverUpdatedAt === undefined || input.pendingUpdatedAt > serverUpdatedAt) + ? input.pendingUpdatedAt + : serverUpdatedAt; + return { + request: { + patch: syncedClientPreferencePatch(input.field, input.value), + updatedAt: nextSyncedClientPreferencesUpdatedAt([currentUpdatedAt], input.now), + }, + } as const; +} + +type SyncedClientPreferencePatchTarget = { + readonly environmentId: EnvironmentId; + readonly input: PatchSyncedClientPreferencesRequest; +}; + +type SyncedClientPreferencePatch = ( + target: SyncedClientPreferencePatchTarget, +) => Promise>; + +export interface SyncedClientPreferenceHydrationInput< + Field extends SyncedClientPreferenceField, + E, +> { + readonly environmentId: EnvironmentId | null; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly clientHydrated: boolean; + readonly clientValue: SyncedClientPreferenceValue | undefined; + readonly live: boolean; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly canPatch: boolean; + readonly now: string; + readonly patch: SyncedClientPreferencePatch; + readonly persist: (value: SyncedClientPreferenceValue) => void; +} + +export type SyncedClientPreferenceHydrationAction = + | { readonly type: "none" } + | { + readonly type: "adopt"; + readonly value: SyncedClientPreferenceValue; + readonly updatedAt: string; + } + | { + readonly type: "seed"; + readonly value: SyncedClientPreferenceValue; + readonly updatedAt: string; + }; + +export function resolveSyncedClientPreferenceHydrationAction< + Field extends SyncedClientPreferenceField, +>(input: { + readonly field: Field; + readonly clientHydrated: boolean; + readonly clientValue: SyncedClientPreferenceValue | undefined; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly seedPending: boolean; + readonly writePending?: { + readonly value: SyncedClientPreferenceValue; + readonly updatedAt: string; + }; + readonly adoptedUpdatedAt?: string; + readonly now: string; +}): SyncedClientPreferenceHydrationAction { + if (!input.clientHydrated || input.clientValue === undefined) return { type: "none" }; + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt(input.serverPreferences, input.field); + if ( + input.writePending !== undefined && + (serverUpdatedAt === undefined || serverUpdatedAt < input.writePending.updatedAt) + ) { + return { type: "none" }; + } + const serverPreferences = input.serverPreferences; + const serverValue = syncedClientPreferenceValue(serverPreferences, input.field); + if (serverValue !== undefined) { + return input.adoptedUpdatedAt !== undefined && + serverUpdatedAt !== undefined && + serverUpdatedAt <= input.adoptedUpdatedAt + ? { type: "none" } + : { + type: "adopt", + value: serverValue, + updatedAt: serverUpdatedAt ?? serverPreferences!.updatedAt, + }; + } + if (input.seedPending) return { type: "none" }; + return { + type: "seed", + value: input.clientValue, + updatedAt: nextSyncedClientPreferencesUpdatedAt([serverUpdatedAt], input.now), + }; +} + +const SYNCED_CLIENT_PREFERENCE_RETRY_DELAY_MS = 1_000; +const SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS = 3; + +type SyncedClientPreferenceRetryScheduler = (retry: () => void, delayMs: number) => () => void; + +const scheduleSyncedClientPreferenceRetry: SyncedClientPreferenceRetryScheduler = ( + retry, + delayMs, +) => { + const timer = setTimeout(retry, delayMs); + return () => clearTimeout(timer); +}; + +export function createSyncedClientPreferenceHydrationController< + Field extends SyncedClientPreferenceField, +>( + field: Field, + scheduleRetry: SyncedClientPreferenceRetryScheduler = scheduleSyncedClientPreferenceRetry, +) { + interface SyncedClientPreferenceEnvironmentState { + adoptedUpdatedAt?: string; + seedPendingUpdatedAt?: string; + writePending?: { + readonly value: SyncedClientPreferenceValue; + readonly updatedAt: string; + }; + writeInFlightUpdatedAt?: string; + pendingAdoption?: { + readonly value: SyncedClientPreferenceValue; + readonly updatedAt: string; + }; + readonly synchronizeAgainByOwner: Map void>; + cancelRetry?: () => void; + patchAttempt: number; + } + + const imperativeSynchronizationOwner = Symbol(); + const stateByEnvironment = new Map(); + const stateFor = (environmentId: EnvironmentId) => { + const current = stateByEnvironment.get(environmentId); + if (current !== undefined) return current; + const state: SyncedClientPreferenceEnvironmentState = { + synchronizeAgainByOwner: new Map(), + patchAttempt: 0, + }; + stateByEnvironment.set(environmentId, state); + return state; + }; + const cancelRetry = (state: SyncedClientPreferenceEnvironmentState) => { + state.cancelRetry?.(); + delete state.cancelRetry; + }; + const deactivate = (environmentId: EnvironmentId, owner: symbol) => { + const state = stateByEnvironment.get(environmentId); + if (state === undefined) return; + state.synchronizeAgainByOwner.delete(owner); + if (state.synchronizeAgainByOwner.size > 0) return; + cancelRetry(state); + }; + const getSynchronizeAgain = (state: SyncedClientPreferenceEnvironmentState) => { + let latest: (() => void) | undefined; + for (const synchronizeAgain of state.synchronizeAgainByOwner.values()) { + latest = synchronizeAgain; + } + return latest; + }; + const requestRetry = (state: SyncedClientPreferenceEnvironmentState) => { + if ( + state.patchAttempt >= SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS || + state.cancelRetry !== undefined || + getSynchronizeAgain(state) === undefined + ) { + return; + } + const delayMs = SYNCED_CLIENT_PREFERENCE_RETRY_DELAY_MS * 2 ** (state.patchAttempt - 1); + state.cancelRetry = scheduleRetry(() => { + delete state.cancelRetry; + getSynchronizeAgain(state)?.(); + }, delayMs); + }; + const markAdopted = (state: SyncedClientPreferenceEnvironmentState, updatedAt: string) => { + if (state.adoptedUpdatedAt === undefined || updatedAt > state.adoptedUpdatedAt) { + state.adoptedUpdatedAt = updatedAt; + } + }; + + const settlePatch = (input: { + readonly environmentId: EnvironmentId; + readonly requestedUpdatedAt: string; + readonly result: AtomCommandResult; + readonly persist: (value: SyncedClientPreferenceValue) => void; + }) => { + const state = stateByEnvironment.get(input.environmentId); + if (state === undefined) return; + if (state.writeInFlightUpdatedAt === input.requestedUpdatedAt) { + delete state.writeInFlightUpdatedAt; + } + if (input.result._tag === "Failure") { + const matchingWrite = state.writePending?.updatedAt === input.requestedUpdatedAt; + const matchingSeed = state.seedPendingUpdatedAt === input.requestedUpdatedAt; + if (!matchingWrite && !matchingSeed) return; + if (matchingSeed) delete state.seedPendingUpdatedAt; + requestRetry(state); + return; + } + + const pendingWrite = state.writePending; + const matchingWrite = pendingWrite?.updatedAt === input.requestedUpdatedAt; + const seedMatchesRequest = state.seedPendingUpdatedAt === input.requestedUpdatedAt; + const matchingSeed = + seedMatchesRequest && + (pendingWrite === undefined || pendingWrite.updatedAt <= input.requestedUpdatedAt); + if (seedMatchesRequest) delete state.seedPendingUpdatedAt; + if (!matchingWrite && !matchingSeed) return; + + cancelRetry(state); + state.patchAttempt = 0; + if (matchingWrite) delete state.writePending; + const resultUpdatedAt = getSyncedClientPreferenceUpdatedAt(input.result.value, field); + const resultValue = syncedClientPreferenceValue(input.result.value, field); + if (resultUpdatedAt !== undefined && resultValue !== undefined) { + state.pendingAdoption = { value: resultValue, updatedAt: resultUpdatedAt }; + } + if (getSynchronizeAgain(state) === undefined || state.pendingAdoption === undefined) return; + input.persist(state.pendingAdoption.value); + markAdopted(state, state.pendingAdoption.updatedAt); + }; + + const dispatchPatch = (input: { + readonly target: SyncedClientPreferencePatchTarget; + readonly patch: SyncedClientPreferencePatch; + readonly persist: (value: SyncedClientPreferenceValue) => void; + }) => { + const { environmentId } = input.target; + const requestedUpdatedAt = input.target.input.updatedAt; + const state = stateFor(environmentId); + state.patchAttempt += 1; + state.writeInFlightUpdatedAt = requestedUpdatedAt; + void input.patch(input.target).then((result) => { + settlePatch({ environmentId, requestedUpdatedAt, result, persist: input.persist }); + }); + }; + + const synchronize = ( + input: SyncedClientPreferenceHydrationInput, + owner = imperativeSynchronizationOwner, + ) => { + const environmentId = input.environmentId; + if (environmentId === null) return; + if (input.clientValue === undefined) { + deactivate(environmentId, owner); + return; + } + if (!input.live) { + deactivate(environmentId, owner); + return; + } + const state = stateFor(environmentId); + state.synchronizeAgainByOwner.set(owner, () => synchronize(input, owner)); + const deactivateSynchronization = () => deactivate(environmentId, owner); + if (input.serverPreferences?.[field] !== undefined) { + delete state.seedPendingUpdatedAt; + } + const pendingWrite = state.writePending; + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt(input.serverPreferences, field); + if ( + state.pendingAdoption !== undefined && + serverUpdatedAt !== undefined && + serverUpdatedAt >= state.pendingAdoption.updatedAt + ) { + delete state.pendingAdoption; + } + if ( + state.pendingAdoption !== undefined && + input.clientHydrated && + input.canPatch && + (state.adoptedUpdatedAt === undefined || + state.pendingAdoption.updatedAt > state.adoptedUpdatedAt) + ) { + if (input.clientValue !== state.pendingAdoption.value) { + input.persist(state.pendingAdoption.value); + } + markAdopted(state, state.pendingAdoption.updatedAt); + } + if ( + pendingWrite !== undefined && + serverUpdatedAt !== undefined && + serverUpdatedAt >= pendingWrite.updatedAt + ) { + delete state.writePending; + delete state.writeInFlightUpdatedAt; + cancelRetry(state); + state.patchAttempt = 0; + } + const activePendingWrite = state.writePending; + if ( + input.canPatch && + activePendingWrite !== undefined && + state.patchAttempt < SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS && + (serverUpdatedAt === undefined || serverUpdatedAt < activePendingWrite.updatedAt) && + state.writeInFlightUpdatedAt !== activePendingWrite.updatedAt && + state.cancelRetry === undefined + ) { + dispatchPatch({ + target: { + environmentId, + input: { + patch: syncedClientPreferencePatch(field, activePendingWrite.value), + updatedAt: activePendingWrite.updatedAt, + }, + }, + patch: input.patch, + persist: input.persist, + }); + } + if (environmentId !== input.primaryEnvironmentId) return deactivateSynchronization; + + let hydrationInput: Parameters>[0] = + { + field, + clientHydrated: input.clientHydrated, + clientValue: input.clientValue, + serverPreferences: input.serverPreferences, + seedPending: state.seedPendingUpdatedAt !== undefined, + now: input.now, + }; + if (activePendingWrite !== undefined) { + hydrationInput = { ...hydrationInput, writePending: activePendingWrite }; + } + if (state.adoptedUpdatedAt !== undefined) { + hydrationInput = { ...hydrationInput, adoptedUpdatedAt: state.adoptedUpdatedAt }; + } + const action = resolveSyncedClientPreferenceHydrationAction(hydrationInput); + if (action.type === "adopt") { + if (!input.canPatch) return deactivateSynchronization; + markAdopted(state, action.updatedAt); + if (input.clientValue !== action.value) input.persist(action.value); + return deactivateSynchronization; + } + if (action.type !== "seed" || !input.canPatch) return deactivateSynchronization; + + state.seedPendingUpdatedAt = action.updatedAt; + dispatchPatch({ + target: { + environmentId, + input: { + patch: syncedClientPreferencePatch(field, action.value), + updatedAt: action.updatedAt, + }, + }, + patch: input.patch, + persist: input.persist, + }); + + return deactivateSynchronization; + }; + + const write = (input: { + readonly environmentId: EnvironmentId | null; + readonly value: SyncedClientPreferenceValue; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly canPatch: boolean; + readonly now: string; + readonly patch: SyncedClientPreferencePatch; + readonly persist: (value: SyncedClientPreferenceValue) => void; + }) => { + if (input.environmentId === null) return; + const environmentId = input.environmentId; + const state = stateFor(environmentId); + cancelRetry(state); + state.patchAttempt = 0; + const controllerUpdatedAt = [ + state.adoptedUpdatedAt, + state.seedPendingUpdatedAt, + state.writePending?.updatedAt, + state.writeInFlightUpdatedAt, + state.pendingAdoption?.updatedAt, + ].reduce( + (latest, candidate) => + candidate !== undefined && (latest === undefined || candidate > latest) + ? candidate + : latest, + undefined, + ); + let writeInput: Parameters>[0] = { + field, + value: input.value, + serverPreferences: input.serverPreferences, + now: input.now, + }; + if (controllerUpdatedAt !== undefined) { + writeInput = { ...writeInput, pendingUpdatedAt: controllerUpdatedAt }; + } + const next = createSyncedClientPreferenceWrite(writeInput); + state.writePending = { + value: input.value, + updatedAt: next.request.updatedAt, + }; + delete state.pendingAdoption; + if (!input.canPatch) return; + dispatchPatch({ + target: { environmentId, input: next.request }, + patch: input.patch, + persist: input.persist, + }); + }; + + return { + synchronize, + write, + getPendingWrite(environmentId: EnvironmentId | null) { + if (environmentId === null) return undefined; + const state = stateByEnvironment.get(environmentId); + return state?.writePending ?? state?.pendingAdoption; + }, + reset() { + for (const state of stateByEnvironment.values()) { + cancelRetry(state); + state.synchronizeAgainByOwner.clear(); + } + stateByEnvironment.clear(); + }, + }; +} + +export function useSyncedClientPreferenceHydrationEffect< + Field extends SyncedClientPreferenceField, + E, +>( + controller: ReturnType>, + input: SyncedClientPreferenceHydrationInput, +): void { + const synchronizationOwner = useMemo(() => Symbol(), [controller]); + useEffect( + () => controller.synchronize(input, synchronizationOwner), + [ + controller, + input.canPatch, + input.clientHydrated, + input.clientValue, + input.environmentId, + input.live, + input.patch, + input.persist, + input.primaryEnvironmentId, + input.serverPreferences, + synchronizationOwner, + ], + ); +} diff --git a/apps/web/src/hooks/useSettings.environment-target.test.ts b/apps/web/src/hooks/useSettings.environment-target.test.ts new file mode 100644 index 000000000000..8585ddab7ca8 --- /dev/null +++ b/apps/web/src/hooks/useSettings.environment-target.test.ts @@ -0,0 +1,198 @@ +import { EnvironmentId, type PatchSyncedClientPreferencesRequest } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../test/reactHookHarness"; + +const testState = vi.hoisted(() => ({ + primaryEnvironmentIds: new Array(), + sessionAtom: Symbol("session"), + updateSettingsAtom: Symbol("update-settings"), + patchPreferencesAtom: Symbol("patch-preferences"), + serverSettingsAtom: Symbol("server-settings"), + sessionEnvironmentIds: new Array(), + updateSettings: vi.fn(), + patchPreferences: vi.fn(), + setClientSettings: vi.fn(async () => undefined), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useEffect: (effect: () => void | (() => void)) => effect(), + useMemo: reactHookHarness.useMemo, + useSyncExternalStore: ( + _subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => Snapshot, + ): Snapshot => getSnapshot(), + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: symbol) => + atom === testState.serverSettingsAtom + ? {} + : atom === testState.sessionAtom + ? { + authenticated: true, + scopes: ["orchestration:operate"], + } + : { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + }, +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ + persistence: { + setClientSettings: testState.setClientSettings, + }, + }), +})); + +vi.mock("~/state/environments", () => ({ + usePrimaryEnvironment: () => ({ environmentId: testState.primaryEnvironmentIds[0] }), +})); + +vi.mock("~/state/server", () => ({ + primaryServerSettingsAtom: Symbol("primary-settings"), + serverEnvironment: { + settingsValueAtom: () => testState.serverSettingsAtom, + updateSettings: testState.updateSettingsAtom, + patchSyncedClientPreferences: testState.patchPreferencesAtom, + }, +})); + +vi.mock("~/state/session", () => ({ + environmentSession: { + sessionStateValueAtom: (environmentId: EnvironmentId) => { + testState.sessionEnvironmentIds.push(environmentId); + return testState.sessionAtom; + }, + }, +})); + +vi.mock("~/state/shell", () => ({ + environmentShell: { + stateValueAtom: () => Symbol("shell"), + }, +})); + +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (atom: symbol) => + atom === testState.patchPreferencesAtom ? testState.patchPreferences : testState.updateSettings, +})); + +import { + __resetClientSettingsPersistenceForTests, + useEnvironmentSettings, + useUpdateEnvironmentSettings, +} from "./useSettings"; + +describe("useUpdateEnvironmentSettings", () => { + beforeEach(() => { + hooks.reset(); + __resetClientSettingsPersistenceForTests(); + testState.patchPreferences.mockReset(); + testState.patchPreferences.mockImplementation( + async (target: { + readonly environmentId: EnvironmentId; + readonly input: PatchSyncedClientPreferencesRequest; + }) => + AsyncResult.success({ + planModeEnabled: target.input.patch.planModeEnabled, + updatedAt: target.input.updatedAt, + }), + ); + testState.setClientSettings.mockClear(); + testState.primaryEnvironmentIds.length = 0; + testState.primaryEnvironmentIds.push(EnvironmentId.make("primary")); + testState.sessionEnvironmentIds.length = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("patches synced preferences in the supplied secondary environment", () => { + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + hooks.beginRender(); + const updateSettings = useUpdateEnvironmentSettings(secondaryEnvironmentId); + + updateSettings({ planModeEnabled: true }); + + expect(testState.patchPreferences).toHaveBeenCalledWith({ + environmentId: secondaryEnvironmentId, + input: { + patch: { planModeEnabled: true }, + updatedAt: expect.any(String), + }, + }); + }); + + it("checks synced-preference access in the supplied secondary environment", () => { + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + hooks.beginRender(); + + useEnvironmentSettings(secondaryEnvironmentId); + + expect(testState.sessionEnvironmentIds).toEqual([secondaryEnvironmentId]); + }); + + it("keeps an optimistic plan mode toggle while the shell is stale", () => { + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + testState.patchPreferences.mockImplementation(() => new Promise(() => undefined)); + hooks.beginRender(); + const updateSettings = useUpdateEnvironmentSettings(secondaryEnvironmentId); + + updateSettings({ planModeEnabled: true }); + hooks.reset(); + hooks.beginRender(); + + expect( + useEnvironmentSettings(secondaryEnvironmentId, (settings) => settings.planModeEnabled), + ).toBe(true); + }); + + it("retries a failed secondary preference patch and reconciles the UI", async () => { + vi.useFakeTimers(); + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + testState.patchPreferences + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail("offline"))) + .mockImplementationOnce(async (target) => + AsyncResult.success({ + planModeEnabled: false, + updatedAt: target.input.updatedAt, + }), + ); + hooks.beginRender(); + useEnvironmentSettings(secondaryEnvironmentId); + hooks.beginRender(); + const updateSettings = useUpdateEnvironmentSettings(secondaryEnvironmentId); + + updateSettings({ planModeEnabled: true }); + await Promise.resolve(); + hooks.beginRender(); + expect( + useEnvironmentSettings(secondaryEnvironmentId, (settings) => settings.planModeEnabled), + ).toBe(true); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(testState.patchPreferences).toHaveBeenCalledTimes(2); + hooks.beginRender(); + expect( + useEnvironmentSettings(secondaryEnvironmentId, (settings) => settings.planModeEnabled), + ).toBe(false); + }); +}); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index b332fe13c2f1..e58a9a1e84ff 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -6,7 +6,19 @@ import { import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; -import { mergeEnvironmentSettings, resolveEnvironmentIdentificationMode } from "./useSettings"; +import { IRIS_THEME_ID } from "../themePalette"; +import { + mergeEnvironmentSettings, + resolveEnvironmentIdentificationMode, + syncedThemeIdFromPreference, +} from "./useSettings"; + +describe("syncedThemeIdFromPreference", () => { + it("maps only built-in themes into synced preferences", () => { + expect(syncedThemeIdFromPreference(IRIS_THEME_ID)).toBe(IRIS_THEME_ID); + expect(syncedThemeIdFromPreference("personal-sunset")).toBeUndefined(); + }); +}); describe("resolveEnvironmentIdentificationMode", () => { it("keeps identification hidden until client settings hydrate", () => { @@ -76,4 +88,25 @@ describe("mergeEnvironmentSettings", () => { expect(settings.providerInstances).toBe(serverSettings.providerInstances); expect(settings.favorites).toBe(clientSettings.favorites); }); + + it("lets the environment's synced plan mode override the local cache", () => { + const settings = mergeEnvironmentSettings( + DEFAULT_SERVER_SETTINGS, + { ...DEFAULT_CLIENT_SETTINGS, planModeEnabled: false }, + { planModeEnabled: true, updatedAt: "2026-08-14T12:00:00.000Z" }, + ); + + expect(settings.planModeEnabled).toBe(true); + }); + + it("keeps a newer local Plan Mode choice visible in a read-only session", () => { + const settings = mergeEnvironmentSettings( + DEFAULT_SERVER_SETTINGS, + { ...DEFAULT_CLIENT_SETTINGS, planModeEnabled: false }, + { planModeEnabled: true, updatedAt: "2026-08-14T12:00:00.000Z" }, + false, + ); + + expect(settings.planModeEnabled).toBe(false); + }); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 590949c09d9b..1803378d33cd 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -12,10 +12,14 @@ import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useAtomValue } from "@effect/atom-react"; import { + AuthOrchestrationOperateScope, DEFAULT_SERVER_SETTINGS, + getSyncedClientPreferenceUpdatedAt, type EnvironmentId, ServerSettings, type ServerSettingsPatch, + type SyncedClientPreferences, + type SyncedClientPreferencesPatch, } from "@t3tools/contracts"; import { type ClientSettingsPatch, @@ -27,16 +31,31 @@ import { import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { ensureLocalApi } from "~/localApi"; import { + canonicalThemePreference, + getThemePreferenceMode, getThemeDefinition, getThemePreviewSidebarArtwork, + isBuiltInThemePreference, + isKnownThemePreference, resolveThemeHalf, subscribeToThemePreview, themeAllowsSidebarArtwork, + type ThemePreference, + type ThemePreferenceMode, } from "~/themePalette"; import * as Struct from "effect/Struct"; +import { Atom } from "effect/unstable/reactivity"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { usePrimaryEnvironment } from "~/state/environments"; +import { environmentSession } from "~/state/session"; +import { environmentShell } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; +import { + SHELL_NOT_LIVE, + createSyncedClientPreferenceHydrationController, + createSyncedClientPreferencesSliceAtom, + useSyncedClientPreferenceHydrationEffect, +} from "./synced-client-preferences"; import { useTheme } from "./useTheme"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -49,6 +68,14 @@ let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; let clientSettingsHydrated = false; let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; +const EMPTY_SYNCED_CLIENT_PREFERENCES_ATOM = Atom.make(SHELL_NOT_LIVE); +const EMPTY_AUTH_SESSION_ATOM = Atom.make(null); + +const syncedClientPreferencesSliceAtom = Atom.family((environmentId: EnvironmentId) => + createSyncedClientPreferencesSliceAtom(environmentShell.stateValueAtom(environmentId)).pipe( + Atom.withLabel(`web:synced-client-preferences:${environmentId}`), + ), +); function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { @@ -204,24 +231,289 @@ function useClientSettingsValue(): ClientSettings { export function mergeEnvironmentSettings( serverSettings: ServerSettings, clientSettings: ClientSettings, + syncedClientPreferences?: SyncedClientPreferences, + syncedPlanModeCanOverrideClient = true, + pendingPlanModeWrite?: { readonly value: boolean; readonly updatedAt: string }, ): UnifiedSettings { - return { ...serverSettings, ...clientSettings }; + const syncedPlanModeUpdatedAt = getSyncedClientPreferenceUpdatedAt( + syncedClientPreferences, + "planModeEnabled", + ); + const pendingPlanModeIsNewer = + pendingPlanModeWrite !== undefined && + (syncedPlanModeUpdatedAt === undefined || + pendingPlanModeWrite.updatedAt > syncedPlanModeUpdatedAt); + let merged: UnifiedSettings = { + ...serverSettings, + ...clientSettings, + }; + if (syncedPlanModeCanOverrideClient) { + if (pendingPlanModeIsNewer) { + merged = { ...merged, planModeEnabled: pendingPlanModeWrite.value }; + } else if (syncedClientPreferences?.planModeEnabled !== undefined) { + merged = { ...merged, planModeEnabled: syncedClientPreferences.planModeEnabled }; + } + } + return merged; } function useMergedSettings( serverSettings: ServerSettings, + syncedClientPreferences: SyncedClientPreferences | undefined, + syncedPlanModeCanOverrideClient: boolean, + pendingPlanModeWrite: { readonly value: boolean; readonly updatedAt: string } | undefined, selector: ((settings: UnifiedSettings) => T) | undefined, ): T { const clientSettings = useClientSettingsValue(); const merged = useMemo( - () => mergeEnvironmentSettings(serverSettings, clientSettings), - [clientSettings, serverSettings], + () => + mergeEnvironmentSettings( + serverSettings, + clientSettings, + syncedClientPreferences, + syncedPlanModeCanOverrideClient, + pendingPlanModeWrite, + ), + [ + clientSettings, + pendingPlanModeWrite, + serverSettings, + syncedClientPreferences, + syncedPlanModeCanOverrideClient, + ], ); return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); } +export const DEFAULT_SYNCED_THEME_ID = "t3-code"; + +export function syncedThemeIdFromPreference(theme: ThemePreference): string | undefined { + if (theme === "system" || theme === "light" || theme === "dark") { + return DEFAULT_SYNCED_THEME_ID; + } + return isBuiltInThemePreference(theme) ? canonicalThemePreference(theme) : undefined; +} + +export function themePreferenceFromSyncedThemeId(themeId: string): ThemePreference { + if ( + themeId === DEFAULT_SYNCED_THEME_ID || + themeId === "system" || + themeId === "light" || + themeId === "dark" + ) { + return "system"; + } + return isKnownThemePreference(themeId) ? canonicalThemePreference(themeId) : "system"; +} + +const syncedPlanModeHydrationController = + createSyncedClientPreferenceHydrationController("planModeEnabled"); +const syncedAppearanceModeHydrationController = + createSyncedClientPreferenceHydrationController("appearanceMode"); +const syncedThemeIdHydrationController = createSyncedClientPreferenceHydrationController("themeId"); + +function useEnvironmentSyncedClientPreferences(environmentId: EnvironmentId | null) { + const preferences = useAtomValue( + environmentId === null + ? EMPTY_SYNCED_CLIENT_PREFERENCES_ATOM + : syncedClientPreferencesSliceAtom(environmentId), + ); + return { + live: preferences !== SHELL_NOT_LIVE, + preferences: preferences === SHELL_NOT_LIVE ? undefined : preferences, + } as const; +} + +function useCanPatchSyncedClientPreferences(environmentId: EnvironmentId | null): boolean { + const session = useAtomValue( + environmentId === null + ? EMPTY_AUTH_SESSION_ATOM + : environmentSession.sessionStateValueAtom(environmentId), + ); + return ( + session?.authenticated === true && + session.scopes?.includes(AuthOrchestrationOperateScope) === true + ); +} + +function useSyncedClientPreferenceState() { + const clientSettings = useClientSettingsValue(); + const themeState = useTheme(); + const persistPlanModeEnabled = useCallback((value: boolean) => { + if (getClientSettingsSnapshot().planModeEnabled !== value) { + persistClientSettings({ ...getClientSettingsSnapshot(), planModeEnabled: value }); + } + }, []); + const persistAppearanceMode = useCallback( + (value: ThemePreferenceMode) => { + if (themeState.appearanceMode !== value) themeState.setAppearanceMode(value); + }, + [themeState.appearanceMode, themeState.setAppearanceMode], + ); + const persistThemeId = useCallback( + (value: string) => { + const theme = themePreferenceFromSyncedThemeId(value); + if (themeState.theme !== theme) themeState.setTheme(theme); + }, + [themeState.setTheme, themeState.theme], + ); + + return { + appearanceMode: themeState.appearanceMode, + persistAppearanceMode, + persistPlanModeEnabled, + persistThemeId, + planModeEnabled: clientSettings.planModeEnabled, + themeId: syncedThemeIdFromPreference(themeState.theme), + themeState, + } as const; +} + +function useSynchronizeSyncedClientPreferences( + environmentId: EnvironmentId | null, + state: ReturnType, +) { + const clientHydrated = useClientSettingsHydrated(); + const primaryEnvironmentId = usePrimaryEnvironment()?.environmentId ?? null; + const synced = useEnvironmentSyncedClientPreferences(environmentId); + const canPatch = useCanPatchSyncedClientPreferences(environmentId); + const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { + label: "synced client preferences seed", + reportFailure: false, + }); + const common = { + environmentId, + primaryEnvironmentId, + clientHydrated, + live: synced.live, + serverPreferences: synced.preferences, + canPatch, + now: new Date().toISOString(), + patch: patchPreferences, + } as const; + + useSyncedClientPreferenceHydrationEffect(syncedPlanModeHydrationController, { + ...common, + clientValue: state.planModeEnabled, + persist: state.persistPlanModeEnabled, + }); + useSyncedClientPreferenceHydrationEffect(syncedAppearanceModeHydrationController, { + ...common, + clientValue: state.appearanceMode, + persist: state.persistAppearanceMode, + }); + useSyncedClientPreferenceHydrationEffect(syncedThemeIdHydrationController, { + ...common, + clientValue: state.themeId, + persist: state.persistThemeId, + }); + + return { + ...synced, + canPatch, + pendingWrite: syncedPlanModeHydrationController.getPendingWrite(environmentId), + } as const; +} + +function useSyncedClientPreferencesHydration(environmentId: EnvironmentId | null) { + const state = useSyncedClientPreferenceState(); + return useSynchronizeSyncedClientPreferences(environmentId, state); +} + +function useWriteSyncedClientPreferences( + environmentId: EnvironmentId | null, + state: ReturnType, +) { + const synced = useEnvironmentSyncedClientPreferences(environmentId); + const canPatch = useCanPatchSyncedClientPreferences(environmentId); + const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { + label: "synced client preferences update", + reportFailure: false, + }); + return useCallback( + (valuePatch: SyncedClientPreferencesPatch) => { + const common = { + environmentId, + serverPreferences: synced.preferences, + canPatch, + now: new Date().toISOString(), + patch: patchPreferences, + } as const; + if (valuePatch.planModeEnabled !== undefined) { + syncedPlanModeHydrationController.write({ + ...common, + value: valuePatch.planModeEnabled, + persist: state.persistPlanModeEnabled, + }); + } + if (valuePatch.appearanceMode !== undefined) { + syncedAppearanceModeHydrationController.write({ + ...common, + value: valuePatch.appearanceMode, + persist: state.persistAppearanceMode, + }); + } + if (valuePatch.themeId !== undefined) { + syncedThemeIdHydrationController.write({ + ...common, + value: valuePatch.themeId, + persist: state.persistThemeId, + }); + } + }, + [ + canPatch, + environmentId, + patchPreferences, + state.persistAppearanceMode, + state.persistPlanModeEnabled, + state.persistThemeId, + synced.preferences, + ], + ); +} + +export function useSyncedTheme() { + const state = useSyncedClientPreferenceState(); + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + useSynchronizeSyncedClientPreferences(environmentId, state); + const write = useWriteSyncedClientPreferences(environmentId, state); + const setTheme = useCallback( + (theme: ThemePreference): boolean => { + if (!state.themeState.setTheme(theme)) return false; + const themeId = syncedThemeIdFromPreference(theme); + if (themeId !== undefined) write({ themeId }); + return true; + }, + [state.themeState, write], + ); + const setAppearanceMode = useCallback( + (appearanceMode: ThemePreferenceMode): boolean => { + if (!state.themeState.setAppearanceMode(appearanceMode)) return false; + write({ appearanceMode }); + return true; + }, + [state.themeState, write], + ); + const setFollowSystem = useCallback( + (followSystem: boolean): boolean => { + if (!state.themeState.setFollowSystem(followSystem)) return false; + const appearanceMode = followSystem + ? "system" + : state.themeState.appearanceMode === "system" + ? (getThemePreferenceMode(state.themeState.theme) ?? "light") + : state.themeState.appearanceMode; + write({ appearanceMode }); + return true; + }, + [state.themeState, write], + ); + + return { ...state.themeState, setAppearanceMode, setFollowSystem, setTheme } as const; +} + export function useClientSettings( selector?: (settings: ClientSettings) => T, ): T { @@ -284,14 +576,29 @@ export function useEnvironmentSettings( selector?: (settings: UnifiedSettings) => T, ): T { const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); - return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); + const synced = useSyncedClientPreferencesHydration(environmentId); + return useMergedSettings( + serverSettings ?? DEFAULT_SERVER_SETTINGS, + synced.preferences, + synced.canPatch, + synced.pendingWrite, + selector, + ); } /** Primary-only settings access for the settings UI and other explicitly global surfaces. */ export function usePrimarySettings( selector?: (settings: UnifiedSettings) => T, ): T { - return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + const synced = useSyncedClientPreferencesHydration(environmentId); + return useMergedSettings( + useAtomValue(primaryServerSettingsAtom), + synced.preferences, + synced.canPatch, + synced.pendingWrite, + selector, + ); } /** @@ -305,6 +612,15 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { serverEnvironment.updateSettings, "server settings update", ); + const synced = useEnvironmentSyncedClientPreferences(environmentId); + const canPatchSyncedPreferences = useCanPatchSyncedClientPreferences(environmentId); + const patchSyncedClientPreferences = useAtomCommand( + serverEnvironment.patchSyncedClientPreferences, + { + label: "synced client preferences update", + reportFailure: false, + }, + ); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); @@ -323,8 +639,29 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { ...clientPatch, }); } + if (clientPatch.planModeEnabled !== undefined) { + syncedPlanModeHydrationController.write({ + environmentId, + value: clientPatch.planModeEnabled, + serverPreferences: synced.preferences, + canPatch: canPatchSyncedPreferences, + now: new Date().toISOString(), + patch: patchSyncedClientPreferences, + persist: (value) => { + if (getClientSettingsSnapshot().planModeEnabled !== value) { + persistClientSettings({ ...getClientSettingsSnapshot(), planModeEnabled: value }); + } + }, + }); + } }, - [environmentId, persistServerSettings], + [ + canPatchSyncedPreferences, + environmentId, + patchSyncedClientPreferences, + persistServerSettings, + synced.preferences, + ], ); return updateSettings; @@ -354,6 +691,9 @@ export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationPromise = null; clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); + syncedPlanModeHydrationController.reset(); + syncedAppearanceModeHydrationController.reset(); + syncedThemeIdHydrationController.reset(); } export function __setClientSettingsForTests(settings: ClientSettings): void { diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 7bf4f0426bd3..642e52c31ab6 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1553,6 +1553,11 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu ); } +export function isBuiltInThemePreference(theme: ThemePreference): boolean { + const themeId = themeIdFromPreference(theme); + return BUILT_IN_THEME_DEFINITIONS.some((definition) => definition.id === themeId); +} + /** Artwork palettes are reviewed alongside built-ins; user themes always use the pill fallback. */ export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean { const themeId = themeIdFromPreference(theme); diff --git a/docs/README.md b/docs/README.md index 30653e7d5035..8ea1e7b2e7f1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,9 @@ - [Install and first run](./user/install.md) - [Permission modes](./user/permission-modes.md) +- [Plan Mode](./user/plan-mode.md) - [Keyboard shortcuts](./user/keybindings.md) +- [Appearance and themes](./user/appearance.md) - [Organizing threads](./user/thread-sidebar.md) - [Review usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) diff --git a/docs/user/appearance.md b/docs/user/appearance.md new file mode 100644 index 000000000000..67f645ba6414 --- /dev/null +++ b/docs/user/appearance.md @@ -0,0 +1,19 @@ +# Appearance + +Use **Settings → Appearance** to choose whether T3 Code follows the system light or dark mode and +to select a built-in theme. + +On mobile, **Import theme** accepts a pasted T3 Code ThemeFile v1 JSON object. Imported themes are +stored on that device and appear after the built-in themes. A theme may define light colors, dark +colors, or both; T3 Code uses its standard colors when the selected theme does not provide the +current appearance. + +Theme colors support hex literals, CSS named colors, `rgb()`/`rgba()`, `hsl()`/`hsla()`, `hwb()`, +`lab()`, `lch()`, `oklab()`, `oklch()`, `color(display-p3 ...)`, and `color(srgb ...)`. A single +unsupported color rejects the whole theme file. + +Mobile supports up to 20 imported themes. Each theme file must be 64 KB or smaller, and the full +imported-theme library may use up to 256 KB of device storage. + +Use the remove button on an imported theme to delete it from the device. Removing the selected +theme switches back to **T3 Code**. Built-in themes cannot be removed. diff --git a/docs/user/plan-mode.md b/docs/user/plan-mode.md new file mode 100644 index 000000000000..658b67f5c5fc --- /dev/null +++ b/docs/user/plan-mode.md @@ -0,0 +1,9 @@ +# Plan Mode + +Enable **Plan Mode (Legacy)** in **Settings → General → Legacy features** on web and desktop, or +in the **Legacy** section of Settings on mobile. The preference follows the connected environment, +so web, desktop, and mobile clients using that environment converge on the same setting. + +Changes made while a client is offline remain available on that device. T3 Code reconciles the +newest saved choice when the environment reconnects. If several environments are connected on +mobile, the most recently updated choice is applied across them. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..f4da4a3e0e4c 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -756,6 +756,10 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), + patchSyncedClientPreferences: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:patch-synced-client-preferences", + tag: WS_METHODS.syncedClientPreferencesPatch, + }), signalProcess: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 40e9bd80dc5b..d0fae94e7f85 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -169,12 +169,14 @@ describe("environment shell synchronization", () => { const subscribeInputs = yield* Queue.unbounded<{ readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; + readonly clientPreferencesStreamItem?: boolean; }>(); const loaderCalls = yield* Ref.make(0); const client = { [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; + readonly clientPreferencesStreamItem?: boolean; }) => Stream.unwrap( Queue.offer(subscribeInputs, input).pipe(Effect.as(Stream.fromQueue(events))), @@ -223,6 +225,7 @@ describe("environment shell synchronization", () => { const subscribeInput = yield* Queue.take(subscribeInputs); expect(subscribeInput.afterSequence).toBeUndefined(); expect(subscribeInput.requestCompletionMarker).toBe(true); + expect(subscribeInput.clientPreferencesStreamItem).toBe(true); expect(yield* Ref.get(loaderCalls)).toBe(1); const synchronizing = yield* SubscriptionRef.get(shellState); expect(synchronizing.status).toBe("synchronizing"); @@ -243,6 +246,7 @@ describe("environment shell synchronization", () => { const resumedInput = yield* Queue.take(subscribeInputs); expect(resumedInput.afterSequence).toBe(resetSnapshot.snapshotSequence); expect(resumedInput.requestCompletionMarker).toBe(true); + expect(resumedInput.clientPreferencesStreamItem).toBe(true); expect(yield* Ref.get(loaderCalls)).toBe(1); }), ); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index c150bbb75b8c..c1d48f635711 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -229,7 +229,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") // If the authoritative refresh failed, omit the cached cursor so the // socket fallback sends a complete snapshot for this new session. if (!canResume || Option.isNone(current.snapshot)) { - return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + const input = { clientPreferencesStreamItem: true as const }; + return supportsCompletionMarker + ? { ...input, requestCompletionMarker: true as const } + : input; } if (!supportsCompletionMarker) { // Without a completion marker there is no synchronized signal for a @@ -242,6 +245,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } return { afterSequence: current.snapshot.value.snapshotSequence, + clientPreferencesStreamItem: true as const, ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), }; }), diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index fdccc4c47dd8..5d74c119691c 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -177,6 +177,33 @@ describe("applyShellStreamEvent", () => { }); }); + it("applies synced client preference updates", () => { + const snapshot = { + ...baseSnapshot, + projects: [stubProject], + threads: [stubThread], + }; + const next = applyShellStreamEvent(snapshot, { + kind: "client-preferences-updated", + sequence: 7, + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + + expect(next.syncedClientPreferences).toEqual({ + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }); + expect(next.snapshotSequence).toBe(7); + expect(next.projects).toBe(snapshot.projects); + expect(next.threads).toBe(snapshot.threads); + expect(next.updatedAt).toBe(snapshot.updatedAt); + }); + it("returns original snapshot for unrecognized event kinds", () => { const unknownEvent = { kind: "unknown-future-event", sequence: 99 } as any; const next = applyShellStreamEvent(baseSnapshot, unknownEvent); diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index 3d3b22a1289f..3b41aaf00609 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -40,6 +40,12 @@ export function applyShellStreamEvent( threads: Arr.filter(snapshot.threads, (t) => t.id !== event.threadId), snapshotSequence: event.sequence, }; + case "client-preferences-updated": + return { + ...snapshot, + syncedClientPreferences: event.preferences, + snapshotSequence: event.sequence, + }; default: return snapshot; } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..a6bc920e4d0f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -15,6 +15,7 @@ export * from "./model.ts"; export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; +export * from "./syncedClientPreferences.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index f403e6de26cc..c7e0c01a7e7f 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -54,6 +54,13 @@ const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPaylo const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); +const LegacyShellSnapshot = Schema.Struct({ + snapshotSequence: Schema.Number, + projects: Schema.Array(Schema.Unknown), + threads: Schema.Array(Schema.Unknown), + updatedAt: Schema.String, +}); +const decodeLegacyShellSnapshot = Schema.decodeUnknownEffect(LegacyShellSnapshot); it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => Effect.gen(function* () { @@ -942,3 +949,41 @@ it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false); }); + +it.effect("rejects unknown-only synced preference patches through generic dispatch", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "client-preferences.patch", + commandId: "preferences-empty-patch", + patch: { unsupported: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), +); + +it.effect("keeps new shell snapshots decodable by legacy clients", () => + Effect.gen(function* () { + const decoded = yield* decodeLegacyShellSnapshot({ + snapshotSequence: 1, + projects: [], + threads: [], + syncedClientPreferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + updatedAt: "2026-08-14T12:00:00.000Z", + }); + + assert.deepStrictEqual(decoded, { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-08-14T12:00:00.000Z", + }); + }), +); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cd9f3a747876..868106f7f482 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -22,6 +22,11 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { + SyncedClientPreferences, + SyncedClientPreferencesPatch, + SyncedClientPreferencesUpdatedAt, +} from "./syncedClientPreferences.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -426,6 +431,7 @@ export const OrchestrationReadModel = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProject), threads: Schema.Array(OrchestrationThread), + syncedClientPreferences: Schema.optional(SyncedClientPreferences), updatedAt: IsoDateTime, }); export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; @@ -501,6 +507,8 @@ export const OrchestrationShellSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProjectShell), threads: Schema.Array(OrchestrationThreadShell), + // Optional so cached snapshots and older servers remain wire-compatible. + syncedClientPreferences: Schema.optional(SyncedClientPreferences), updatedAt: IsoDateTime, }); export type OrchestrationShellSnapshot = typeof OrchestrationShellSnapshot.Type; @@ -526,6 +534,11 @@ export const OrchestrationShellStreamEvent = Schema.Union([ sequence: NonNegativeInt, threadId: ThreadId, }), + Schema.Struct({ + kind: Schema.Literal("client-preferences-updated"), + sequence: NonNegativeInt, + preferences: SyncedClientPreferences, + }), ]); export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type; @@ -556,6 +569,11 @@ export const OrchestrationSubscribeShellInput = Schema.Struct({ * snapshot or catch-up replay and before it begins emitting live events. */ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * Opts into `client-preferences-updated` stream items. Older clients use a + * closed output union and must not receive that newer variant. + */ + clientPreferencesStreamItem: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShellInput.Type; @@ -633,6 +651,16 @@ export const OrchestrationThreadDetailSnapshot = Schema.Struct({ }); export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; +export const SyncedClientPreferencesAggregateId = Schema.Literal("client-preferences"); +export type SyncedClientPreferencesAggregateId = typeof SyncedClientPreferencesAggregateId.Type; + +const ClientPreferencesPatchCommand = Schema.Struct({ + type: Schema.Literal("client-preferences.patch"), + commandId: CommandId, + patch: SyncedClientPreferencesPatch, + updatedAt: SyncedClientPreferencesUpdatedAt, +}); + export const ProjectCreateCommand = Schema.Struct({ type: Schema.Literal("project.create"), commandId: CommandId, @@ -910,6 +938,7 @@ const ThreadSessionStopCommand = Schema.Struct({ }); const DispatchableClientOrchestrationCommand = Schema.Union([ + ClientPreferencesPatchCommand, ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, @@ -938,6 +967,7 @@ export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; export const ClientOrchestrationCommand = Schema.Union([ + ClientPreferencesPatchCommand, ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, @@ -1056,6 +1086,7 @@ export const OrchestrationCommand = Schema.Union([ export type OrchestrationCommand = typeof OrchestrationCommand.Type; export const OrchestrationEventType = Schema.Literals([ + "client-preferences.patched", "project.created", "project.meta-updated", "project.deleted", @@ -1088,10 +1119,19 @@ export const OrchestrationEventType = Schema.Literals([ ]); export type OrchestrationEventType = typeof OrchestrationEventType.Type; -export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]); +export const OrchestrationAggregateKind = Schema.Literals([ + "client-preferences", + "project", + "thread", +]); export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type; export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]); +export const ClientPreferencesPatchedPayload = Schema.Struct({ + patch: SyncedClientPreferencesPatch, + updatedAt: SyncedClientPreferencesUpdatedAt, +}); + export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, @@ -1332,7 +1372,7 @@ const EventBaseFields = { sequence: NonNegativeInt, eventId: EventId, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([SyncedClientPreferencesAggregateId, ProjectId, ThreadId]), occurredAt: IsoDateTime, commandId: Schema.NullOr(CommandId), causationEventId: Schema.NullOr(EventId), @@ -1341,6 +1381,11 @@ const EventBaseFields = { } as const; export const OrchestrationEvent = Schema.Union([ + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("client-preferences.patched"), + payload: ClientPreferencesPatchedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("project.created"), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 115fc8a13114..df179bd63ceb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -179,6 +179,11 @@ import { } from "./resourceTelemetry.ts"; import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { + GetSyncedClientPreferencesRequest, + PatchSyncedClientPreferencesRequest, + SyncedClientPreferences, +} from "./syncedClientPreferences.ts"; import { SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -260,6 +265,8 @@ export const WS_METHODS = { serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", + syncedClientPreferencesGet: "syncedClientPreferences.get", + syncedClientPreferencesPatch: "syncedClientPreferences.patch", serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", @@ -384,6 +391,22 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); +export const WsGetSyncedClientPreferencesRpc = Rpc.make(WS_METHODS.syncedClientPreferencesGet, { + payload: GetSyncedClientPreferencesRequest, + success: Schema.NullOr(SyncedClientPreferences), + error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), +}); + +export const WsPatchSyncedClientPreferencesRpc = Rpc.make(WS_METHODS.syncedClientPreferencesPatch, { + payload: PatchSyncedClientPreferencesRequest, + success: SyncedClientPreferences, + error: Schema.Union([ + OrchestrationDispatchCommandError, + OrchestrationGetSnapshotError, + EnvironmentAuthorizationError, + ]), +}); + export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { payload: Schema.Struct({}), success: SourceControlDiscoveryResult, @@ -984,6 +1007,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsGetSyncedClientPreferencesRpc, + WsPatchSyncedClientPreferencesRpc, WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 570157292b54..ef40780d636e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -9,13 +9,98 @@ import { ServerSettings, ServerSettingsPatch, } from "./settings.ts"; +import { + SyncedClientPreferences, + SyncedClientPreferencesPatch, + SyncedClientPreferencesUpdatedAt, + PatchSyncedClientPreferencesRequest, +} from "./syncedClientPreferences.ts"; const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); +const decodeSyncedClientPreferences = Schema.decodeUnknownSync(SyncedClientPreferences); +const decodeSyncedClientPreferencesPatch = Schema.decodeUnknownSync(SyncedClientPreferencesPatch); +const decodePatchSyncedClientPreferencesRequest = Schema.decodeUnknownSync( + PatchSyncedClientPreferencesRequest, +); +const decodeLegacySyncedClientPreferences = Schema.decodeUnknownSync( + Schema.Struct({ + planModeEnabled: Schema.optionalKey(Schema.Boolean), + updatedAt: SyncedClientPreferencesUpdatedAt, + }), +); const encodeServerSettings = Schema.encodeSync(ServerSettings); +describe("SyncedClientPreferences", () => { + it("accepts exactly the three rollout keys plus the LWW stamp", () => { + const preferences = decodeSyncedClientPreferences({ + planModeEnabled: true, + appearanceMode: "dark", + themeId: "midnight", + updatedAtByField: { + planModeEnabled: "2026-08-14T10:00:00.000Z", + appearanceMode: "2026-08-14T11:00:00.000Z", + themeId: "2026-08-14T12:00:00.000Z", + }, + updatedAt: "2026-08-14T12:00:00.000Z", + ignored: true, + }); + const patch = decodeSyncedClientPreferencesPatch({ + planModeEnabled: false, + appearanceMode: "system", + themeId: "default", + ignored: true, + }); + + expect(Object.keys(preferences).sort()).toEqual([ + "appearanceMode", + "planModeEnabled", + "themeId", + "updatedAt", + "updatedAtByField", + ]); + expect(Object.keys(patch).sort()).toEqual(["appearanceMode", "planModeEnabled", "themeId"]); + }); + + it("rejects non-canonical LWW stamps", () => { + for (const updatedAt of ["not-a-date", "2026-02-30T00:00:00.000Z"]) { + expect(() => + decodeSyncedClientPreferences({ + planModeEnabled: true, + updatedAt, + }), + ).toThrow(); + } + }); + + it("decodes aggregate-only old snapshots and preserves old-client decode", () => { + const oldSnapshot = decodeSyncedClientPreferences({ + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }); + const currentSnapshot = decodeSyncedClientPreferences({ + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }); + + expect(oldSnapshot).not.toHaveProperty("updatedAtByField"); + expect(decodeLegacySyncedClientPreferences(currentSnapshot)).toEqual(oldSnapshot); + }); + + it("rejects empty and unknown-only patches at the RPC boundary", () => { + expect(() => decodeSyncedClientPreferencesPatch({})).toThrow(); + expect(() => + decodePatchSyncedClientPreferencesRequest({ + patch: { unsupported: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }), + ).toThrow(); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); diff --git a/packages/contracts/src/syncedClientPreferences.ts b/packages/contracts/src/syncedClientPreferences.ts new file mode 100644 index 000000000000..97ade40c7f78 --- /dev/null +++ b/packages/contracts/src/syncedClientPreferences.ts @@ -0,0 +1,89 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { IsoDateTime } from "./baseSchemas.ts"; + +export const SyncedClientAppearanceMode = Schema.Literals(["system", "light", "dark"]); +export type SyncedClientAppearanceMode = typeof SyncedClientAppearanceMode.Type; + +const SyncedClientPreferencesUpdatedAtPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +export const SyncedClientPreferencesUpdatedAt = IsoDateTime.check( + Schema.makeFilter( + (value) => + (SyncedClientPreferencesUpdatedAtPattern.test(value) && + Option.exists(DateTime.make(value), (parsed) => DateTime.formatIso(parsed) === value)) || + "Synced client preferences updatedAt must be a canonical UTC timestamp.", + ), +); +export type SyncedClientPreferencesUpdatedAt = typeof SyncedClientPreferencesUpdatedAt.Type; + +export function nextSyncedClientPreferencesUpdatedAt( + updatedAts: ReadonlyArray, + now: string, +): string { + let latest: string | undefined; + for (const updatedAt of updatedAts) { + if (updatedAt !== undefined && (latest === undefined || updatedAt > latest)) latest = updatedAt; + } + if (latest === undefined || now > latest) return now; + return DateTime.formatIso(DateTime.add(DateTime.makeUnsafe(latest), { milliseconds: 1 })); +} + +const SyncedClientPreferenceFields = { + planModeEnabled: Schema.optionalKey(Schema.Boolean), + appearanceMode: Schema.optionalKey(SyncedClientAppearanceMode), + themeId: Schema.optionalKey(Schema.String), +} as const; + +export const SYNCED_CLIENT_PREFERENCE_FIELDS = [ + "planModeEnabled", + "appearanceMode", + "themeId", +] as const; +export type SyncedClientPreferenceField = (typeof SYNCED_CLIENT_PREFERENCE_FIELDS)[number]; + +export const SyncedClientPreferencesUpdatedAtByField = Schema.Struct({ + planModeEnabled: Schema.optionalKey(SyncedClientPreferencesUpdatedAt), + appearanceMode: Schema.optionalKey(SyncedClientPreferencesUpdatedAt), + themeId: Schema.optionalKey(SyncedClientPreferencesUpdatedAt), +}); +export type SyncedClientPreferencesUpdatedAtByField = + typeof SyncedClientPreferencesUpdatedAtByField.Type; + +export const SyncedClientPreferences = Schema.Struct({ + ...SyncedClientPreferenceFields, + // Optional while mixed-version clients may still receive aggregate-only snapshots. + updatedAtByField: Schema.optionalKey(SyncedClientPreferencesUpdatedAtByField), + // Retained as the maximum field stamp so older clients can decode current snapshots. + updatedAt: SyncedClientPreferencesUpdatedAt, +}); +export type SyncedClientPreferences = typeof SyncedClientPreferences.Type; + +export function getSyncedClientPreferenceUpdatedAt( + preferences: SyncedClientPreferences | undefined, + field: SyncedClientPreferenceField, +): SyncedClientPreferencesUpdatedAt | undefined { + if (preferences?.[field] === undefined) return undefined; + return preferences.updatedAtByField?.[field] ?? preferences.updatedAt; +} + +export const SyncedClientPreferencesPatch = Schema.Struct(SyncedClientPreferenceFields).check( + Schema.makeFilter( + (patch) => + patch.planModeEnabled !== undefined || + patch.appearanceMode !== undefined || + patch.themeId !== undefined || + "Synced client preferences patch must include at least one supported preference.", + ), +); +export type SyncedClientPreferencesPatch = typeof SyncedClientPreferencesPatch.Type; + +export const GetSyncedClientPreferencesRequest = Schema.Struct({}); +export type GetSyncedClientPreferencesRequest = typeof GetSyncedClientPreferencesRequest.Type; + +export const PatchSyncedClientPreferencesRequest = Schema.Struct({ + patch: SyncedClientPreferencesPatch, + updatedAt: SyncedClientPreferencesUpdatedAt, +}); +export type PatchSyncedClientPreferencesRequest = typeof PatchSyncedClientPreferencesRequest.Type;