From c672c36a7f451124328aa09ac8b8b250e2c403cd Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 10:28:15 -0400 Subject: [PATCH 01/42] feat(mobile): plan mode (legacy) toggle with cross-client sync Adds a Plan Mode (Legacy) toggle to mobile Settings (default off) that gates the /plan and /default slash commands and forces outgoing turns to default mode when off, mirroring web semantics across every send path (composer, new task, queued outbox). Adds submit-time parsing and slash support on the new-task composer. Introduces per-environment synced client preferences (contracts schema, event-sourced server projection with additive migration, incremental shell stream item) consumed by web and mobile with LWW reconciliation, clock-skew clamping, and offline device-local fallback. Closes #6401 --- apps/mobile/src/Stack.tsx | 7 + apps/mobile/src/components/AppSymbol.tsx | 1 + .../features/settings/SettingsRouteScreen.tsx | 32 +- .../features/threads/NewTaskDraftScreen.tsx | 270 +++++++--- .../src/features/threads/ThreadComposer.tsx | 137 ++--- .../threads/new-task-flow-provider.tsx | 15 +- .../features/threads/new-task-submit.test.ts | 80 +++ .../src/features/threads/new-task-submit.ts | 37 ++ .../src/features/threads/plan-mode.test.ts | 204 ++++++++ apps/mobile/src/features/threads/plan-mode.ts | 124 +++++ .../features/threads/use-plan-mode-enabled.ts | 18 + apps/mobile/src/lib/storage.test.ts | 19 +- .../src/persistence/mobile-preferences.ts | 28 +- .../state/synced-client-preferences-model.ts | 187 +++++++ .../state/synced-client-preferences.test.ts | 325 ++++++++++++ .../src/state/synced-client-preferences.ts | 145 ++++++ apps/mobile/src/state/thread-outbox-model.ts | 22 +- apps/mobile/src/state/thread-outbox.test.ts | 70 ++- .../src/state/use-thread-composer-state.ts | 15 +- .../src/state/use-thread-outbox-drain.ts | 42 +- apps/server/src/auth/RpcAuthorization.ts | 2 + .../checkpointing/CheckpointDiffQuery.test.ts | 10 + .../Layers/OrchestrationEngine.test.ts | 76 ++- .../Layers/OrchestrationEngine.ts | 10 +- .../Layers/ProjectionPipeline.test.ts | 95 ++++ .../Layers/ProjectionPipeline.ts | 37 ++ .../Layers/ProjectionSnapshotQuery.ts | 468 +++++++++++------- .../src/orchestration/Normalizer.test.ts | 25 + apps/server/src/orchestration/Normalizer.ts | 12 + .../Services/ProjectionSnapshotQuery.ts | 7 + apps/server/src/orchestration/decider.ts | 15 + apps/server/src/orchestration/projector.ts | 24 + .../Layers/OrchestrationEventStore.ts | 5 +- apps/server/src/persistence/Migrations.ts | 2 + ..._ProjectionSyncedClientPreferences.test.ts | 86 ++++ .../041_ProjectionSyncedClientPreferences.ts | 27 + .../Services/OrchestrationCommandReceipts.ts | 3 +- .../project/ProjectSetupScriptRunner.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + apps/server/src/server.test.ts | 72 +++ apps/server/src/serverRuntimeStartup.test.ts | 4 + apps/server/src/ws.ts | 83 +++- apps/web/src/hooks/useSettings.test.ts | 379 +++++++++++++- apps/web/src/hooks/useSettings.ts | 425 +++++++++++++++- docs/user/plan-mode.md | 9 + packages/client-runtime/src/state/server.ts | 4 + .../src/state/shellReducer.test.ts | 25 + .../client-runtime/src/state/shellReducer.ts | 6 + packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.test.ts | 44 ++ packages/contracts/src/orchestration.ts | 44 +- packages/contracts/src/rpc.ts | 25 + packages/contracts/src/settings.test.ts | 55 ++ .../contracts/src/syncedClientPreferences.ts | 50 ++ 54 files changed, 3550 insertions(+), 360 deletions(-) create mode 100644 apps/mobile/src/features/threads/new-task-submit.test.ts create mode 100644 apps/mobile/src/features/threads/new-task-submit.ts create mode 100644 apps/mobile/src/features/threads/plan-mode.test.ts create mode 100644 apps/mobile/src/features/threads/plan-mode.ts create mode 100644 apps/mobile/src/features/threads/use-plan-mode-enabled.ts create mode 100644 apps/mobile/src/state/synced-client-preferences-model.ts create mode 100644 apps/mobile/src/state/synced-client-preferences.test.ts create mode 100644 apps/mobile/src/state/synced-client-preferences.ts create mode 100644 apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts create mode 100644 apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.ts create mode 100644 docs/user/plan-mode.md create mode 100644 packages/contracts/src/syncedClientPreferences.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 20bba1f6062d..942d9225044e 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -77,6 +77,7 @@ import { NATIVE_SHEET_SURFACE_CONTENT_STYLE, } 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); @@ -372,6 +373,11 @@ function ThreadOutboxDrainWorker() { return null; } +function SyncedClientPreferencesWorker() { + useSyncedClientPreferences(); + return null; +} + function RootStackLayout(props: { readonly children: React.ReactNode; readonly state: NavigationState; @@ -408,6 +414,7 @@ function RootStackLayout(props: { return ( + 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/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/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 6084d8e15c68..5a3775fe0a41 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -5,7 +5,7 @@ import { useNavigation, usePreventRemove, } from "@react-navigation/native"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Pressable, ScrollView, View, useColorScheme } from "react-native"; import { KeyboardController, @@ -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, @@ -63,7 +69,19 @@ import { resolveNewTaskBranchLabel, resolveNewTaskWorkspaceLabel, } from "./new-task-context-presentation"; +import { + canSubmitNewTaskDraft, + resolveNewTaskComposerSelection, + 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 +116,7 @@ export function NewTaskDraftScreen(props: { const projects = useProjects(); const createProjectThread = useCreateProjectThread(); const flow = useNewTaskFlow(); + const planModeEnabled = flow.planModeEnabled; const navigation = useNavigation(); const { consumeShare, @@ -122,6 +141,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 +247,42 @@ 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); + }, []); + const handleComposerSelectionChange = updateComposerSelection; + 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) { @@ -633,6 +695,47 @@ export function NewTaskDraftScreen(props: { [flow], ); + const handleCommandSelect = useCallback( + (item: ComposerCommandItem): void => { + const draftKey = flow.draftKey; + if (!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, + planModeEnabled, + updateComposerSelection, + ], + ); + async function handleStart(): Promise { const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; @@ -640,6 +743,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 +770,23 @@ 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, + submitting: flow.submitting, + workspaceMode, + selectedBranchName, + }) ) { return; } @@ -686,6 +809,7 @@ export function NewTaskDraftScreen(props: { if (!message) { return; } + startInFlightRef.current = true; flow.setSubmitting(true); try { await enqueueThreadOutboxMessage(message); @@ -697,6 +821,7 @@ export function NewTaskDraftScreen(props: { return; } finally { flow.setSubmitting(false); + startInFlightRef.current = false; } if (editingPendingTask) { flow.finishEditingPendingTask(); @@ -710,7 +835,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 +849,63 @@ 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 { + 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, + }, + } + : {}), + }); - 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) { @@ -798,11 +928,23 @@ export function NewTaskDraftScreen(props: { 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, + 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 +1097,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 planModeEnabled = usePlanModeEnabled(); const connectionStatus = composerConnectionStatus({ connectionError: props.connectionError, connectionState: props.connectionState, @@ -347,25 +355,28 @@ 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); }, []); + const handleSelectionChange = updateComposerSelection; 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 +395,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,13 +510,26 @@ 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 () => { 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 { @@ -546,53 +547,65 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer inFlightThreadIdsRef.current.delete(threadKey); } }, [ + 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 ─────────────────────────────────────────── 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..c9b08b75382d 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"; @@ -194,7 +195,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 +403,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 +899,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [ editingPendingProject, editingPendingTask, + planModeEnabled, selectedEnvironmentServerConfig, selectedModel, selectedProject, selectedProjectDraftKey, - planModeEnabled, planModePreferenceLoaded, startFromOrigin, workspaceMode, 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..6309f6040d98 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-submit.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + canSubmitNewTaskDraft, + resolveNewTaskComposerSelection, + shouldInterpretNewTaskSubmit, +} from "./new-task-submit"; + +describe("new-task submission eligibility", () => { + const readyInput = { + text: "Implement the task", + incomingShareReady: true, + importingShare: false, + 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("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("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..6ed591468dd3 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-submit.ts @@ -0,0 +1,37 @@ +export function shouldInterpretNewTaskSubmit(input: { + readonly editingPendingTask: object | null; +}): boolean { + return input.editingPendingTask === null; +} + +export function resolveNewTaskComposerSelection(input: { + readonly previousDraftKey: string | null; + readonly draftKey: string | null; + readonly promptLength: number; + readonly selection: { readonly start: number; readonly end: number }; +}): { 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 submitting: boolean; + readonly workspaceMode: "local" | "worktree"; + readonly selectedBranchName: string | null; +}): boolean { + return ( + input.text.trim().length > 0 && + input.incomingShareReady && + !input.importingShare && + !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..58870ab5348d --- /dev/null +++ b/apps/mobile/src/features/threads/plan-mode.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + getBuiltInComposerSlashCommands, + getPlanModeComposerSlashCommands, + replaceCurrentComposerTrigger, + 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", + ); + }); + + describe("existing-thread submit parsing", () => { + 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..41dcd98f0ded --- /dev/null +++ b/apps/mobile/src/features/threads/plan-mode.ts @@ -0,0 +1,124 @@ +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; +} 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..0faf023a1be8 --- /dev/null +++ b/apps/mobile/src/features/threads/use-plan-mode-enabled.ts @@ -0,0 +1,18 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { mobilePreferencesAtom } from "../../state/preferences"; +import { resolvePlanModeEnabled } from "./plan-mode"; + +export function usePlanModePreferenceState() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferencesResult); + 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/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index a97252c7b72b..c13ea0dee8c3 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -172,9 +172,24 @@ 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({ baseFontSize: 17 }); + await expect(loadPreferences()).resolves.toEqual({ planModeEnabled: true }); }); it("falls back to secure storage when SQLite cannot save preferences", async () => { diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 1da9c9f7ac6f..38b1ff29385b 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,7 +5,10 @@ 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, +} from "@t3tools/contracts"; import * as MobileDatabase from "./mobile-database"; import * as MobileSecureStorage from "./mobile-secure-storage"; @@ -13,6 +16,7 @@ import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-sto const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; +const isSyncedClientPreferencesUpdatedAt = Schema.is(SyncedClientPreferencesUpdatedAt); export interface Preferences { readonly liveActivitiesEnabled?: boolean; @@ -27,6 +31,11 @@ export interface Preferences { readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; readonly autoSettleOnMerge?: boolean; + /** + * Offline cache of the most recently reconciled environment preference. + */ + readonly planModeEnabled?: boolean; + 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 +44,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()( @@ -89,8 +96,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; autoSettleOnMerge?: boolean; - legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; + syncedClientPreferencesUpdatedAt?: string; + legacyThreadListEnabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -130,12 +138,18 @@ 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 ( + typeof parsed.syncedClientPreferencesUpdatedAt === "string" && + isSyncedClientPreferencesUpdatedAt(parsed.syncedClientPreferencesUpdatedAt) + ) { + preferences.syncedClientPreferencesUpdatedAt = parsed.syncedClientPreferencesUpdatedAt; + } + if (typeof parsed.legacyThreadListEnabled === "boolean") { + preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; + } return preferences; } 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..2bf942df819d --- /dev/null +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -0,0 +1,187 @@ +import type { + EnvironmentId, + PatchSyncedClientPreferencesRequest, + SyncedClientPreferences, +} from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; + +import type { Preferences } from "../persistence/mobile-preferences"; + +export interface EnvironmentPreferenceState { + readonly environmentId: EnvironmentId; + readonly preferences: SyncedClientPreferences | undefined; + readonly canPatch?: boolean; +} + +export interface PlanModePreferencePatchTarget { + readonly environmentId: EnvironmentId; + readonly input: PatchSyncedClientPreferencesRequest; +} + +export async function fanOutPlanModePreferencePatches( + targets: ReadonlyArray, + patch: (target: PlanModePreferencePatchTarget) => Promise, +): Promise { + await Promise.allSettled(targets.map(patch)); +} + +export function canonicalPlanModePreferencePatch( + preferences: SyncedClientPreferences, +): Partial | null { + return preferences.planModeEnabled === undefined + ? null + : { + planModeEnabled: preferences.planModeEnabled, + syncedClientPreferencesUpdatedAt: preferences.updatedAt, + }; +} + +export function settlePendingPlanModePreferencePatch(input: { + readonly pendingByEnvironment: Map; + readonly target: PlanModePreferencePatchTarget; + readonly result: AtomCommandResult; +}): Partial | null { + if (input.pendingByEnvironment.get(input.target.environmentId) !== input.target.input.updatedAt) { + return null; + } + input.pendingByEnvironment.delete(input.target.environmentId); + return input.result._tag === "Success" + ? canonicalPlanModePreferencePatch(input.result.value) + : null; +} + +export function nextMobileSyncedPreferencesUpdatedAt( + currentUpdatedAts: ReadonlyArray, + now: string, +): string { + const latest = currentUpdatedAts.reduce( + (current, candidate) => + candidate !== undefined && (current === undefined || candidate > current) + ? candidate + : current, + undefined, + ); + if (latest === undefined || now > latest) return now; + return new Date(Date.parse(latest) + 1).toISOString(); +} + +export function createPlanModePreferenceWrite(input: { + readonly value: boolean; + readonly connectedEnvironmentIds: ReadonlyArray; + readonly currentUpdatedAts: ReadonlyArray; + readonly now: string; +}): { + readonly localPatch: Pick; + readonly environmentPatches: ReadonlyArray; +} { + const updatedAt = nextMobileSyncedPreferencesUpdatedAt(input.currentUpdatedAts, input.now); + const request = { + patch: { planModeEnabled: input.value }, + updatedAt, + } as const; + return { + localPatch: { + planModeEnabled: input.value, + syncedClientPreferencesUpdatedAt: updatedAt, + }, + environmentPatches: input.connectedEnvironmentIds.map((environmentId) => ({ + environmentId, + input: request, + })), + }; +} + +export function reconcilePlanModePreferences(input: { + readonly localPlanModeEnabled: boolean | undefined; + readonly localUpdatedAt: string | undefined; + readonly environments: ReadonlyArray; + readonly now: string; +}): { + readonly localPatch: Partial | null; + readonly environmentPatches: ReadonlyArray; +} { + if (input.environments.length === 0) { + return { localPatch: null, environmentPatches: [] }; + } + + const environmentCandidates = input.environments + .flatMap((environment) => + environment.preferences?.planModeEnabled === undefined + ? [] + : [ + { + source: environment.environmentId, + value: environment.preferences.planModeEnabled, + updatedAt: environment.preferences.updatedAt, + }, + ], + ) + .toSorted( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.source.localeCompare(right.source), + ); + const latestEnvironment = environmentCandidates.at(-1); + const latestObservedEnvironmentUpdatedAt = input.environments.reduce( + (latest, environment) => { + const candidate = environment.preferences?.updatedAt; + return candidate !== undefined && (latest === undefined || candidate > latest) + ? candidate + : latest; + }, + undefined, + ); + const boundedLocalUpdatedAt = + input.localUpdatedAt !== undefined && + latestObservedEnvironmentUpdatedAt !== undefined && + input.localUpdatedAt > latestObservedEnvironmentUpdatedAt + ? nextMobileSyncedPreferencesUpdatedAt( + [latestObservedEnvironmentUpdatedAt], + latestObservedEnvironmentUpdatedAt, + ) + : input.localUpdatedAt; + // Across environments, the newest plan-bearing stamp wins. Exact stamp ties + // use environment id for deterministic convergence; an unstamped legacy + // device value seeds only when no environment already owns the preference. + const localWins = + input.localPlanModeEnabled !== undefined && + boundedLocalUpdatedAt !== undefined && + (latestEnvironment === undefined || boundedLocalUpdatedAt > latestEnvironment.updatedAt); + const value = localWins + ? input.localPlanModeEnabled + : (latestEnvironment?.value ?? input.localPlanModeEnabled ?? false); + const winningUpdatedAt = localWins ? boundedLocalUpdatedAt : latestEnvironment?.updatedAt; + + const environmentsNeedingReconciliation = input.environments.filter( + (environment) => + environment.preferences?.planModeEnabled !== value || + environment.preferences?.updatedAt !== winningUpdatedAt, + ); + const observedUpdatedAts = [ + boundedLocalUpdatedAt, + ...input.environments.map((environment) => environment.preferences?.updatedAt), + ]; + const needsNewStamp = + environmentsNeedingReconciliation.length > 0 && + observedUpdatedAts.some( + (updatedAt) => + updatedAt !== undefined && (winningUpdatedAt === undefined || updatedAt > winningUpdatedAt), + ); + const updatedAt = needsNewStamp + ? nextMobileSyncedPreferencesUpdatedAt(observedUpdatedAts, input.now) + : (winningUpdatedAt ?? input.now); + const localPatch = + input.localPlanModeEnabled === value && input.localUpdatedAt === updatedAt + ? null + : { + planModeEnabled: value, + syncedClientPreferencesUpdatedAt: updatedAt, + }; + const request = { patch: { planModeEnabled: value }, updatedAt } as const; + + return { + localPatch, + environmentPatches: (needsNewStamp ? input.environments : environmentsNeedingReconciliation) + .filter((environment) => environment.canPatch !== false) + .map(({ environmentId }) => ({ environmentId, input: request })), + }; +} 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..d02e5be58859 --- /dev/null +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -0,0 +1,325 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createPlanModePreferenceWrite, + fanOutPlanModePreferencePatches, + reconcilePlanModePreferences, + settlePendingPlanModePreferencePatch, +} from "./synced-client-preferences-model"; + +const environmentId = (value: string) => EnvironmentId.make(value); + +describe("synced client preferences", () => { + 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, + syncedClientPreferencesUpdatedAt: "2026-08-14T12:00:00.000Z", + }, + environmentPatches: [], + }); + }); + + it("fans a mobile toggle out to every connected environment", () => { + const write = createPlanModePreferenceWrite({ + value: true, + connectedEnvironmentIds: [environmentId("environment-1"), environmentId("environment-2")], + currentUpdatedAts: ["2026-08-14T12:00:00.000Z", "2026-08-14T12:02:00.000Z"], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(write.localPatch).toEqual({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAt: "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("keeps offline toggles device-local", () => { + expect( + createPlanModePreferenceWrite({ + value: false, + connectedEnvironmentIds: [], + currentUpdatedAts: [], + now: "2026-08-14T12:00:00.000Z", + }), + ).toEqual({ + localPatch: { + planModeEnabled: false, + syncedClientPreferencesUpdatedAt: "2026-08-14T12:00:00.000Z", + }, + environmentPatches: [], + }); + }); + + 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("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?.syncedClientPreferencesUpdatedAt, + 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("advances once 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:30:00.000Z", + }); + const second = reconcilePlanModePreferences({ + localPlanModeEnabled: first.localPatch?.planModeEnabled, + localUpdatedAt: first.localPatch?.syncedClientPreferencesUpdatedAt, + environments, + now: "2026-08-14T13:01:00.000Z", + }); + + expect(first.environmentPatches).toHaveLength(2); + expect(first.environmentPatches[0]?.input.updatedAt).toBe("2026-08-14T13:00:00.001Z"); + expect(second.environmentPatches[0]?.input.updatedAt).toBe( + first.environmentPatches[0]?.input.updatedAt, + ); + }); + + 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("bounds a fast device clock 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: "2099-01-01T00:00:01.000Z", + }); + + expect(reconciliation.localPatch).toEqual({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAt: "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("clears pending reconciliation from an older canonical patch ack", () => { + const target = { + environmentId: environmentId("environment-1"), + input: { + patch: { planModeEnabled: true }, + updatedAt: "2099-01-01T00:00:00.000Z", + }, + } as const; + const pendingByEnvironment = new Map([[target.environmentId, target.input.updatedAt]]); + const canonical = { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:30.000Z", + } as const; + + const localPatch = settlePendingPlanModePreferencePatch({ + pendingByEnvironment, + target, + result: AsyncResult.success(canonical), + }); + const next = reconcilePlanModePreferences({ + localPlanModeEnabled: localPatch?.planModeEnabled, + localUpdatedAt: localPatch?.syncedClientPreferencesUpdatedAt, + environments: [ + { + environmentId: target.environmentId, + canPatch: true, + preferences: canonical, + }, + ], + now: "2099-01-01T00:00:01.000Z", + }); + + expect(pendingByEnvironment.size).toBe(0); + expect(localPatch).toEqual({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAt: 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("continues fan-out when one environment write fails", async () => { + const attempted: string[] = []; + const targets = createPlanModePreferenceWrite({ + value: true, + connectedEnvironmentIds: [environmentId("failing"), environmentId("healthy")], + currentUpdatedAts: [], + now: "2026-08-14T12:00:00.000Z", + }).environmentPatches; + + await expect( + fanOutPlanModePreferencePatches(targets, async (target) => { + attempted.push(target.environmentId); + if (target.environmentId === environmentId("failing")) throw new Error("offline"); + }), + ).resolves.toBeUndefined(); + expect(attempted).toEqual([environmentId("failing"), environmentId("healthy")]); + }); +}); 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..bab525a996e6 --- /dev/null +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -0,0 +1,145 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope, type EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import { environmentShell } from "./shell"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "./preferences"; +import { serverEnvironment } from "./server"; +import { environmentSession } from "./session"; +import { useAtomCommand } from "./use-atom-command"; +import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; +import { + canonicalPlanModePreferencePatch, + createPlanModePreferenceWrite, + fanOutPlanModePreferencePatches, + reconcilePlanModePreferences, + settlePendingPlanModePreferencePatch, +} from "./synced-client-preferences-model"; + +function useConnectedEnvironmentPreferenceStates() { + const { connectedEnvironments } = useRemoteConnectionStatus(); + const connectedEnvironmentIds = useMemo( + () => + connectedEnvironments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + [connectedEnvironments], + ); + const statesAtom = useMemo( + () => + Atom.make((get) => + connectedEnvironmentIds.map((environmentId) => ({ + environmentId, + shell: get(environmentShell.stateValueAtom(environmentId)), + session: get(environmentSession.sessionStateValueAtom(environmentId)), + })), + ), + [connectedEnvironmentIds], + ); + const states = useAtomValue(statesAtom).map(({ environmentId, shell, session }) => ({ + environmentId, + shell, + canPatch: + session?.authenticated === true && + session.scopes?.includes(AuthOrchestrationOperateScope) === true, + })); + return { + connectedEnvironmentIds: states + .filter((state) => state.canPatch) + .map((state) => state.environmentId), + states, + } as const; +} + +export function useSyncedClientPreferences(): void { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const { states } = useConnectedEnvironmentPreferenceStates(); + const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { + label: "synced client preferences reconciliation", + reportFailure: false, + }); + const pendingByEnvironment = useRef(new Map()); + + useEffect(() => { + if (!AsyncResult.isSuccess(preferencesResult)) { + return; + } + const liveStates = states.filter(({ shell }) => shell.status === "live"); + if (liveStates.length === 0) return; + for (const { environmentId, shell } of liveStates) { + const updatedAt = + shell.snapshot._tag === "Some" + ? shell.snapshot.value.syncedClientPreferences?.updatedAt + : undefined; + if (updatedAt === pendingByEnvironment.current.get(environmentId)) { + pendingByEnvironment.current.delete(environmentId); + } + } + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: preferencesResult.value.planModeEnabled, + localUpdatedAt: 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(), + }); + if (reconciliation.localPatch !== null) savePreferences(reconciliation.localPatch); + for (const target of reconciliation.environmentPatches) { + if (pendingByEnvironment.current.get(target.environmentId) === target.input.updatedAt) { + continue; + } + pendingByEnvironment.current.set(target.environmentId, target.input.updatedAt); + void patchPreferences(target).then((result) => { + const localPatch = settlePendingPlanModePreferencePatch({ + pendingByEnvironment: pendingByEnvironment.current, + target, + result, + }); + if (localPatch !== null) savePreferences(localPatch); + }); + } + }, [patchPreferences, preferencesResult, savePreferences, states]); +} + +export function useUpdatePlanModePreference() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const { connectedEnvironmentIds, states } = useConnectedEnvironmentPreferenceStates(); + const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { + label: "synced client preferences update", + reportFailure: false, + }); + + return useCallback( + (value: boolean) => { + const current = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {}; + const write = createPlanModePreferenceWrite({ + value, + connectedEnvironmentIds, + currentUpdatedAts: [ + current.syncedClientPreferencesUpdatedAt, + ...states.map(({ shell }) => + shell.snapshot._tag === "Some" + ? shell.snapshot.value.syncedClientPreferences?.updatedAt + : undefined, + ), + ], + now: new Date().toISOString(), + }); + savePreferences(write.localPatch); + void fanOutPlanModePreferencePatches(write.environmentPatches, async (target) => { + const result = await patchPreferences(target); + if (AsyncResult.isSuccess(result)) { + const localPatch = canonicalPlanModePreferencePatch(result.value); + if (localPatch !== null) savePreferences(localPatch); + } + }); + }, + [connectedEnvironmentIds, patchPreferences, preferencesResult, savePreferences, states], + ); +} diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index eede506976a7..f52bdae2859c 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,30 @@ 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, +): { + readonly planModeEnabled: boolean; + readonly settings: ThreadSettingsSnapshot; +} { + const planModeEnabled = readPlanModeEnabled(); + return { + planModeEnabled, + settings: resolveQueuedThreadSettings(message, thread, planModeEnabled), }; } diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index b12ad2dc5843..7bd409e7cd00 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,61 @@ 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 the live preference when legacy plan mode is disabled mid-drain", () => { + 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 queuedSettings = resolveQueuedThreadSettings(message, thread, planModeEnabled); + + planModeEnabled = false; + const sendDecision = resolveQueuedThreadSendDecision(message, thread, () => { + preferenceReads += 1; + return planModeEnabled; + }); + + expect(queuedSettings.interactionMode).toBe("plan"); + expect(preferenceReads).toBe(1); + expect(sendDecision).toMatchObject({ + planModeEnabled: false, + settings: { interactionMode: "default" }, + }); + }); + 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..f27c35dfba8f 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -41,6 +41,8 @@ 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 { resolveComposerInteractionMode } from "../features/threads/plan-mode"; +import { usePlanModeEnabled } from "../features/threads/use-plan-mode-enabled"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -78,6 +80,7 @@ export function useThreadComposerState() { const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const planModeEnabled = usePlanModeEnabled(); useEffect(() => { ensureComposerDraftsLoaded(); @@ -102,7 +105,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; @@ -159,7 +165,10 @@ export function useThreadComposerState() { attachments, modelSelection: draft.modelSelection ?? thread.modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, - interactionMode: draft.interactionMode ?? thread.interactionMode, + interactionMode: resolveComposerInteractionMode({ + interactionMode: draft.interactionMode ?? thread.interactionMode, + planModeEnabled, + }), createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey); @@ -175,7 +184,7 @@ export function useThreadComposerState() { ); }); return messageId; - }, [selectedThreadDetail, selectedThreadShell]); + }, [planModeEnabled, 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..cf775345f28d 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 { planModeEnabled, settings } = resolveQueuedThreadSendDecision( + queuedMessage, + thread, + readPlanModeEnabled, + ); const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage); if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { @@ -230,7 +248,10 @@ export function useThreadOutboxDrain(): void { }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, - interactionMode: settings.interactionMode, + interactionMode: resolveComposerInteractionMode({ + interactionMode: settings.interactionMode, + planModeEnabled, + }), createdAt: queuedMessage.createdAt, }, }); @@ -251,6 +272,7 @@ export function useThreadOutboxDrain(): void { creation: QueuedThreadCreation, projectCwd: string, ) => { + const planModeEnabled = readPlanModeEnabled(); const modelSelection = queuedMessage.modelSelection; if (modelSelection === undefined) { return false; @@ -269,7 +291,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 +308,9 @@ export function useThreadOutboxDrain(): void { ); useEffect(() => { - if (dispatchingQueuedMessageId !== null) { + // Wait for the stored mode before draining. Load errors resolve to defaults, + // so only an atom construction failure can leave this pending forever. + if (!planModePreferenceLoaded || dispatchingQueuedMessageId !== null) { return; } @@ -414,6 +441,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..7444af9842c3 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,72 @@ 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, + 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, + 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 +262,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..3a355aa05ddc 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -2,6 +2,7 @@ import type { OrchestrationEvent, OrchestrationReadModel, ProjectId, + SyncedClientPreferencesAggregateId, ThreadId, } from "@t3tools/contracts"; import { OrchestrationCommand } from "@t3tools/contracts"; @@ -59,10 +60,15 @@ interface CommandEnvelope { } function commandToAggregateRef(command: OrchestrationCommand): { - readonly aggregateKind: "project" | "thread"; - readonly aggregateId: ProjectId | ThreadId; + readonly aggregateKind: "client-preferences" | "project" | "thread"; + readonly aggregateId: SyncedClientPreferencesAggregateId | ProjectId | ThreadId; } { switch (command.type) { + case "client-preferences.patch": + return { + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + }; case "project.create": case "project.meta.update": case "project.delete": diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..42867f9b17e6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -159,6 +159,11 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { `; assert.deepEqual(messageRows, [{ messageId: "message-1", text: "hello" }]); + const syncedPreferenceRows = yield* sql>` + SELECT * FROM projection_synced_client_preferences + `; + assert.deepEqual(syncedPreferenceRows, []); + const stateRows = yield* sql<{ readonly projector: string; readonly lastAppliedSequence: number; @@ -1171,6 +1176,96 @@ 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-stale"), + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + occurredAt: "2026-08-14T11:00:00.000Z", + commandId: CommandId.make("preferences-stale"), + causationEventId: null, + correlationId: CommandId.make("preferences-stale"), + metadata: {}, + payload: { + patch: { planModeEnabled: false }, + updatedAt: "2026-08-14T11: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* projectionPipeline.bootstrap; + yield* projectionPipeline.projectEvent(equalStampEvent); + yield* projectionPipeline.projectEvent(equalStampEvent); + + const readPreferences = () => + sql<{ + readonly planModeEnabled: number; + readonly appearanceMode: string | null; + readonly updatedAt: string; + }>` + SELECT + plan_mode_enabled AS "planModeEnabled", + appearance_mode AS "appearanceMode", + updated_at AS "updatedAt" + FROM projection_synced_client_preferences + `; + const expected = [ + { + planModeEnabled: 0, + appearanceMode: "dark", + updatedAt: "2026-08-14T12: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..38718d8eef2c 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,43 @@ 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 appearanceMode = event.payload.patch.appearanceMode ?? null; + const themeId = event.payload.patch.themeId ?? null; + return sql` + INSERT INTO projection_synced_client_preferences ( + singleton_id, + plan_mode_enabled, + appearance_mode, + theme_id, + updated_at + ) + VALUES (1, ${planModeEnabled}, ${appearanceMode}, ${themeId}, ${event.payload.updatedAt}) + ON CONFLICT (singleton_id) + DO UPDATE SET + plan_mode_enabled = COALESCE(excluded.plan_mode_enabled, plan_mode_enabled), + appearance_mode = COALESCE(excluded.appearance_mode, appearance_mode), + theme_id = COALESCE(excluded.theme_id, theme_id), + updated_at = excluded.updated_at + WHERE excluded.updated_at >= 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..1179c9f176bd 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,12 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), }); const ProjectionStateDbRowSchema = ProjectionState; +const ProjectionSyncedClientPreferencesRowSchema = Schema.Struct({ + planModeEnabled: Schema.NullOr(Schema.Number), + appearanceMode: Schema.NullOr(Schema.Literals(["system", "light", "dark"])), + themeId: Schema.NullOr(Schema.String), + updatedAt: IsoDateTime, +}); const ProjectionCountsRowSchema = Schema.Struct({ projectCount: Schema.Number, threadCount: Schema.Number, @@ -193,6 +200,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 +217,20 @@ 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) => ({ + ...(value.planModeEnabled === null ? {} : { planModeEnabled: value.planModeEnabled !== 0 }), + ...(value.appearanceMode === null ? {} : { appearanceMode: value.appearanceMode }), + ...(value.themeId === null ? {} : { themeId: value.themeId }), + updatedAt: value.updatedAt, + }), + }); +} + function escapeLikePattern(value: string): string { return value.replaceAll("!", "!!").replaceAll("%", "!%").replaceAll("_", "!_"); } @@ -763,6 +785,21 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getSyncedClientPreferencesRow = SqlSchema.findOneOption({ + Request: Schema.Void, + Result: ProjectionSyncedClientPreferencesRowSchema, + execute: () => + sql` + SELECT + plan_mode_enabled AS "planModeEnabled", + appearance_mode AS "appearanceMode", + theme_id AS "themeId", + updated_at AS "updatedAt" + FROM projection_synced_client_preferences + WHERE singleton_id = 1 + `, + }); + const readProjectionCounts = SqlSchema.findOne({ Request: Schema.Void, Result: ProjectionCountsRowSchema, @@ -1517,6 +1554,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + getSyncedClientPreferencesRow(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:getSyncedClientPreferences:query", + "ProjectionSnapshotQuery.getSnapshot:getSyncedClientPreferences:decodeRow", + ), + ), + ), ]), ) .pipe( @@ -1531,6 +1576,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointRows, latestTurnRows, stateRows, + syncedClientPreferencesRow, ]) => Effect.gen(function* () { const messagesByThread = new Map>(); @@ -1541,6 +1587,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const latestTurnByThread = new Map(); let updatedAt: string | null = null; + const syncedClientPreferences = mapSyncedClientPreferences( + syncedClientPreferencesRow, + ); + if (syncedClientPreferences !== undefined) { + updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); + } for (const row of projectRows) { updatedAt = maxIso(updatedAt, row.updatedAt); @@ -1717,6 +1769,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, + syncedClientPreferences, updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -1787,13 +1840,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + getSyncedClientPreferencesRow(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:getSyncedClientPreferences:query", + "ProjectionSnapshotQuery.getCommandReadModel:getSyncedClientPreferences:decodeRow", + ), + ), + ), ]), ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + proposedPlanRows, + sessionRows, + latestTurnRows, + stateRows, + syncedClientPreferencesRow, + ]) => Effect.sync(() => { let updatedAt: string | null = null; + const syncedClientPreferences = mapSyncedClientPreferences( + syncedClientPreferencesRow, + ); + if (syncedClientPreferences !== undefined) { + updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); + } const projects: OrchestrationProject[] = []; const threads: OrchestrationThread[] = []; @@ -1925,6 +2000,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, + syncedClientPreferences, updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", } satisfies OrchestrationReadModel; }), @@ -1981,96 +2057,120 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + getSyncedClientPreferencesRow(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:getSyncedClientPreferences:query", + "ProjectionSnapshotQuery.getShellSnapshot:getSyncedClientPreferences:decodeRow", + ), + ), + ), ]), ) .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, + syncedClientPreferencesRow, + ]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + const syncedClientPreferences = mapSyncedClientPreferences( + syncedClientPreferencesRow, + ); + 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 +2224,120 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + getSyncedClientPreferencesRow(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:getSyncedClientPreferences:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:getSyncedClientPreferences:decodeRow", + ), + ), + ), ]), ) .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, + syncedClientPreferencesRow, + ]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + const syncedClientPreferences = mapSyncedClientPreferences( + syncedClientPreferencesRow, + ); + 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 +2362,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { })), ); + const getSyncedClientPreferences: ProjectionSnapshotQueryShape["getSyncedClientPreferences"] = + () => + getSyncedClientPreferencesRow(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSyncedClientPreferences:query", + "ProjectionSnapshotQuery.getSyncedClientPreferences:decodeRow", + ), + ), + Effect.map(mapSyncedClientPreferences), + ); + const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => readProjectionCounts(undefined).pipe( Effect.mapError( @@ -2815,6 +2950,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.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..4421dc0200cb 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,5 +1,6 @@ import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { + ClientPreferencesPatchedPayload, OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, @@ -205,6 +206,29 @@ 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; + if (current !== undefined && current.updatedAt > payload.updatedAt) { + return { ...nextBase, updatedAt: model.updatedAt }; + } + return { + ...nextBase, + syncedClientPreferences: { + ...current, + ...payload.patch, + 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..9e289e895c2b --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts @@ -0,0 +1,86 @@ +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, + appearance_mode TEXT, + theme_id 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>` + SELECT * 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", "appearance_mode", "theme_id", "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..082f86708ba1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.ts @@ -0,0 +1,27 @@ +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, + appearance_mode TEXT, + theme_id 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..d60ca76674df 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,77 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("streams a client preference patch incrementally instead of replacing the shell", () => + 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 items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + assert.deepEqual(ack, canonicalPreferences); + assert.deepEqual(items[0], { + kind: "client-preferences-updated", + sequence: 1, + preferences: canonicalPreferences, + }); + assert.deepEqual(items[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..d76655eb9e0a 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,29 @@ const makeWsRpcLayer = ( Effect.orElseSucceed(() => Option.none()), ); + const getSyncedClientPreferences = () => projectionSnapshotQuery.getSyncedClientPreferences(); + + 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 +694,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 +708,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 +720,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 +734,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), @@ -1531,6 +1556,50 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.syncedClientPreferencesGet]: (_input) => + observeRpcEffect( + WS_METHODS.syncedClientPreferencesGet, + 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* 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/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index b332fe13c2f1..49e061ef6e23 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -1,12 +1,377 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -import { describe, expect, it } from "vite-plus/test"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { act, createElement, Fragment } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { mergeEnvironmentSettings, resolveEnvironmentIdentificationMode } from "./useSettings"; +import { + createSyncedClientPreferencesSliceAtom, + createSyncedPlanModeHydrationController, + createSyncedPlanModeWrite, + mergeEnvironmentSettings, + resolveEnvironmentIdentificationMode, + resolveSyncedPlanModeHydrationAction, + type SyncedPlanModeHydrationInput, + useSyncedPlanModeHydrationEffect, +} from "./useSettings"; + +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 as unknown, + documentElement: null as unknown, + }; + 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: "", + }; + document.body = container; + document.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); + return createRoot(container as unknown as Element); +} + +describe("synced plan mode", () => { + it("adopts an environment value over the local cache", () => { + expect( + resolveSyncedPlanModeHydrationAction({ + 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( + resolveSyncedPlanModeHydrationAction({ + 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-14T12:00:00.001Z", + }); + expect( + resolveSyncedPlanModeHydrationAction({ + 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( + createSyncedPlanModeWrite({ + value: false, + serverPreferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ + clientPatch: { planModeEnabled: false }, + request: { + patch: { planModeEnabled: false }, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }); + }); + + it("does not re-adopt stale server state while a local write is pending", () => { + expect( + resolveSyncedPlanModeHydrationAction({ + 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 with both environment hydration hooks mounted", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + const controller = createSyncedPlanModeHydrationController(); + 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 HydrationHook = ({ input }: { input: SyncedPlanModeHydrationInput }) => { + useSyncedPlanModeHydrationEffect(controller, input); + return null; + }; + const root = createHookTestRoot(); + + try { + for (let render = 0; render < 10; render += 1) { + await act(async () => { + root.render( + createElement( + Fragment, + null, + createElement(HydrationHook, { + input: { + 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, + }, + }), + createElement(HydrationHook, { + input: { + 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, + }, + }), + ), + ); + }); + } + + expect(localValue).toBe(true); + expect(persisted).toEqual([true]); + expect(patch).not.toHaveBeenCalled(); + } finally { + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + } + }); + + it("settles a pending write from an older canonical ack without re-patching", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const controller = createSyncedPlanModeHydrationController(); + 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 = createSyncedPlanModeHydrationController(); + 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 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(); + }); +}); describe("resolveEnvironmentIdentificationMode", () => { it("keeps identification hidden until client settings hydrate", () => { @@ -76,4 +441,14 @@ 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); + }); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 590949c09d9b..3577a6c38680 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -9,14 +9,19 @@ * access is intentionally named as such so environment-sensitive consumers * cannot silently read the wrong server's settings. */ -import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { useAtomValue } from "@effect/atom-react"; import { + AuthOrchestrationOperateScope, DEFAULT_SERVER_SETTINGS, type EnvironmentId, + type PatchSyncedClientPreferencesRequest, ServerSettings, type ServerSettingsPatch, + type SyncedClientPreferences, } from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; import { type ClientSettingsPatch, type ClientSettings, @@ -34,8 +39,12 @@ import { themeAllowsSidebarArtwork, } from "~/themePalette"; import * as Struct from "effect/Struct"; +import * as Option from "effect/Option"; +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 { useTheme } from "./useTheme"; @@ -49,6 +58,25 @@ let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; let clientSettingsHydrated = false; let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; +const SHELL_NOT_LIVE = Symbol("shell-not-live"); +const EMPTY_SYNCED_CLIENT_PREFERENCES_ATOM = Atom.make(SHELL_NOT_LIVE); +const EMPTY_AUTH_SESSION_ATOM = Atom.make(null); + +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; + }); +} + +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 +232,363 @@ function useClientSettingsValue(): ClientSettings { export function mergeEnvironmentSettings( serverSettings: ServerSettings, clientSettings: ClientSettings, + syncedClientPreferences?: SyncedClientPreferences, ): UnifiedSettings { - return { ...serverSettings, ...clientSettings }; + return { + ...serverSettings, + ...clientSettings, + ...(syncedClientPreferences?.planModeEnabled === undefined + ? {} + : { planModeEnabled: syncedClientPreferences.planModeEnabled }), + }; } function useMergedSettings( serverSettings: ServerSettings, + syncedClientPreferences: SyncedClientPreferences | undefined, selector: ((settings: UnifiedSettings) => T) | undefined, ): T { const clientSettings = useClientSettingsValue(); const merged = useMemo( - () => mergeEnvironmentSettings(serverSettings, clientSettings), - [clientSettings, serverSettings], + () => mergeEnvironmentSettings(serverSettings, clientSettings, syncedClientPreferences), + [clientSettings, serverSettings, syncedClientPreferences], ); return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); } +export function nextSyncedClientPreferencesUpdatedAt( + currentUpdatedAt: string | undefined, + now: string, +): string { + if (currentUpdatedAt === undefined || now > currentUpdatedAt) return now; + return new Date(Date.parse(currentUpdatedAt) + 1).toISOString(); +} + +export function createSyncedPlanModeWrite(input: { + readonly value: boolean; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly now: string; +}) { + return { + clientPatch: { planModeEnabled: input.value }, + request: { + patch: { planModeEnabled: input.value }, + updatedAt: nextSyncedClientPreferencesUpdatedAt( + input.serverPreferences?.updatedAt, + input.now, + ), + }, + } as const; +} + +type SyncedPlanModePatchTarget = { + readonly environmentId: EnvironmentId; + readonly input: PatchSyncedClientPreferencesRequest; +}; + +type SyncedPlanModePatch = ( + target: SyncedPlanModePatchTarget, +) => Promise>; + +export interface SyncedPlanModeHydrationInput { + readonly environmentId: EnvironmentId | null; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly clientHydrated: boolean; + readonly clientValue: boolean; + readonly live: boolean; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly canPatch: boolean; + readonly now: string; + readonly patch: SyncedPlanModePatch; + readonly persist: (value: boolean) => void; +} + +export type SyncedPlanModeHydrationAction = + | { readonly type: "none" } + | { readonly type: "adopt"; readonly value: boolean; readonly updatedAt: string } + | { readonly type: "seed"; readonly value: boolean; readonly updatedAt: string }; + +export function resolveSyncedPlanModeHydrationAction(input: { + readonly clientHydrated: boolean; + readonly clientValue: boolean; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly seedPending: boolean; + readonly writePending?: { readonly value: boolean; readonly updatedAt: string }; + readonly adoptedUpdatedAt?: string; + readonly now: string; +}): SyncedPlanModeHydrationAction { + if (!input.clientHydrated) return { type: "none" }; + if ( + input.writePending !== undefined && + (input.serverPreferences === undefined || + input.serverPreferences.updatedAt < input.writePending.updatedAt) + ) { + return { type: "none" }; + } + if (input.serverPreferences?.planModeEnabled !== undefined) { + return input.adoptedUpdatedAt !== undefined && + input.serverPreferences.updatedAt <= input.adoptedUpdatedAt + ? { type: "none" } + : { + type: "adopt", + value: input.serverPreferences.planModeEnabled, + updatedAt: input.serverPreferences.updatedAt, + }; + } + if (input.seedPending) return { type: "none" }; + return { + type: "seed", + value: input.clientValue, + updatedAt: nextSyncedClientPreferencesUpdatedAt(input.serverPreferences?.updatedAt, input.now), + }; +} + +export function createSyncedPlanModeHydrationController() { + const adoptedUpdatedAtByEnvironment = new Map(); + const seedPendingByEnvironment = new Map(); + const writePendingByEnvironment = new Map< + EnvironmentId, + { readonly value: boolean; readonly updatedAt: string } + >(); + const writeInFlightByEnvironment = new Map(); + const markAdopted = (environmentId: EnvironmentId, updatedAt: string) => { + const current = adoptedUpdatedAtByEnvironment.get(environmentId); + if (current === undefined || updatedAt > current) { + adoptedUpdatedAtByEnvironment.set(environmentId, updatedAt); + } + }; + + const settlePatch = (input: { + readonly environmentId: EnvironmentId; + readonly requestedUpdatedAt: string; + readonly result: AtomCommandResult; + readonly persist: (value: boolean) => void; + }) => { + if (writeInFlightByEnvironment.get(input.environmentId) === input.requestedUpdatedAt) { + writeInFlightByEnvironment.delete(input.environmentId); + } + if (input.result._tag === "Failure") { + if (seedPendingByEnvironment.get(input.environmentId) === input.requestedUpdatedAt) { + seedPendingByEnvironment.delete(input.environmentId); + } + return; + } + + const matchingWrite = + writePendingByEnvironment.get(input.environmentId)?.updatedAt === input.requestedUpdatedAt; + const matchingSeed = + seedPendingByEnvironment.get(input.environmentId) === input.requestedUpdatedAt; + if (!matchingWrite && !matchingSeed) return; + + if (matchingWrite) writePendingByEnvironment.delete(input.environmentId); + if (matchingSeed) seedPendingByEnvironment.delete(input.environmentId); + markAdopted(input.environmentId, input.result.value.updatedAt); + if (input.result.value.planModeEnabled !== undefined) { + input.persist(input.result.value.planModeEnabled); + } + }; + + const dispatchPatch = (input: { + readonly target: SyncedPlanModePatchTarget; + readonly patch: SyncedPlanModePatch; + readonly persist: (value: boolean) => void; + }) => { + const { environmentId } = input.target; + const requestedUpdatedAt = input.target.input.updatedAt; + writeInFlightByEnvironment.set(environmentId, requestedUpdatedAt); + void input.patch(input.target).then((result) => { + settlePatch({ environmentId, requestedUpdatedAt, result, persist: input.persist }); + }); + }; + + const synchronize = (input: SyncedPlanModeHydrationInput) => { + const environmentId = input.environmentId; + if (environmentId === null || environmentId !== input.primaryEnvironmentId || !input.live) { + return; + } + if (input.serverPreferences?.planModeEnabled !== undefined) { + seedPendingByEnvironment.delete(environmentId); + } + const pendingWrite = writePendingByEnvironment.get(environmentId); + if ( + pendingWrite !== undefined && + input.serverPreferences !== undefined && + input.serverPreferences.updatedAt >= pendingWrite.updatedAt + ) { + writePendingByEnvironment.delete(environmentId); + writeInFlightByEnvironment.delete(environmentId); + } + const activePendingWrite = writePendingByEnvironment.get(environmentId); + if ( + input.canPatch && + activePendingWrite !== undefined && + (input.serverPreferences === undefined || + input.serverPreferences.updatedAt < activePendingWrite.updatedAt) && + writeInFlightByEnvironment.get(environmentId) !== activePendingWrite.updatedAt + ) { + dispatchPatch({ + target: { + environmentId, + input: { + patch: { planModeEnabled: activePendingWrite.value }, + updatedAt: activePendingWrite.updatedAt, + }, + }, + patch: input.patch, + persist: input.persist, + }); + } + + const adoptedUpdatedAt = adoptedUpdatedAtByEnvironment.get(environmentId); + const action = resolveSyncedPlanModeHydrationAction({ + clientHydrated: input.clientHydrated, + clientValue: input.clientValue, + serverPreferences: input.serverPreferences, + seedPending: seedPendingByEnvironment.has(environmentId), + ...(activePendingWrite === undefined ? {} : { writePending: activePendingWrite }), + ...(adoptedUpdatedAt === undefined ? {} : { adoptedUpdatedAt }), + now: input.now, + }); + if (action.type === "adopt") { + markAdopted(environmentId, action.updatedAt); + if (input.clientValue !== action.value) input.persist(action.value); + return; + } + if (action.type !== "seed" || !input.canPatch) return; + + seedPendingByEnvironment.set(environmentId, action.updatedAt); + dispatchPatch({ + target: { + environmentId, + input: { + patch: { planModeEnabled: action.value }, + updatedAt: action.updatedAt, + }, + }, + patch: input.patch, + persist: input.persist, + }); + }; + + const write = (input: { + readonly environmentId: EnvironmentId | null; + readonly value: boolean; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly canPatch: boolean; + readonly now: string; + readonly patch: SyncedPlanModePatch; + readonly persist: (value: boolean) => void; + }) => { + if (input.environmentId === null || !input.canPatch) return; + const environmentId = input.environmentId; + const next = createSyncedPlanModeWrite({ + value: input.value, + serverPreferences: input.serverPreferences, + now: input.now, + }); + writePendingByEnvironment.set(environmentId, { + value: input.value, + updatedAt: next.request.updatedAt, + }); + dispatchPatch({ + target: { environmentId, input: next.request }, + patch: input.patch, + persist: input.persist, + }); + }; + + return { + synchronize, + write, + reset() { + adoptedUpdatedAtByEnvironment.clear(); + seedPendingByEnvironment.clear(); + writePendingByEnvironment.clear(); + writeInFlightByEnvironment.clear(); + }, + }; +} + +const syncedPlanModeHydrationController = createSyncedPlanModeHydrationController(); + +export function useSyncedPlanModeHydrationEffect( + controller: ReturnType, + input: SyncedPlanModeHydrationInput, +): void { + useEffect(() => { + controller.synchronize(input); + }, [ + controller, + input.canPatch, + input.clientHydrated, + input.clientValue, + input.environmentId, + input.live, + input.patch, + input.persist, + input.primaryEnvironmentId, + input.serverPreferences, + ]); +} + +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 useSyncedPlanModeHydration(environmentId: EnvironmentId | null) { + const clientSettings = useClientSettingsValue(); + const clientHydrated = useClientSettingsHydrated(); + const primaryEnvironmentId = usePrimaryEnvironment()?.environmentId ?? null; + const synced = useEnvironmentSyncedClientPreferences(environmentId); + const canPatch = useCanPatchSyncedClientPreferences(primaryEnvironmentId); + const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { + label: "synced client preferences seed", + reportFailure: false, + }); + const persistSyncedPlanMode = useCallback((value: boolean) => { + if (getClientSettingsSnapshot().planModeEnabled !== value) { + persistClientSettings({ ...getClientSettingsSnapshot(), planModeEnabled: value }); + } + }, []); + + useSyncedPlanModeHydrationEffect(syncedPlanModeHydrationController, { + environmentId, + primaryEnvironmentId, + clientHydrated, + clientValue: clientSettings.planModeEnabled, + live: synced.live, + serverPreferences: synced.preferences, + canPatch, + now: new Date().toISOString(), + patch: patchPreferences, + persist: persistSyncedPlanMode, + }); + + return synced.preferences; +} + export function useClientSettings( selector?: (settings: ClientSettings) => T, ): T { @@ -284,14 +651,25 @@ export function useEnvironmentSettings( selector?: (settings: UnifiedSettings) => T, ): T { const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); - return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); + const syncedClientPreferences = useSyncedPlanModeHydration(environmentId); + return useMergedSettings( + serverSettings ?? DEFAULT_SERVER_SETTINGS, + syncedClientPreferences, + 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 syncedClientPreferences = useSyncedPlanModeHydration(environmentId); + return useMergedSettings( + useAtomValue(primaryServerSettingsAtom), + syncedClientPreferences, + selector, + ); } /** @@ -305,6 +683,16 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { serverEnvironment.updateSettings, "server settings update", ); + const syncedEnvironmentId = usePrimaryEnvironment()?.environmentId ?? null; + const synced = useEnvironmentSyncedClientPreferences(syncedEnvironmentId); + const canPatchSyncedPreferences = useCanPatchSyncedClientPreferences(syncedEnvironmentId); + const patchSyncedClientPreferences = useAtomCommand( + serverEnvironment.patchSyncedClientPreferences, + { + label: "synced client preferences update", + reportFailure: false, + }, + ); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); @@ -323,8 +711,30 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { ...clientPatch, }); } + if (clientPatch.planModeEnabled !== undefined) { + syncedPlanModeHydrationController.write({ + environmentId: syncedEnvironmentId, + 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, + syncedEnvironmentId, + ], ); return updateSettings; @@ -354,6 +764,7 @@ export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationPromise = null; clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); + syncedPlanModeHydrationController.reset(); } export function __setClientSettingsForTests(settings: ClientSettings): void { diff --git a/docs/user/plan-mode.md b/docs/user/plan-mode.md new file mode 100644 index 000000000000..bf6491a58330 --- /dev/null +++ b/docs/user/plan-mode.md @@ -0,0 +1,9 @@ +# Plan Mode + +Enable **Plan Mode (Legacy)** in **Settings → General → Legacy features**. 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/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index fdccc4c47dd8..9f0457ce41ae 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -177,6 +177,31 @@ 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, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + + expect(next.syncedClientPreferences).toEqual({ + planModeEnabled: true, + 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..8bc61d5881c9 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,40 @@ 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, + 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..5a72adf6b1d7 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; @@ -633,6 +646,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 +933,7 @@ const ThreadSessionStopCommand = Schema.Struct({ }); const DispatchableClientOrchestrationCommand = Schema.Union([ + ClientPreferencesPatchCommand, ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, @@ -938,6 +962,7 @@ export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; export const ClientOrchestrationCommand = Schema.Union([ + ClientPreferencesPatchCommand, ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, @@ -1056,6 +1081,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 +1114,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 +1367,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 +1376,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..2d1325395b7e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -9,13 +9,68 @@ import { ServerSettings, ServerSettingsPatch, } from "./settings.ts"; +import { + SyncedClientPreferences, + SyncedClientPreferencesPatch, + 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 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", + 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", + ]); + expect(Object.keys(patch).sort()).toEqual(["appearanceMode", "planModeEnabled", "themeId"]); + }); + + it("rejects non-canonical LWW stamps", () => { + expect(() => + decodeSyncedClientPreferences({ + planModeEnabled: true, + updatedAt: "not-a-date", + }), + ).toThrow(); + }); + + 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..86aa0241cc95 --- /dev/null +++ b/packages/contracts/src/syncedClientPreferences.ts @@ -0,0 +1,50 @@ +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) && Number.isFinite(Date.parse(value))) || + "Synced client preferences updatedAt must be a canonical UTC timestamp.", + ), +); +export type SyncedClientPreferencesUpdatedAt = typeof SyncedClientPreferencesUpdatedAt.Type; + +const SyncedClientPreferenceFields = { + planModeEnabled: Schema.optionalKey(Schema.Boolean), + appearanceMode: Schema.optionalKey(SyncedClientAppearanceMode), + themeId: Schema.optionalKey(Schema.String), +} as const; + +/** Client preferences replicated through one environment's event log. */ +export const SyncedClientPreferences = Schema.Struct({ + ...SyncedClientPreferenceFields, + updatedAt: SyncedClientPreferencesUpdatedAt, +}); +export type SyncedClientPreferences = typeof SyncedClientPreferences.Type; + +/** Exactly the preference keys that clients may patch in this rollout. */ +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; From 0b1f7193d07d0dfce04dd77fb5ce8550df8274f5 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 19:39:42 -0400 Subject: [PATCH 02/42] fix(mobile): address review-bot findings - Preserve offline web plan-mode writes - Reject impossible preference timestamps - Retry failed web preference patches - Order rapid web toggles monotonically - Re-read plan mode before queued mobile delivery - Route environment-scoped preference writes correctly - Ignore stale mobile preference acknowledgements - Document the mobile Legacy settings location - Use Hermes-compatible reconciliation sorting --- .../state/synced-client-preferences-model.ts | 55 ++++-- .../state/synced-client-preferences.test.ts | 81 +++++++++ .../src/state/synced-client-preferences.ts | 21 ++- apps/mobile/src/state/thread-outbox-model.ts | 5 +- apps/mobile/src/state/thread-outbox.test.ts | 12 +- .../src/state/use-thread-outbox-drain.ts | 7 +- .../useSettings.environment-target.test.ts | 115 ++++++++++++ apps/web/src/hooks/useSettings.test.ts | 167 ++++++++++++++++++ apps/web/src/hooks/useSettings.ts | 71 +++++++- docs/user/plan-mode.md | 6 +- packages/contracts/src/settings.test.ts | 14 +- .../contracts/src/syncedClientPreferences.ts | 5 +- 12 files changed, 502 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/hooks/useSettings.environment-target.test.ts diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 2bf942df819d..99f9004bbbed 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -91,6 +91,30 @@ export function createPlanModePreferenceWrite(input: { }; } +export function createPlanModePreferenceWriteController() { + let latestRequestedUpdatedAt: string | undefined; + + return { + create(input: Parameters[0]) { + const write = createPlanModePreferenceWrite({ + ...input, + currentUpdatedAts: [latestRequestedUpdatedAt, ...input.currentUpdatedAts], + }); + latestRequestedUpdatedAt = write.localPatch.syncedClientPreferencesUpdatedAt; + return write; + }, + settle(input: { + readonly target: PlanModePreferencePatchTarget; + readonly result: AtomCommandResult; + }): Partial | null { + if (input.target.input.updatedAt !== latestRequestedUpdatedAt) return null; + return input.result._tag === "Success" + ? canonicalPlanModePreferencePatch(input.result.value) + : null; + }, + }; +} + export function reconcilePlanModePreferences(input: { readonly localPlanModeEnabled: boolean | undefined; readonly localUpdatedAt: string | undefined; @@ -104,22 +128,21 @@ export function reconcilePlanModePreferences(input: { return { localPatch: null, environmentPatches: [] }; } - const environmentCandidates = input.environments - .flatMap((environment) => - environment.preferences?.planModeEnabled === undefined - ? [] - : [ - { - source: environment.environmentId, - value: environment.preferences.planModeEnabled, - updatedAt: environment.preferences.updatedAt, - }, - ], - ) - .toSorted( - (left, right) => - left.updatedAt.localeCompare(right.updatedAt) || left.source.localeCompare(right.source), - ); + const environmentCandidates = input.environments.flatMap((environment) => + environment.preferences?.planModeEnabled === undefined + ? [] + : [ + { + source: environment.environmentId, + value: environment.preferences.planModeEnabled, + updatedAt: environment.preferences.updatedAt, + }, + ], + ); + environmentCandidates.sort( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.source.localeCompare(right.source), + ); const latestEnvironment = environmentCandidates.at(-1); const latestObservedEnvironmentUpdatedAt = input.environments.reduce( (latest, environment) => { diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index d02e5be58859..938429478d3f 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { createPlanModePreferenceWrite, + createPlanModePreferenceWriteController, fanOutPlanModePreferencePatches, reconcilePlanModePreferences, settlePendingPlanModePreferencePatch, @@ -67,6 +68,43 @@ describe("synced client preferences", () => { ]); }); + it("ignores a stale toggle ack after a newer same-millisecond write", () => { + const controller = createPlanModePreferenceWriteController(); + const environment = environmentId("environment-1"); + const first = controller.create({ + value: true, + connectedEnvironmentIds: [environment], + currentUpdatedAts: [], + now: "2026-08-14T12:00:00.000Z", + }); + const second = controller.create({ + value: false, + connectedEnvironmentIds: [environment], + currentUpdatedAts: [], + now: "2026-08-14T12:00:00.000Z", + }); + + expect(second.localPatch.syncedClientPreferencesUpdatedAt).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(second.localPatch); + expect( + controller.settle({ + target: first.environmentPatches[0]!, + result: AsyncResult.success({ + planModeEnabled: true, + updatedAt: first.environmentPatches[0]!.input.updatedAt, + }), + }), + ).toBeNull(); + }); + it("keeps offline toggles device-local", () => { expect( createPlanModePreferenceWrite({ @@ -115,6 +153,49 @@ describe("synced client preferences", () => { expect(reconciliation.environmentPatches[0]?.input.patch.planModeEnabled).toBe(true); }); + it("reconciles when ES2023 change-by-copy array methods are unavailable", () => { + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, "toSorted"); + // oxlint-disable-next-line no-extend-native -- Simulate Hermes, which omits this method. + Object.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) { + // oxlint-disable-next-line no-extend-native -- Restore the pre-test Hermes-like shape. + Reflect.deleteProperty(Array.prototype, "toSorted"); + } else { + // oxlint-disable-next-line no-extend-native -- Restore the pre-test implementation. + Object.defineProperty(Array.prototype, "toSorted", descriptor); + } + } + }); + it("reuses the winning stamp across pre-ack reconciliation passes", () => { const environments = [ { diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index bab525a996e6..36ef8f04167a 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -10,8 +10,7 @@ import { environmentSession } from "./session"; import { useAtomCommand } from "./use-atom-command"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; import { - canonicalPlanModePreferencePatch, - createPlanModePreferenceWrite, + createPlanModePreferenceWriteController, fanOutPlanModePreferencePatches, reconcilePlanModePreferences, settlePendingPlanModePreferencePatch, @@ -114,11 +113,12 @@ export function useUpdatePlanModePreference() { label: "synced client preferences update", reportFailure: false, }); + const writeController = useMemo(() => createPlanModePreferenceWriteController(), []); return useCallback( (value: boolean) => { const current = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {}; - const write = createPlanModePreferenceWrite({ + const write = writeController.create({ value, connectedEnvironmentIds, currentUpdatedAts: [ @@ -134,12 +134,17 @@ export function useUpdatePlanModePreference() { savePreferences(write.localPatch); void fanOutPlanModePreferencePatches(write.environmentPatches, async (target) => { const result = await patchPreferences(target); - if (AsyncResult.isSuccess(result)) { - const localPatch = canonicalPlanModePreferencePatch(result.value); - if (localPatch !== null) savePreferences(localPatch); - } + const localPatch = writeController.settle({ target, result }); + if (localPatch !== null) savePreferences(localPatch); }); }, - [connectedEnvironmentIds, patchPreferences, preferencesResult, savePreferences, states], + [ + connectedEnvironmentIds, + patchPreferences, + preferencesResult, + savePreferences, + states, + writeController, + ], ); } diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index f52bdae2859c..10e15799cdf1 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -107,13 +107,14 @@ export function resolveQueuedThreadSendDecision( thread: ThreadSettingsSnapshot, readPlanModeEnabled: () => boolean, ): { - readonly planModeEnabled: boolean; readonly settings: ThreadSettingsSnapshot; + readonly readStartTurnInteractionMode: () => ThreadSettingsSnapshot["interactionMode"]; } { const planModeEnabled = readPlanModeEnabled(); return { - planModeEnabled, 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 7bd409e7cd00..a833fa0a9e8d 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -154,20 +154,16 @@ describe("thread outbox", () => { runtimeMode: "approval-required", interactionMode: "plan", } as const; - const queuedSettings = resolveQueuedThreadSettings(message, thread, planModeEnabled); - - planModeEnabled = false; const sendDecision = resolveQueuedThreadSendDecision(message, thread, () => { preferenceReads += 1; return planModeEnabled; }); - expect(queuedSettings.interactionMode).toBe("plan"); + expect(sendDecision.settings.interactionMode).toBe("plan"); expect(preferenceReads).toBe(1); - expect(sendDecision).toMatchObject({ - planModeEnabled: false, - settings: { interactionMode: "default" }, - }); + planModeEnabled = false; + expect(sendDecision.readStartTurnInteractionMode()).toBe("default"); + expect(preferenceReads).toBe(2); }); it("compares model options as part of the queued settings change", () => { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index cf775345f28d..825c13bbfab0 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -181,7 +181,7 @@ export function useThreadOutboxDrain(): void { const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { - const { planModeEnabled, settings } = resolveQueuedThreadSendDecision( + const { settings, readStartTurnInteractionMode } = resolveQueuedThreadSendDecision( queuedMessage, thread, readPlanModeEnabled, @@ -248,10 +248,7 @@ export function useThreadOutboxDrain(): void { }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, - interactionMode: resolveComposerInteractionMode({ - interactionMode: settings.interactionMode, - planModeEnabled, - }), + interactionMode: readStartTurnInteractionMode(), createdAt: queuedMessage.createdAt, }, }); 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..f72453cc01ec --- /dev/null +++ b/apps/web/src/hooks/useSettings.environment-target.test.ts @@ -0,0 +1,115 @@ +import { EnvironmentId, type PatchSyncedClientPreferencesRequest } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../test/reactHookHarness"; + +const testState = vi.hoisted(() => ({ + primaryEnvironmentId: "primary" as EnvironmentId, + sessionAtom: Symbol("session"), + updateSettingsAtom: Symbol("update-settings"), + patchPreferencesAtom: Symbol("patch-preferences"), + 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, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: unknown) => + 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.primaryEnvironmentId }), +})); + +vi.mock("~/state/server", () => ({ + primaryServerSettingsAtom: Symbol("primary-settings"), + serverEnvironment: { + updateSettings: testState.updateSettingsAtom, + patchSyncedClientPreferences: testState.patchPreferencesAtom, + }, +})); + +vi.mock("~/state/session", () => ({ + environmentSession: { + sessionStateValueAtom: () => 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 { useUpdateEnvironmentSettings } from "./useSettings"; + +describe("useUpdateEnvironmentSettings", () => { + beforeEach(() => { + hooks.reset(); + 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(); + }); + + 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), + }, + }); + }); +}); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 49e061ef6e23..acb9b9cc684d 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -7,6 +7,7 @@ import { import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import * as Option from "effect/Option"; +import * as Cause from "effect/Cause"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { act, createElement, Fragment } from "react"; import { createRoot } from "react-dom/client"; @@ -335,6 +336,172 @@ describe("synced plan mode", () => { expect(patch).not.toHaveBeenCalled(); }); + it("uploads an offline write when patch access becomes available", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const controller = createSyncedPlanModeHydrationController(); + 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 = createSyncedPlanModeHydrationController((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 SyncedPlanModeHydrationInput; + + 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("keeps the latest rapid toggle when responses settle out of order", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const controller = createSyncedPlanModeHydrationController(); + const previous = { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const targets: Array["patch"]>[0]> = []; + const resolvePatches: Array< + (result: Awaited["patch"]>>) => void + > = []; + const patch: SyncedPlanModeHydrationInput["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("keeps the synced preference atom stable across thread-only shell updates", () => { const preferences = { planModeEnabled: true, diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 3577a6c38680..b6fd1036625a 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -269,16 +269,20 @@ export function nextSyncedClientPreferencesUpdatedAt( export function createSyncedPlanModeWrite(input: { readonly value: boolean; readonly serverPreferences: SyncedClientPreferences | undefined; + readonly pendingUpdatedAt?: string; readonly now: string; }) { + const serverUpdatedAt = input.serverPreferences?.updatedAt; + const currentUpdatedAt = + input.pendingUpdatedAt !== undefined && + (serverUpdatedAt === undefined || input.pendingUpdatedAt > serverUpdatedAt) + ? input.pendingUpdatedAt + : serverUpdatedAt; return { clientPatch: { planModeEnabled: input.value }, request: { patch: { planModeEnabled: input.value }, - updatedAt: nextSyncedClientPreferencesUpdatedAt( - input.serverPreferences?.updatedAt, - input.now, - ), + updatedAt: nextSyncedClientPreferencesUpdatedAt(currentUpdatedAt, input.now), }, } as const; } @@ -345,7 +349,18 @@ export function resolveSyncedPlanModeHydrationAction(input: { }; } -export function createSyncedPlanModeHydrationController() { +const SYNCED_PLAN_MODE_RETRY_DELAY_MS = 1_000; + +type SyncedPlanModeRetryScheduler = (retry: () => void) => () => void; + +const scheduleSyncedPlanModeRetry: SyncedPlanModeRetryScheduler = (retry) => { + const timer = setTimeout(retry, SYNCED_PLAN_MODE_RETRY_DELAY_MS); + return () => clearTimeout(timer); +}; + +export function createSyncedPlanModeHydrationController( + scheduleRetry: SyncedPlanModeRetryScheduler = scheduleSyncedPlanModeRetry, +) { const adoptedUpdatedAtByEnvironment = new Map(); const seedPendingByEnvironment = new Map(); const writePendingByEnvironment = new Map< @@ -353,6 +368,22 @@ export function createSyncedPlanModeHydrationController() { { readonly value: boolean; readonly updatedAt: string } >(); const writeInFlightByEnvironment = new Map(); + const synchronizeAgainByEnvironment = new Map void>(); + const cancelRetryByEnvironment = new Map void>(); + const cancelRetry = (environmentId: EnvironmentId) => { + cancelRetryByEnvironment.get(environmentId)?.(); + cancelRetryByEnvironment.delete(environmentId); + }; + const requestRetry = (environmentId: EnvironmentId) => { + if (cancelRetryByEnvironment.has(environmentId)) return; + cancelRetryByEnvironment.set( + environmentId, + scheduleRetry(() => { + cancelRetryByEnvironment.delete(environmentId); + synchronizeAgainByEnvironment.get(environmentId)?.(); + }), + ); + }; const markAdopted = (environmentId: EnvironmentId, updatedAt: string) => { const current = adoptedUpdatedAtByEnvironment.get(environmentId); if (current === undefined || updatedAt > current) { @@ -373,6 +404,7 @@ export function createSyncedPlanModeHydrationController() { if (seedPendingByEnvironment.get(input.environmentId) === input.requestedUpdatedAt) { seedPendingByEnvironment.delete(input.environmentId); } + requestRetry(input.environmentId); return; } @@ -382,6 +414,7 @@ export function createSyncedPlanModeHydrationController() { seedPendingByEnvironment.get(input.environmentId) === input.requestedUpdatedAt; if (!matchingWrite && !matchingSeed) return; + cancelRetry(input.environmentId); if (matchingWrite) writePendingByEnvironment.delete(input.environmentId); if (matchingSeed) seedPendingByEnvironment.delete(input.environmentId); markAdopted(input.environmentId, input.result.value.updatedAt); @@ -405,9 +438,13 @@ export function createSyncedPlanModeHydrationController() { const synchronize = (input: SyncedPlanModeHydrationInput) => { const environmentId = input.environmentId; - if (environmentId === null || environmentId !== input.primaryEnvironmentId || !input.live) { + if (environmentId === null) return; + if (environmentId !== input.primaryEnvironmentId || !input.live) { + synchronizeAgainByEnvironment.delete(environmentId); + cancelRetry(environmentId); return; } + synchronizeAgainByEnvironment.set(environmentId, () => synchronize(input)); if (input.serverPreferences?.planModeEnabled !== undefined) { seedPendingByEnvironment.delete(environmentId); } @@ -419,6 +456,7 @@ export function createSyncedPlanModeHydrationController() { ) { writePendingByEnvironment.delete(environmentId); writeInFlightByEnvironment.delete(environmentId); + cancelRetry(environmentId); } const activePendingWrite = writePendingByEnvironment.get(environmentId); if ( @@ -481,17 +519,31 @@ export function createSyncedPlanModeHydrationController() { readonly patch: SyncedPlanModePatch; readonly persist: (value: boolean) => void; }) => { - if (input.environmentId === null || !input.canPatch) return; + if (input.environmentId === null) return; const environmentId = input.environmentId; + const controllerUpdatedAt = [ + adoptedUpdatedAtByEnvironment.get(environmentId), + seedPendingByEnvironment.get(environmentId), + writePendingByEnvironment.get(environmentId)?.updatedAt, + writeInFlightByEnvironment.get(environmentId), + ].reduce( + (latest, candidate) => + candidate !== undefined && (latest === undefined || candidate > latest) + ? candidate + : latest, + undefined, + ); const next = createSyncedPlanModeWrite({ value: input.value, serverPreferences: input.serverPreferences, + ...(controllerUpdatedAt === undefined ? {} : { pendingUpdatedAt: controllerUpdatedAt }), now: input.now, }); writePendingByEnvironment.set(environmentId, { value: input.value, updatedAt: next.request.updatedAt, }); + if (!input.canPatch) return; dispatchPatch({ target: { environmentId, input: next.request }, patch: input.patch, @@ -503,10 +555,13 @@ export function createSyncedPlanModeHydrationController() { synchronize, write, reset() { + for (const cancel of cancelRetryByEnvironment.values()) cancel(); adoptedUpdatedAtByEnvironment.clear(); seedPendingByEnvironment.clear(); writePendingByEnvironment.clear(); writeInFlightByEnvironment.clear(); + synchronizeAgainByEnvironment.clear(); + cancelRetryByEnvironment.clear(); }, }; } @@ -683,7 +738,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { serverEnvironment.updateSettings, "server settings update", ); - const syncedEnvironmentId = usePrimaryEnvironment()?.environmentId ?? null; + const syncedEnvironmentId = environmentId; const synced = useEnvironmentSyncedClientPreferences(syncedEnvironmentId); const canPatchSyncedPreferences = useCanPatchSyncedClientPreferences(syncedEnvironmentId); const patchSyncedClientPreferences = useAtomCommand( diff --git a/docs/user/plan-mode.md b/docs/user/plan-mode.md index bf6491a58330..658b67f5c5fc 100644 --- a/docs/user/plan-mode.md +++ b/docs/user/plan-mode.md @@ -1,8 +1,8 @@ # Plan Mode -Enable **Plan Mode (Legacy)** in **Settings → General → Legacy features**. The preference follows -the connected environment, so web, desktop, and mobile clients using that environment converge on -the same setting. +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 diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 2d1325395b7e..da8f03d2ebbc 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -52,12 +52,14 @@ describe("SyncedClientPreferences", () => { }); it("rejects non-canonical LWW stamps", () => { - expect(() => - decodeSyncedClientPreferences({ - planModeEnabled: true, - updatedAt: "not-a-date", - }), - ).toThrow(); + for (const updatedAt of ["not-a-date", "2026-02-30T00:00:00.000Z"]) { + expect(() => + decodeSyncedClientPreferences({ + planModeEnabled: true, + updatedAt, + }), + ).toThrow(); + } }); it("rejects empty and unknown-only patches at the RPC boundary", () => { diff --git a/packages/contracts/src/syncedClientPreferences.ts b/packages/contracts/src/syncedClientPreferences.ts index 86aa0241cc95..8c9aa35eee15 100644 --- a/packages/contracts/src/syncedClientPreferences.ts +++ b/packages/contracts/src/syncedClientPreferences.ts @@ -1,3 +1,5 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { IsoDateTime } from "./baseSchemas.ts"; @@ -9,7 +11,8 @@ const SyncedClientPreferencesUpdatedAtPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}: export const SyncedClientPreferencesUpdatedAt = IsoDateTime.check( Schema.makeFilter( (value) => - (SyncedClientPreferencesUpdatedAtPattern.test(value) && Number.isFinite(Date.parse(value))) || + (SyncedClientPreferencesUpdatedAtPattern.test(value) && + Option.exists(DateTime.make(value), (parsed) => DateTime.formatIso(parsed) === value)) || "Synced client preferences updatedAt must be a canonical UTC timestamp.", ), ); From c04006b2d7d24731dc6f7dfbcb474548c68f9afe Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 20:13:57 -0400 Subject: [PATCH 03/42] fix(mobile): residual review findings from stacked PR triage - Bound mobile synced-preference stamps to the canonical future-skew window. - Gate thread-composer enqueue mode selection on preference hydration. - Preserve newer local Plan Mode intent in read-only mobile and web sessions. - Add Plan Mode to the user-docs index. --- .../src/features/threads/plan-mode.test.ts | 18 +++++++++ apps/mobile/src/features/threads/plan-mode.ts | 8 ++++ .../state/synced-client-preferences-model.ts | 11 +++++- .../state/synced-client-preferences.test.ts | 38 ++++++++++++++++++- .../src/state/use-thread-composer-state.ts | 23 +++++++---- apps/web/src/hooks/useSettings.test.ts | 11 ++++++ apps/web/src/hooks/useSettings.ts | 26 +++++++++---- docs/README.md | 1 + 8 files changed, 118 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/threads/plan-mode.test.ts b/apps/mobile/src/features/threads/plan-mode.test.ts index 58870ab5348d..78ad9f232045 100644 --- a/apps/mobile/src/features/threads/plan-mode.test.ts +++ b/apps/mobile/src/features/threads/plan-mode.test.ts @@ -4,6 +4,7 @@ import { getBuiltInComposerSlashCommands, getPlanModeComposerSlashCommands, replaceCurrentComposerTrigger, + resolveComposerEnqueueInteractionMode, resolveComposerInteractionMode, resolveComposerSubmitInteractionMode, resolvePlanModeEnabled, @@ -65,6 +66,23 @@ describe("mobile plan mode", () => { ); }); + 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("switches modes for standalone commands while enabled", () => { expect( diff --git a/apps/mobile/src/features/threads/plan-mode.ts b/apps/mobile/src/features/threads/plan-mode.ts index 41dcd98f0ded..1e36addfedca 100644 --- a/apps/mobile/src/features/threads/plan-mode.ts +++ b/apps/mobile/src/features/threads/plan-mode.ts @@ -122,3 +122,11 @@ export function resolveComposerInteractionMode(input: { ? (input.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) : DEFAULT_PROVIDER_INTERACTION_MODE; } + +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/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 99f9004bbbed..a2a2432cdab4 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -7,6 +7,8 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import type { Preferences } from "../persistence/mobile-preferences"; +const SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; + export interface EnvironmentPreferenceState { readonly environmentId: EnvironmentId; readonly preferences: SyncedClientPreferences | undefined; @@ -54,9 +56,12 @@ export function nextMobileSyncedPreferencesUpdatedAt( currentUpdatedAts: ReadonlyArray, now: string, ): string { + const maximumUpdatedAt = Date.parse(now) + SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS; const latest = currentUpdatedAts.reduce( (current, candidate) => - candidate !== undefined && (current === undefined || candidate > current) + candidate !== undefined && + Date.parse(candidate) <= maximumUpdatedAt && + (current === undefined || candidate > current) ? candidate : current, undefined, @@ -153,7 +158,11 @@ export function reconcilePlanModePreferences(input: { }, undefined, ); + const hasPatchableEnvironment = input.environments.some( + (environment) => environment.canPatch !== false, + ); const boundedLocalUpdatedAt = + hasPatchableEnvironment && input.localUpdatedAt !== undefined && latestObservedEnvironmentUpdatedAt !== undefined && input.localUpdatedAt > latestObservedEnvironmentUpdatedAt diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 938429478d3f..3f543bb116b7 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -6,6 +6,7 @@ import { createPlanModePreferenceWrite, createPlanModePreferenceWriteController, fanOutPlanModePreferencePatches, + nextMobileSyncedPreferencesUpdatedAt, reconcilePlanModePreferences, settlePendingPlanModePreferencePatch, } from "./synced-client-preferences-model"; @@ -13,6 +14,21 @@ import { const environmentId = (value: string) => EnvironmentId.make(value); describe("synced client preferences", () => { + 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("adopts the environment plan mode into the device cache on connect", () => { expect( reconcilePlanModePreferences({ @@ -253,7 +269,7 @@ describe("synced client preferences", () => { localPlanModeEnabled: false, localUpdatedAt: "2026-08-14T11:00:00.000Z", environments, - now: "2026-08-14T12:30:00.000Z", + now: "2026-08-14T12:59:00.000Z", }); const second = reconcilePlanModePreferences({ localPlanModeEnabled: first.localPatch?.planModeEnabled, @@ -386,6 +402,26 @@ describe("synced client preferences", () => { 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("continues fan-out when one environment write fails", async () => { const attempted: string[] = []; const targets = createPlanModePreferenceWrite({ diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index f27c35dfba8f..87a6a872437d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -41,8 +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 { resolveComposerInteractionMode } from "../features/threads/plan-mode"; -import { usePlanModeEnabled } from "../features/threads/use-plan-mode-enabled"; +import { + resolveComposerEnqueueInteractionMode, + resolveComposerInteractionMode, +} from "../features/threads/plan-mode"; +import { usePlanModePreferenceState } from "../features/threads/use-plan-mode-enabled"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -80,7 +83,8 @@ export function useThreadComposerState() { const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); - const planModeEnabled = usePlanModeEnabled(); + const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = + usePlanModePreferenceState(); useEffect(() => { ensureComposerDraftsLoaded(); @@ -148,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); @@ -165,10 +175,7 @@ export function useThreadComposerState() { attachments, modelSelection: draft.modelSelection ?? thread.modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, - interactionMode: resolveComposerInteractionMode({ - interactionMode: draft.interactionMode ?? thread.interactionMode, - planModeEnabled, - }), + interactionMode: enqueueInteractionMode, createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey); @@ -184,7 +191,7 @@ export function useThreadComposerState() { ); }); return messageId; - }, [planModeEnabled, selectedThreadDetail, selectedThreadShell]); + }, [planModeEnabled, planModePreferenceLoaded, selectedThreadDetail, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index acb9b9cc684d..2d9aaac7c6b7 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -618,4 +618,15 @@ describe("mergeEnvironmentSettings", () => { 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 b6fd1036625a..04ed502146c3 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -233,11 +233,12 @@ export function mergeEnvironmentSettings( serverSettings: ServerSettings, clientSettings: ClientSettings, syncedClientPreferences?: SyncedClientPreferences, + syncedPlanModeCanOverrideClient = true, ): UnifiedSettings { return { ...serverSettings, ...clientSettings, - ...(syncedClientPreferences?.planModeEnabled === undefined + ...(!syncedPlanModeCanOverrideClient || syncedClientPreferences?.planModeEnabled === undefined ? {} : { planModeEnabled: syncedClientPreferences.planModeEnabled }), }; @@ -246,13 +247,20 @@ export function mergeEnvironmentSettings( function useMergedSettings( serverSettings: ServerSettings, syncedClientPreferences: SyncedClientPreferences | undefined, + syncedPlanModeCanOverrideClient: boolean, selector: ((settings: UnifiedSettings) => T) | undefined, ): T { const clientSettings = useClientSettingsValue(); const merged = useMemo( - () => mergeEnvironmentSettings(serverSettings, clientSettings, syncedClientPreferences), - [clientSettings, serverSettings, syncedClientPreferences], + () => + mergeEnvironmentSettings( + serverSettings, + clientSettings, + syncedClientPreferences, + syncedPlanModeCanOverrideClient, + ), + [clientSettings, serverSettings, syncedClientPreferences, syncedPlanModeCanOverrideClient], ); return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); @@ -641,7 +649,7 @@ function useSyncedPlanModeHydration(environmentId: EnvironmentId | null) { persist: persistSyncedPlanMode, }); - return synced.preferences; + return { ...synced, canPatch } as const; } export function useClientSettings( @@ -706,10 +714,11 @@ export function useEnvironmentSettings( selector?: (settings: UnifiedSettings) => T, ): T { const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); - const syncedClientPreferences = useSyncedPlanModeHydration(environmentId); + const synced = useSyncedPlanModeHydration(environmentId); return useMergedSettings( serverSettings ?? DEFAULT_SERVER_SETTINGS, - syncedClientPreferences, + synced.preferences, + synced.canPatch, selector, ); } @@ -719,10 +728,11 @@ export function usePrimarySettings( selector?: (settings: UnifiedSettings) => T, ): T { const environmentId = usePrimaryEnvironment()?.environmentId ?? null; - const syncedClientPreferences = useSyncedPlanModeHydration(environmentId); + const synced = useSyncedPlanModeHydration(environmentId); return useMergedSettings( useAtomValue(primaryServerSettingsAtom), - syncedClientPreferences, + synced.preferences, + synced.canPatch, selector, ); } diff --git a/docs/README.md b/docs/README.md index 30653e7d5035..82c684cb6231 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ - [Install and first run](./user/install.md) - [Permission modes](./user/permission-modes.md) +- [Plan Mode](./user/plan-mode.md) - [Keyboard shortcuts](./user/keybindings.md) - [Organizing threads](./user/thread-sidebar.md) - [Review usage](./user/usage.md) From 506872507e1edcf342d7fd55f7cc1dff8325bed3 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 20:34:23 -0400 Subject: [PATCH 04/42] fix(mobile): address follow-up review findings --- .../state/synced-client-preferences-model.ts | 35 ++++++++++--- .../state/synced-client-preferences.test.ts | 12 ++++- .../src/state/synced-client-preferences.ts | 14 +++--- apps/mobile/src/state/thread-outbox.test.ts | 7 ++- .../src/state/use-thread-outbox-drain.ts | 7 +-- .../useSettings.environment-target.test.ts | 43 ++++++++++++---- apps/web/src/hooks/useSettings.test.ts | 50 +++++++++++++++++++ apps/web/src/hooks/useSettings.ts | 13 +++-- 8 files changed, 142 insertions(+), 39 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index a2a2432cdab4..7e0971cbb1cc 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -53,11 +53,12 @@ export function settlePendingPlanModePreferencePatch(input: { } export function nextMobileSyncedPreferencesUpdatedAt( - currentUpdatedAts: ReadonlyArray, + localUpdatedAts: ReadonlyArray, now: string, + authoritativeUpdatedAts: ReadonlyArray = [], ): string { const maximumUpdatedAt = Date.parse(now) + SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS; - const latest = currentUpdatedAts.reduce( + const latestLocal = localUpdatedAts.reduce( (current, candidate) => candidate !== undefined && Date.parse(candidate) <= maximumUpdatedAt && @@ -66,6 +67,13 @@ export function nextMobileSyncedPreferencesUpdatedAt( : current, undefined, ); + const latest = authoritativeUpdatedAts.reduce( + (current, candidate) => + candidate !== undefined && (current === undefined || candidate > current) + ? candidate + : current, + latestLocal, + ); if (latest === undefined || now > latest) return now; return new Date(Date.parse(latest) + 1).toISOString(); } @@ -74,12 +82,17 @@ export function createPlanModePreferenceWrite(input: { readonly value: boolean; readonly connectedEnvironmentIds: ReadonlyArray; readonly currentUpdatedAts: ReadonlyArray; + readonly authoritativeUpdatedAts?: ReadonlyArray; readonly now: string; }): { readonly localPatch: Pick; readonly environmentPatches: ReadonlyArray; } { - const updatedAt = nextMobileSyncedPreferencesUpdatedAt(input.currentUpdatedAts, input.now); + const updatedAt = nextMobileSyncedPreferencesUpdatedAt( + input.currentUpdatedAts, + input.now, + input.authoritativeUpdatedAts, + ); const request = { patch: { planModeEnabled: input.value }, updatedAt, @@ -103,7 +116,10 @@ export function createPlanModePreferenceWriteController() { create(input: Parameters[0]) { const write = createPlanModePreferenceWrite({ ...input, - currentUpdatedAts: [latestRequestedUpdatedAt, ...input.currentUpdatedAts], + authoritativeUpdatedAts: [ + latestRequestedUpdatedAt, + ...(input.authoritativeUpdatedAts ?? []), + ], }); latestRequestedUpdatedAt = write.localPatch.syncedClientPreferencesUpdatedAt; return write; @@ -166,10 +182,9 @@ export function reconcilePlanModePreferences(input: { input.localUpdatedAt !== undefined && latestObservedEnvironmentUpdatedAt !== undefined && input.localUpdatedAt > latestObservedEnvironmentUpdatedAt - ? nextMobileSyncedPreferencesUpdatedAt( - [latestObservedEnvironmentUpdatedAt], + ? nextMobileSyncedPreferencesUpdatedAt([], latestObservedEnvironmentUpdatedAt, [ latestObservedEnvironmentUpdatedAt, - ) + ]) : input.localUpdatedAt; // Across environments, the newest plan-bearing stamp wins. Exact stamp ties // use environment id for deterministic convergence; an unstamped legacy @@ -199,7 +214,11 @@ export function reconcilePlanModePreferences(input: { updatedAt !== undefined && (winningUpdatedAt === undefined || updatedAt > winningUpdatedAt), ); const updatedAt = needsNewStamp - ? nextMobileSyncedPreferencesUpdatedAt(observedUpdatedAts, input.now) + ? nextMobileSyncedPreferencesUpdatedAt( + [boundedLocalUpdatedAt], + input.now, + input.environments.map((environment) => environment.preferences?.updatedAt), + ) : (winningUpdatedAt ?? input.now); const localPatch = input.localPlanModeEnabled === value && input.localUpdatedAt === updatedAt diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 3f543bb116b7..17e1f3a4d3b1 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -29,6 +29,14 @@ describe("synced client preferences", () => { ).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({ @@ -58,7 +66,8 @@ describe("synced client preferences", () => { const write = createPlanModePreferenceWrite({ value: true, connectedEnvironmentIds: [environmentId("environment-1"), environmentId("environment-2")], - currentUpdatedAts: ["2026-08-14T12:00:00.000Z", "2026-08-14T12:02:00.000Z"], + currentUpdatedAts: ["2026-08-14T12:00:00.000Z"], + authoritativeUpdatedAts: ["2026-08-14T12:02:00.000Z"], now: "2026-08-14T12:01:00.000Z", }); @@ -203,7 +212,6 @@ describe("synced client preferences", () => { expect(reconciliation.localPatch?.planModeEnabled).toBe(true); } finally { if (descriptor === undefined) { - // oxlint-disable-next-line no-extend-native -- Restore the pre-test Hermes-like shape. Reflect.deleteProperty(Array.prototype, "toSorted"); } else { // oxlint-disable-next-line no-extend-native -- Restore the pre-test implementation. diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index 36ef8f04167a..0f5f1b00c321 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -121,14 +121,12 @@ export function useUpdatePlanModePreference() { const write = writeController.create({ value, connectedEnvironmentIds, - currentUpdatedAts: [ - current.syncedClientPreferencesUpdatedAt, - ...states.map(({ shell }) => - shell.snapshot._tag === "Some" - ? shell.snapshot.value.syncedClientPreferences?.updatedAt - : undefined, - ), - ], + currentUpdatedAts: [current.syncedClientPreferencesUpdatedAt], + authoritativeUpdatedAts: states.map(({ shell }) => + shell.snapshot._tag === "Some" + ? shell.snapshot.value.syncedClientPreferences?.updatedAt + : undefined, + ), now: new Date().toISOString(), }); savePreferences(write.localPatch); diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index a833fa0a9e8d..7e9383796d80 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -136,7 +136,7 @@ describe("thread outbox", () => { expect(resolveQueuedThreadSettings(message, thread, true).interactionMode).toBe("plan"); }); - it("uses the live preference when legacy plan mode is disabled mid-drain", () => { + it("uses one live interaction mode for metadata and turn delivery", () => { let planModeEnabled = true; let preferenceReads = 0; const message = { @@ -162,7 +162,10 @@ describe("thread outbox", () => { expect(sendDecision.settings.interactionMode).toBe("plan"); expect(preferenceReads).toBe(1); planModeEnabled = false; - expect(sendDecision.readStartTurnInteractionMode()).toBe("default"); + const deliveryInteractionMode = sendDecision.readStartTurnInteractionMode(); + planModeEnabled = true; + + expect(deliveryInteractionMode).toBe("default"); expect(preferenceReads).toBe(2); }); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 825c13bbfab0..190b4d10a58f 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -219,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, }, }); @@ -248,7 +249,7 @@ export function useThreadOutboxDrain(): void { }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, - interactionMode: readStartTurnInteractionMode(), + interactionMode, createdAt: queuedMessage.createdAt, }, }); diff --git a/apps/web/src/hooks/useSettings.environment-target.test.ts b/apps/web/src/hooks/useSettings.environment-target.test.ts index f72453cc01ec..c02b50f7441e 100644 --- a/apps/web/src/hooks/useSettings.environment-target.test.ts +++ b/apps/web/src/hooks/useSettings.environment-target.test.ts @@ -9,6 +9,8 @@ const testState = vi.hoisted(() => ({ sessionAtom: Symbol("session"), updateSettingsAtom: Symbol("update-settings"), patchPreferencesAtom: Symbol("patch-preferences"), + serverSettingsAtom: Symbol("server-settings"), + sessionEnvironmentIds: [] as EnvironmentId[], updateSettings: vi.fn(), patchPreferences: vi.fn(), setClientSettings: vi.fn(async () => undefined), @@ -20,6 +22,9 @@ vi.mock("react", async (importOriginal) => { return { ...actual, useCallback: reactHookHarness.useCallback, + useEffect: (effect: () => void | (() => void)) => effect(), + useMemo: reactHookHarness.useMemo, + useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => getSnapshot(), }; }); @@ -30,15 +35,17 @@ vi.mock("react/compiler-runtime", async () => { vi.mock("@effect/atom-react", () => ({ useAtomValue: (atom: unknown) => - atom === testState.sessionAtom - ? { - authenticated: true, - scopes: ["orchestration:operate"], - } - : { - planModeEnabled: false, - updatedAt: "2026-08-14T12:00:00.000Z", - }, + atom === testState.serverSettingsAtom + ? {} + : atom === testState.sessionAtom + ? { + authenticated: true, + scopes: ["orchestration:operate"], + } + : { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + }, })); vi.mock("~/localApi", () => ({ @@ -56,6 +63,7 @@ vi.mock("~/state/environments", () => ({ vi.mock("~/state/server", () => ({ primaryServerSettingsAtom: Symbol("primary-settings"), serverEnvironment: { + settingsValueAtom: () => testState.serverSettingsAtom, updateSettings: testState.updateSettingsAtom, patchSyncedClientPreferences: testState.patchPreferencesAtom, }, @@ -63,7 +71,10 @@ vi.mock("~/state/server", () => ({ vi.mock("~/state/session", () => ({ environmentSession: { - sessionStateValueAtom: () => testState.sessionAtom, + sessionStateValueAtom: (environmentId: EnvironmentId) => { + testState.sessionEnvironmentIds.push(environmentId); + return testState.sessionAtom; + }, }, })); @@ -78,7 +89,7 @@ vi.mock("~/state/use-atom-command", () => ({ atom === testState.patchPreferencesAtom ? testState.patchPreferences : testState.updateSettings, })); -import { useUpdateEnvironmentSettings } from "./useSettings"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "./useSettings"; describe("useUpdateEnvironmentSettings", () => { beforeEach(() => { @@ -95,6 +106,7 @@ describe("useUpdateEnvironmentSettings", () => { }), ); testState.setClientSettings.mockClear(); + testState.sessionEnvironmentIds.length = 0; }); it("patches synced preferences in the supplied secondary environment", () => { @@ -112,4 +124,13 @@ describe("useUpdateEnvironmentSettings", () => { }, }); }); + + it("checks synced-preference access in the supplied secondary environment", () => { + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + hooks.beginRender(); + + useEnvironmentSettings(secondaryEnvironmentId); + + expect(testState.sessionEnvironmentIds).toEqual([secondaryEnvironmentId]); + }); }); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 2d9aaac7c6b7..572ace46bc75 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -502,6 +502,56 @@ describe("synced plan mode", () => { expect(persisted).toEqual([false]); }); + it("ignores a seed acknowledgement after a newer write is queued", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const controller = createSyncedPlanModeHydrationController(); + const targets: Array["patch"]>[0]> = []; + const resolvePatches: Array< + (result: Awaited["patch"]>>) => void + > = []; + const patch: SyncedPlanModeHydrationInput["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, diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 04ed502146c3..3b6520738c32 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -416,15 +416,18 @@ export function createSyncedPlanModeHydrationController( return; } - const matchingWrite = - writePendingByEnvironment.get(input.environmentId)?.updatedAt === input.requestedUpdatedAt; - const matchingSeed = + const pendingWrite = writePendingByEnvironment.get(input.environmentId); + const matchingWrite = pendingWrite?.updatedAt === input.requestedUpdatedAt; + const seedMatchesRequest = seedPendingByEnvironment.get(input.environmentId) === input.requestedUpdatedAt; + const matchingSeed = + seedMatchesRequest && + (pendingWrite === undefined || pendingWrite.updatedAt <= input.requestedUpdatedAt); + if (seedMatchesRequest) seedPendingByEnvironment.delete(input.environmentId); if (!matchingWrite && !matchingSeed) return; cancelRetry(input.environmentId); if (matchingWrite) writePendingByEnvironment.delete(input.environmentId); - if (matchingSeed) seedPendingByEnvironment.delete(input.environmentId); markAdopted(input.environmentId, input.result.value.updatedAt); if (input.result.value.planModeEnabled !== undefined) { input.persist(input.result.value.planModeEnabled); @@ -625,7 +628,7 @@ function useSyncedPlanModeHydration(environmentId: EnvironmentId | null) { const clientHydrated = useClientSettingsHydrated(); const primaryEnvironmentId = usePrimaryEnvironment()?.environmentId ?? null; const synced = useEnvironmentSyncedClientPreferences(environmentId); - const canPatch = useCanPatchSyncedClientPreferences(primaryEnvironmentId); + const canPatch = useCanPatchSyncedClientPreferences(environmentId); const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { label: "synced client preferences seed", reportFailure: false, From 67df07d58bc5d5728fe6d988fe51cb21a77042b1 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 20:55:06 -0400 Subject: [PATCH 05/42] fix(web): address review finding on synced settings hook --- apps/web/src/hooks/useSettings.test.ts | 74 +++++++++++++++++++++++++ apps/web/src/hooks/useSettings.ts | 75 ++++++++++++++++++-------- 2 files changed, 126 insertions(+), 23 deletions(-) diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 572ace46bc75..02efac0287d9 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -436,6 +436,80 @@ describe("synced plan mode", () => { 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 = createSyncedPlanModeHydrationController((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 HydrationHook = ({ input }: { input: SyncedPlanModeHydrationInput }) => { + useSyncedPlanModeHydrationEffect(controller, input); + return null; + }; + const root = createHookTestRoot(); + + try { + 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 SyncedPlanModeHydrationInput; + + await act(async () => { + root.render(createElement(HydrationHook, { input })); + }); + expect(scheduledRetries).toHaveLength(1); + + await act(async () => { + root.render( + createElement(HydrationHook, { + input: { + ...input, + environmentId: nextPrimaryEnvironmentId, + primaryEnvironmentId: nextPrimaryEnvironmentId, + serverPreferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }, + }), + ); + }); + scheduledRetries[0]?.(); + await Promise.resolve(); + + expect(patch).toHaveBeenCalledOnce(); + expect(persist).not.toHaveBeenCalled(); + } 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 = createSyncedPlanModeHydrationController(); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 3b6520738c32..82857be0c4bb 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -369,6 +369,7 @@ const scheduleSyncedPlanModeRetry: SyncedPlanModeRetryScheduler = (retry) => { export function createSyncedPlanModeHydrationController( scheduleRetry: SyncedPlanModeRetryScheduler = scheduleSyncedPlanModeRetry, ) { + const imperativeSynchronizationOwner = Symbol(); const adoptedUpdatedAtByEnvironment = new Map(); const seedPendingByEnvironment = new Map(); const writePendingByEnvironment = new Map< @@ -376,19 +377,36 @@ export function createSyncedPlanModeHydrationController( { readonly value: boolean; readonly updatedAt: string } >(); const writeInFlightByEnvironment = new Map(); - const synchronizeAgainByEnvironment = new Map void>(); + const synchronizeAgainByEnvironment = new Map void>>(); const cancelRetryByEnvironment = new Map void>(); const cancelRetry = (environmentId: EnvironmentId) => { cancelRetryByEnvironment.get(environmentId)?.(); cancelRetryByEnvironment.delete(environmentId); }; + const deactivate = (environmentId: EnvironmentId, owner: symbol) => { + const synchronizeAgainByOwner = synchronizeAgainByEnvironment.get(environmentId); + synchronizeAgainByOwner?.delete(owner); + if (synchronizeAgainByOwner !== undefined && synchronizeAgainByOwner.size > 0) return; + synchronizeAgainByEnvironment.delete(environmentId); + cancelRetry(environmentId); + }; + const getSynchronizeAgain = (environmentId: EnvironmentId) => { + let latest: (() => void) | undefined; + for (const synchronizeAgain of synchronizeAgainByEnvironment.get(environmentId)?.values() ?? + []) { + latest = synchronizeAgain; + } + return latest; + }; const requestRetry = (environmentId: EnvironmentId) => { - if (cancelRetryByEnvironment.has(environmentId)) return; + if (cancelRetryByEnvironment.has(environmentId) || getSynchronizeAgain(environmentId) == null) { + return; + } cancelRetryByEnvironment.set( environmentId, scheduleRetry(() => { cancelRetryByEnvironment.delete(environmentId); - synchronizeAgainByEnvironment.get(environmentId)?.(); + getSynchronizeAgain(environmentId)?.(); }), ); }; @@ -447,15 +465,21 @@ export function createSyncedPlanModeHydrationController( }); }; - const synchronize = (input: SyncedPlanModeHydrationInput) => { + const synchronize = ( + input: SyncedPlanModeHydrationInput, + owner = imperativeSynchronizationOwner, + ) => { const environmentId = input.environmentId; if (environmentId === null) return; if (environmentId !== input.primaryEnvironmentId || !input.live) { - synchronizeAgainByEnvironment.delete(environmentId); - cancelRetry(environmentId); + deactivate(environmentId, owner); return; } - synchronizeAgainByEnvironment.set(environmentId, () => synchronize(input)); + const synchronizeAgainByOwner = + synchronizeAgainByEnvironment.get(environmentId) ?? new Map void>(); + synchronizeAgainByOwner.set(owner, () => synchronize(input, owner)); + synchronizeAgainByEnvironment.set(environmentId, synchronizeAgainByOwner); + const deactivateSynchronization = () => deactivate(environmentId, owner); if (input.serverPreferences?.planModeEnabled !== undefined) { seedPendingByEnvironment.delete(environmentId); } @@ -503,9 +527,9 @@ export function createSyncedPlanModeHydrationController( if (action.type === "adopt") { markAdopted(environmentId, action.updatedAt); if (input.clientValue !== action.value) input.persist(action.value); - return; + return deactivateSynchronization; } - if (action.type !== "seed" || !input.canPatch) return; + if (action.type !== "seed" || !input.canPatch) return deactivateSynchronization; seedPendingByEnvironment.set(environmentId, action.updatedAt); dispatchPatch({ @@ -519,6 +543,8 @@ export function createSyncedPlanModeHydrationController( patch: input.patch, persist: input.persist, }); + + return deactivateSynchronization; }; const write = (input: { @@ -583,20 +609,23 @@ export function useSyncedPlanModeHydrationEffect( controller: ReturnType, input: SyncedPlanModeHydrationInput, ): void { - useEffect(() => { - controller.synchronize(input); - }, [ - controller, - input.canPatch, - input.clientHydrated, - input.clientValue, - input.environmentId, - input.live, - input.patch, - input.persist, - input.primaryEnvironmentId, - input.serverPreferences, - ]); + 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, + ], + ); } function useEnvironmentSyncedClientPreferences(environmentId: EnvironmentId | null) { From a28abf5a6cc903d3049a041eeca2c67b74f337c4 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 21:07:01 -0400 Subject: [PATCH 06/42] fix(mobile): bounded preference retry and stream compat --- .../state/synced-client-preferences-model.ts | 123 ++++++++++++++++ .../state/synced-client-preferences.test.ts | 131 +++++++++++++++++- .../src/state/synced-client-preferences.ts | 42 +++--- apps/server/src/server.test.ts | 18 ++- apps/server/src/ws.ts | 32 +++-- .../src/state/shell-sync.test.ts | 4 + packages/client-runtime/src/state/shell.ts | 6 +- packages/contracts/src/orchestration.ts | 5 + 8 files changed, 322 insertions(+), 39 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 7e0971cbb1cc..dafd319de36e 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -8,6 +8,14 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import type { Preferences } from "../persistence/mobile-preferences"; const SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; +const PLAN_MODE_PREFERENCE_RECONCILIATION_MAX_ATTEMPTS = 3; + +type PlanModePreferenceRetryScheduler = (retry: () => void, delayMs: number) => () => void; + +const schedulePlanModePreferenceRetry: PlanModePreferenceRetryScheduler = (retry, delayMs) => { + const timer = setTimeout(retry, delayMs); + return () => clearTimeout(timer); +}; export interface EnvironmentPreferenceState { readonly environmentId: EnvironmentId; @@ -20,6 +28,121 @@ export interface PlanModePreferencePatchTarget { readonly input: PatchSyncedClientPreferencesRequest; } +export function createPlanModePreferenceReconciliationController( + scheduleRetry: PlanModePreferenceRetryScheduler = schedulePlanModePreferenceRetry, +) { + interface Reconciliation { + readonly target: PlanModePreferencePatchTarget; + attempt: number; + patch: () => Promise; + persist: (patch: Partial) => void; + cancelRetry?: () => void; + } + + let activeEnvironmentIds = new Set(); + const reconciliationByEnvironment = new Map(); + const settledUpdatedAtByEnvironment = new Map(); + const cancel = (environmentId: EnvironmentId) => { + reconciliationByEnvironment.get(environmentId)?.cancelRetry?.(); + reconciliationByEnvironment.delete(environmentId); + }; + const settleFailure = (reconciliation: Reconciliation) => { + const { environmentId } = reconciliation.target; + if (reconciliationByEnvironment.get(environmentId) !== reconciliation) return; + if (reconciliation.attempt >= PLAN_MODE_PREFERENCE_RECONCILIATION_MAX_ATTEMPTS) { + reconciliationByEnvironment.delete(environmentId); + settledUpdatedAtByEnvironment.set(environmentId, reconciliation.target.input.updatedAt); + return; + } + const delayMs = 1_000 * 2 ** (reconciliation.attempt - 1); + reconciliation.cancelRetry = scheduleRetry(() => { + reconciliation.cancelRetry = undefined; + if ( + activeEnvironmentIds.has(environmentId) && + reconciliationByEnvironment.get(environmentId) === reconciliation + ) { + dispatch(reconciliation); + } + }, delayMs); + }; + const dispatch = (reconciliation: Reconciliation) => { + reconciliation.attempt += 1; + void reconciliation.patch().then( + (preferences) => { + const { environmentId } = reconciliation.target; + if (reconciliationByEnvironment.get(environmentId) !== reconciliation) return; + if (preferences === null) { + settleFailure(reconciliation); + return; + } + reconciliationByEnvironment.delete(environmentId); + settledUpdatedAtByEnvironment.set(environmentId, reconciliation.target.input.updatedAt); + const localPatch = canonicalPlanModePreferencePatch(preferences); + if (localPatch !== null) reconciliation.persist(localPatch); + }, + () => settleFailure(reconciliation), + ); + }; + + return { + setActiveEnvironmentIds(environmentIds: ReadonlyArray) { + const nextEnvironmentIds = new Set(environmentIds); + for (const environmentId of activeEnvironmentIds) { + if (nextEnvironmentIds.has(environmentId)) continue; + cancel(environmentId); + settledUpdatedAtByEnvironment.delete(environmentId); + } + activeEnvironmentIds = nextEnvironmentIds; + }, + observe(environmentId: EnvironmentId, updatedAt: string | undefined) { + const reconciliation = reconciliationByEnvironment.get(environmentId); + if (reconciliation?.target.input.updatedAt === updatedAt) cancel(environmentId); + }, + reconcile(input: { + readonly target: PlanModePreferencePatchTarget; + readonly patch: ( + target: PlanModePreferencePatchTarget, + ) => Promise>; + readonly persist: (patch: Partial) => void; + }) { + const { environmentId } = input.target; + if ( + !activeEnvironmentIds.has(environmentId) || + settledUpdatedAtByEnvironment.get(environmentId) === input.target.input.updatedAt + ) { + return; + } + const current = reconciliationByEnvironment.get(environmentId); + if (current?.target.input.updatedAt === input.target.input.updatedAt) { + current.patch = async () => { + const result = await input.patch(input.target); + return result._tag === "Success" ? result.value : null; + }; + current.persist = input.persist; + return; + } + if (current !== undefined) cancel(environmentId); + settledUpdatedAtByEnvironment.delete(environmentId); + const reconciliation: Reconciliation = { + target: input.target, + attempt: 0, + patch: async () => { + const result = await input.patch(input.target); + return result._tag === "Success" ? result.value : null; + }, + persist: input.persist, + }; + reconciliationByEnvironment.set(environmentId, reconciliation); + dispatch(reconciliation); + }, + reset() { + for (const environmentId of reconciliationByEnvironment.keys()) cancel(environmentId); + activeEnvironmentIds.clear(); + settledUpdatedAtByEnvironment.clear(); + }, + }; +} + export async function fanOutPlanModePreferencePatches( targets: ReadonlyArray, patch: (target: PlanModePreferencePatchTarget) => Promise, diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 17e1f3a4d3b1..1d13dd0d1013 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -1,8 +1,10 @@ import { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { + createPlanModePreferenceReconciliationController, createPlanModePreferenceWrite, createPlanModePreferenceWriteController, fanOutPlanModePreferencePatches, @@ -14,6 +16,33 @@ import { const environmentId = (value: string) => EnvironmentId.make(value); 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("bounds excessively future-skewed local stamps", () => { expect( nextMobileSyncedPreferencesUpdatedAt( @@ -447,4 +476,104 @@ describe("synced client preferences", () => { ).resolves.toBeUndefined(); expect(attempted).toEqual([environmentId("failing"), environmentId("healthy")]); }); + + 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 = createPlanModePreferenceReconciliationController(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, + syncedClientPreferencesUpdatedAt: target.input.updatedAt, + }); + }); + + it("settles cleanly 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 = createPlanModePreferenceReconciliationController(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]); + + controller.reconcile({ target, patch, persist: vi.fn() }); + await flushReconciliation(); + expect(patch).toHaveBeenCalledTimes(3); + }); + + 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 = createPlanModePreferenceReconciliationController(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 index 0f5f1b00c321..7feb3227fac5 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -1,7 +1,7 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { AuthOrchestrationOperateScope, type EnvironmentId } from "@t3tools/contracts"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { environmentShell } from "./shell"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "./preferences"; @@ -10,10 +10,10 @@ import { environmentSession } from "./session"; import { useAtomCommand } from "./use-atom-command"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; import { + createPlanModePreferenceReconciliationController, createPlanModePreferenceWriteController, fanOutPlanModePreferencePatches, reconcilePlanModePreferences, - settlePendingPlanModePreferencePatch, } from "./synced-client-preferences-model"; function useConnectedEnvironmentPreferenceStates() { @@ -59,23 +59,26 @@ export function useSyncedClientPreferences(): void { label: "synced client preferences reconciliation", reportFailure: false, }); - const pendingByEnvironment = useRef(new Map()); + const reconciliationController = useMemo( + () => createPlanModePreferenceReconciliationController(), + [], + ); + + useEffect(() => () => reconciliationController.reset(), [reconciliationController]); useEffect(() => { - if (!AsyncResult.isSuccess(preferencesResult)) { - return; - } const liveStates = states.filter(({ shell }) => shell.status === "live"); - if (liveStates.length === 0) return; + reconciliationController.setActiveEnvironmentIds( + liveStates.filter(({ canPatch }) => canPatch).map(({ environmentId }) => environmentId), + ); for (const { environmentId, shell } of liveStates) { const updatedAt = shell.snapshot._tag === "Some" ? shell.snapshot.value.syncedClientPreferences?.updatedAt : undefined; - if (updatedAt === pendingByEnvironment.current.get(environmentId)) { - pendingByEnvironment.current.delete(environmentId); - } + reconciliationController.observe(environmentId, updatedAt); } + if (!AsyncResult.isSuccess(preferencesResult) || liveStates.length === 0) return; const reconciliation = reconcilePlanModePreferences({ localPlanModeEnabled: preferencesResult.value.planModeEnabled, localUpdatedAt: preferencesResult.value.syncedClientPreferencesUpdatedAt, @@ -89,20 +92,13 @@ export function useSyncedClientPreferences(): void { }); if (reconciliation.localPatch !== null) savePreferences(reconciliation.localPatch); for (const target of reconciliation.environmentPatches) { - if (pendingByEnvironment.current.get(target.environmentId) === target.input.updatedAt) { - continue; - } - pendingByEnvironment.current.set(target.environmentId, target.input.updatedAt); - void patchPreferences(target).then((result) => { - const localPatch = settlePendingPlanModePreferencePatch({ - pendingByEnvironment: pendingByEnvironment.current, - target, - result, - }); - if (localPatch !== null) savePreferences(localPatch); + reconciliationController.reconcile({ + target, + patch: patchPreferences, + persist: savePreferences, }); } - }, [patchPreferences, preferencesResult, savePreferences, states]); + }, [patchPreferences, preferencesResult, reconciliationController, savePreferences, states]); } export function useUpdatePlanModePreference() { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d60ca76674df..cb7768b31245 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5986,7 +5986,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("streams a client preference patch incrementally instead of replacing the shell", () => + it.effect("gates incremental client preference patches for legacy shell subscribers", () => Effect.gen(function* () { const canonicalPreferences = { planModeEnabled: true, @@ -6038,22 +6038,32 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ), ); - const items = yield* Effect.scoped( + 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(items[0], { + assert.deepEqual(legacyItems, [{ kind: "synchronized" }]); + assert.deepEqual(capableItems[0], { kind: "client-preferences-updated", sequence: 1, preferences: canonicalPreferences, }); - assert.deepEqual(items[1], { kind: "synchronized" }); + assert.deepEqual(capableItems[1], { kind: "synchronized" }); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d76655eb9e0a..c9415b15c32b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1234,6 +1234,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) => @@ -1282,9 +1290,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( @@ -1302,16 +1312,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" }, 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..a2cd2b25e461 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 } : {}; + return { + clientPreferencesStreamItem: true as const, + ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + }; } 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/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 5a72adf6b1d7..868106f7f482 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -569,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; From cef7f85b9c4c7d53dbe00309abbcec96d03909b8 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 21:21:08 -0400 Subject: [PATCH 07/42] fix(mobile): retry exhausted preference reconciliation on next trigger --- .../state/synced-client-preferences-model.ts | 1 - .../state/synced-client-preferences.test.ts | 44 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index dafd319de36e..33a6adfb677e 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -51,7 +51,6 @@ export function createPlanModePreferenceReconciliationController( if (reconciliationByEnvironment.get(environmentId) !== reconciliation) return; if (reconciliation.attempt >= PLAN_MODE_PREFERENCE_RECONCILIATION_MAX_ATTEMPTS) { reconciliationByEnvironment.delete(environmentId); - settledUpdatedAtByEnvironment.set(environmentId, reconciliation.target.input.updatedAt); return; } const delayMs = 1_000 * 2 ** (reconciliation.attempt - 1); diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 1d13dd0d1013..b43391e93270 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -517,7 +517,7 @@ describe("synced client preferences", () => { }); }); - it("settles cleanly after exhausting reconciliation retries", async () => { + it("stops retrying automatically after exhausting reconciliation retries", async () => { const environment = environmentId("environment-1"); const target = { environmentId: environment, @@ -540,10 +540,50 @@ describe("synced client preferences", () => { expect(patch).toHaveBeenCalledTimes(3); expect(scheduled.map(({ delayMs }) => delayMs)).toEqual([1_000, 2_000]); + }); - controller.reconcile({ target, patch, persist: vi.fn() }); + 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 = createPlanModePreferenceReconciliationController(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, + syncedClientPreferencesUpdatedAt: target.input.updatedAt, + }); }); it.each(["disconnect", "unmount"] as const)( From f842dd1b403690cdec4372d612df94b586234295 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 21:47:32 -0400 Subject: [PATCH 08/42] fix(mobile): guard stale settles in preference sync --- apps/mobile/src/state/preferences.test.ts | 53 ++++++++++++ apps/mobile/src/state/preferences.ts | 23 +++++- .../src/state/synced-client-preferences.ts | 22 ++++- apps/web/src/hooks/useSettings.test.ts | 82 +++++++++++++++++++ apps/web/src/hooks/useSettings.ts | 1 + 5 files changed, 177 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index c53594eb2306..127a34d3933b 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -124,6 +124,59 @@ 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, + 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, + syncedClientPreferencesUpdatedAt: "2026-08-14T12:01:00.000Z", + }); + registry.set(state.updatePreferencesAtom, { + planModeEnabled: false, + syncedClientPreferencesUpdatedAt: "2026-08-14T12:02:00.000Z", + }); + registry.set(state.persistReconciledPreferencesAtom, { + expectedUpdatedAt: "2026-08-14T12:01:00.000Z", + patch: { + planModeEnabled: true, + syncedClientPreferencesUpdatedAt: "2026-08-14T12:01:00.000Z", + }, + }); + + expect( + Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom))), + ).toMatchObject({ + planModeEnabled: false, + syncedClientPreferencesUpdatedAt: "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({ diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index d173cf55be5a..9ce075b21e6f 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -15,6 +15,11 @@ interface OptimisticPreferences { readonly versions: Partial>; } +export interface ReconciledPreferencesPatch { + readonly expectedUpdatedAt: string; + 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 @@ -114,7 +119,21 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime { + const current = get(preferencesAtom); + if ( + !AsyncResult.isSuccess(current) || + current.value.syncedClientPreferencesUpdatedAt !== input.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 +141,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.ts b/apps/mobile/src/state/synced-client-preferences.ts index 7feb3227fac5..d9b890d7989e 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -4,7 +4,11 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo } from "react"; import { environmentShell } from "./shell"; -import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "./preferences"; +import { + mobilePreferencesAtom, + persistReconciledMobilePreferencesAtom, + updateMobilePreferencesAtom, +} from "./preferences"; import { serverEnvironment } from "./server"; import { environmentSession } from "./session"; import { useAtomCommand } from "./use-atom-command"; @@ -54,6 +58,7 @@ function useConnectedEnvironmentPreferenceStates() { export function useSyncedClientPreferences(): void { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const persistReconciledPreferences = useAtomSet(persistReconciledMobilePreferencesAtom); const { states } = useConnectedEnvironmentPreferenceStates(); const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { label: "synced client preferences reconciliation", @@ -95,10 +100,21 @@ export function useSyncedClientPreferences(): void { reconciliationController.reconcile({ target, patch: patchPreferences, - persist: savePreferences, + persist: (patch) => + persistReconciledPreferences({ + expectedUpdatedAt: target.input.updatedAt, + patch, + }), }); } - }, [patchPreferences, preferencesResult, reconciliationController, savePreferences, states]); + }, [ + patchPreferences, + persistReconciledPreferences, + preferencesResult, + reconciliationController, + savePreferences, + states, + ]); } export function useUpdatePlanModePreference() { diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 02efac0287d9..f0a7ca5e07fc 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -510,6 +510,88 @@ describe("synced plan mode", () => { } }); + 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 = createSyncedPlanModeHydrationController(); + let resolvePatch!: ( + result: Awaited["patch"]>>, + ) => void; + const patch = vi.fn["patch"]>( + () => + new Promise((resolve) => { + resolvePatch = resolve; + }), + ); + const persisted: boolean[] = []; + const persist = (value: boolean) => persisted.push(value); + const HydrationHook = ({ input }: { input: SyncedPlanModeHydrationInput }) => { + useSyncedPlanModeHydrationEffect(controller, input); + return null; + }; + const root = createHookTestRoot(); + + try { + 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 SyncedPlanModeHydrationInput; + + await act(async () => { + root.render(createElement(HydrationHook, { input })); + }); + expect(patch).toHaveBeenCalledOnce(); + + if (switchPrimary) { + await act(async () => { + root.render( + createElement(HydrationHook, { + input: { + ...input, + environmentId: nextPrimaryEnvironmentId, + primaryEnvironmentId: nextPrimaryEnvironmentId, + clientValue: false, + serverPreferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }, + }), + ); + }); + } + + await act(async () => { + resolvePatch( + AsyncResult.success({ + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }), + ); + await Promise.resolve(); + }); + + expect(persisted).toEqual(expectedPersisted); + } 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 = createSyncedPlanModeHydrationController(); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 82857be0c4bb..705e9ef66e4a 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -446,6 +446,7 @@ export function createSyncedPlanModeHydrationController( cancelRetry(input.environmentId); if (matchingWrite) writePendingByEnvironment.delete(input.environmentId); + if (getSynchronizeAgain(input.environmentId) === undefined) return; markAdopted(input.environmentId, input.result.value.updatedAt); if (input.result.value.planModeEnabled !== undefined) { input.persist(input.result.value.planModeEnabled); From 54ca4bf5e502dddf4b17fe3c90d9ab16eebb5c98 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 22:05:51 -0400 Subject: [PATCH 09/42] fix(web): keep adoption guard on inactive settles --- apps/web/src/hooks/useSettings.test.ts | 78 ++++++++++++++++++++++++++ apps/web/src/hooks/useSettings.ts | 2 +- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index f0a7ca5e07fc..8a20675c8e6a 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -592,6 +592,84 @@ describe("synced plan mode", () => { }, ); + it("does not adopt stale shell preferences after an inactive patch settles", async () => { + const environmentId = EnvironmentId.make("primary"); + const controller = createSyncedPlanModeHydrationController(); + 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["patch"]>>, + ) => void; + const patch = vi.fn["patch"]>( + () => + new Promise((resolve) => { + resolvePatch = resolve; + }), + ); + let localValue = false; + const persisted: boolean[] = []; + const persist = (value: boolean) => { + localValue = value; + persisted.push(value); + }; + const deactivate = controller.synchronize({ + environmentId, + primaryEnvironmentId: environmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: previous, + canPatch: true, + now: previous.updatedAt, + patch, + persist, + }); + + localValue = true; + controller.write({ + environmentId, + value: localValue, + serverPreferences: previous, + canPatch: true, + now: "2026-08-14T12:02:00.000Z", + patch, + persist, + }); + deactivate?.(); + resolvePatch( + AsyncResult.success({ + planModeEnabled: true, + updatedAt: "2026-08-14T12:02:00.000Z", + }), + ); + await Promise.resolve(); + + expect(persisted).toEqual([]); + + controller.synchronize({ + environmentId, + primaryEnvironmentId: environmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: stale, + canPatch: true, + now: "2026-08-14T12:03:00.000Z", + patch, + persist, + }); + + expect(localValue).toBe(true); + expect(persisted).toEqual([]); + expect(patch).toHaveBeenCalledOnce(); + }); + it("keeps the latest rapid toggle when responses settle out of order", async () => { const primaryEnvironmentId = EnvironmentId.make("primary"); const controller = createSyncedPlanModeHydrationController(); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 705e9ef66e4a..18669108320b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -446,8 +446,8 @@ export function createSyncedPlanModeHydrationController( cancelRetry(input.environmentId); if (matchingWrite) writePendingByEnvironment.delete(input.environmentId); - if (getSynchronizeAgain(input.environmentId) === undefined) return; markAdopted(input.environmentId, input.result.value.updatedAt); + if (getSynchronizeAgain(input.environmentId) === undefined) return; if (input.result.value.planModeEnabled !== undefined) { input.persist(input.result.value.planModeEnabled); } From bd13cef3fbd359c696e8812bd89626785b938152 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 23:15:44 -0400 Subject: [PATCH 10/42] refactor(mobile): simplify synced preference machinery Collapse mobile reconciliation maps into one per-environment state record. Collapse web hydration maps into one per-environment state record. Share the LWW timestamp helper across web and mobile. Inline Promise.allSettled and remove obsolete test-only helpers. --- .../state/synced-client-preferences-model.ts | 115 +++++------- .../state/synced-client-preferences.test.ts | 41 ++--- .../src/state/synced-client-preferences.ts | 13 +- apps/web/src/hooks/useSettings.ts | 170 +++++++++--------- .../contracts/src/syncedClientPreferences.ts | 12 ++ 5 files changed, 158 insertions(+), 193 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 33a6adfb677e..dba3e1c6b8a6 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -1,7 +1,8 @@ -import type { - EnvironmentId, - PatchSyncedClientPreferencesRequest, - SyncedClientPreferences, +import { + nextSyncedClientPreferencesUpdatedAt, + type EnvironmentId, + type PatchSyncedClientPreferencesRequest, + type SyncedClientPreferences, } from "@t3tools/contracts"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; @@ -39,27 +40,29 @@ export function createPlanModePreferenceReconciliationController( cancelRetry?: () => void; } - let activeEnvironmentIds = new Set(); - const reconciliationByEnvironment = new Map(); - const settledUpdatedAtByEnvironment = new Map(); + interface EnvironmentReconciliation { + reconciliation?: Reconciliation; + settledUpdatedAt?: string; + } + + const environmentReconciliations = new Map(); const cancel = (environmentId: EnvironmentId) => { - reconciliationByEnvironment.get(environmentId)?.cancelRetry?.(); - reconciliationByEnvironment.delete(environmentId); + const state = environmentReconciliations.get(environmentId); + state?.reconciliation?.cancelRetry?.(); + if (state !== undefined) state.reconciliation = undefined; }; const settleFailure = (reconciliation: Reconciliation) => { const { environmentId } = reconciliation.target; - if (reconciliationByEnvironment.get(environmentId) !== reconciliation) return; + const state = environmentReconciliations.get(environmentId); + if (state?.reconciliation !== reconciliation) return; if (reconciliation.attempt >= PLAN_MODE_PREFERENCE_RECONCILIATION_MAX_ATTEMPTS) { - reconciliationByEnvironment.delete(environmentId); + state.reconciliation = undefined; return; } const delayMs = 1_000 * 2 ** (reconciliation.attempt - 1); reconciliation.cancelRetry = scheduleRetry(() => { reconciliation.cancelRetry = undefined; - if ( - activeEnvironmentIds.has(environmentId) && - reconciliationByEnvironment.get(environmentId) === reconciliation - ) { + if (environmentReconciliations.get(environmentId)?.reconciliation === reconciliation) { dispatch(reconciliation); } }, delayMs); @@ -69,13 +72,14 @@ export function createPlanModePreferenceReconciliationController( void reconciliation.patch().then( (preferences) => { const { environmentId } = reconciliation.target; - if (reconciliationByEnvironment.get(environmentId) !== reconciliation) return; + const state = environmentReconciliations.get(environmentId); + if (state?.reconciliation !== reconciliation) return; if (preferences === null) { settleFailure(reconciliation); return; } - reconciliationByEnvironment.delete(environmentId); - settledUpdatedAtByEnvironment.set(environmentId, reconciliation.target.input.updatedAt); + state.reconciliation = undefined; + state.settledUpdatedAt = reconciliation.target.input.updatedAt; const localPatch = canonicalPlanModePreferencePatch(preferences); if (localPatch !== null) reconciliation.persist(localPatch); }, @@ -86,15 +90,19 @@ export function createPlanModePreferenceReconciliationController( return { setActiveEnvironmentIds(environmentIds: ReadonlyArray) { const nextEnvironmentIds = new Set(environmentIds); - for (const environmentId of activeEnvironmentIds) { + for (const environmentId of environmentReconciliations.keys()) { if (nextEnvironmentIds.has(environmentId)) continue; cancel(environmentId); - settledUpdatedAtByEnvironment.delete(environmentId); + environmentReconciliations.delete(environmentId); + } + for (const environmentId of nextEnvironmentIds) { + if (!environmentReconciliations.has(environmentId)) { + environmentReconciliations.set(environmentId, {}); + } } - activeEnvironmentIds = nextEnvironmentIds; }, observe(environmentId: EnvironmentId, updatedAt: string | undefined) { - const reconciliation = reconciliationByEnvironment.get(environmentId); + const reconciliation = environmentReconciliations.get(environmentId)?.reconciliation; if (reconciliation?.target.input.updatedAt === updatedAt) cancel(environmentId); }, reconcile(input: { @@ -105,13 +113,11 @@ export function createPlanModePreferenceReconciliationController( readonly persist: (patch: Partial) => void; }) { const { environmentId } = input.target; - if ( - !activeEnvironmentIds.has(environmentId) || - settledUpdatedAtByEnvironment.get(environmentId) === input.target.input.updatedAt - ) { + const state = environmentReconciliations.get(environmentId); + if (state === undefined || state.settledUpdatedAt === input.target.input.updatedAt) { return; } - const current = reconciliationByEnvironment.get(environmentId); + const current = state.reconciliation; if (current?.target.input.updatedAt === input.target.input.updatedAt) { current.patch = async () => { const result = await input.patch(input.target); @@ -121,7 +127,7 @@ export function createPlanModePreferenceReconciliationController( return; } if (current !== undefined) cancel(environmentId); - settledUpdatedAtByEnvironment.delete(environmentId); + state.settledUpdatedAt = undefined; const reconciliation: Reconciliation = { target: input.target, attempt: 0, @@ -131,24 +137,16 @@ export function createPlanModePreferenceReconciliationController( }, persist: input.persist, }; - reconciliationByEnvironment.set(environmentId, reconciliation); + state.reconciliation = reconciliation; dispatch(reconciliation); }, reset() { - for (const environmentId of reconciliationByEnvironment.keys()) cancel(environmentId); - activeEnvironmentIds.clear(); - settledUpdatedAtByEnvironment.clear(); + for (const environmentId of environmentReconciliations.keys()) cancel(environmentId); + environmentReconciliations.clear(); }, }; } -export async function fanOutPlanModePreferencePatches( - targets: ReadonlyArray, - patch: (target: PlanModePreferencePatchTarget) => Promise, -): Promise { - await Promise.allSettled(targets.map(patch)); -} - export function canonicalPlanModePreferencePatch( preferences: SyncedClientPreferences, ): Partial | null { @@ -160,44 +158,21 @@ export function canonicalPlanModePreferencePatch( }; } -export function settlePendingPlanModePreferencePatch(input: { - readonly pendingByEnvironment: Map; - readonly target: PlanModePreferencePatchTarget; - readonly result: AtomCommandResult; -}): Partial | null { - if (input.pendingByEnvironment.get(input.target.environmentId) !== input.target.input.updatedAt) { - return null; - } - input.pendingByEnvironment.delete(input.target.environmentId); - return input.result._tag === "Success" - ? canonicalPlanModePreferencePatch(input.result.value) - : null; -} - export function nextMobileSyncedPreferencesUpdatedAt( localUpdatedAts: ReadonlyArray, now: string, authoritativeUpdatedAts: ReadonlyArray = [], ): string { const maximumUpdatedAt = Date.parse(now) + SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS; - const latestLocal = localUpdatedAts.reduce( - (current, candidate) => - candidate !== undefined && - Date.parse(candidate) <= maximumUpdatedAt && - (current === undefined || candidate > current) - ? candidate - : current, - undefined, - ); - const latest = authoritativeUpdatedAts.reduce( - (current, candidate) => - candidate !== undefined && (current === undefined || candidate > current) - ? candidate - : current, - latestLocal, + return nextSyncedClientPreferencesUpdatedAt( + [ + ...localUpdatedAts.filter( + (candidate) => candidate !== undefined && Date.parse(candidate) <= maximumUpdatedAt, + ), + ...authoritativeUpdatedAts, + ], + now, ); - if (latest === undefined || now > latest) return now; - return new Date(Date.parse(latest) + 1).toISOString(); } export function createPlanModePreferenceWrite(input: { diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index b43391e93270..bf94b3592cc5 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -7,10 +7,8 @@ import { createPlanModePreferenceReconciliationController, createPlanModePreferenceWrite, createPlanModePreferenceWriteController, - fanOutPlanModePreferencePatches, nextMobileSyncedPreferencesUpdatedAt, reconcilePlanModePreferences, - settlePendingPlanModePreferencePatch, } from "./synced-client-preferences-model"; const environmentId = (value: string) => EnvironmentId.make(value); @@ -382,7 +380,7 @@ describe("synced client preferences", () => { ]); }); - it("clears pending reconciliation from an older canonical patch ack", () => { + it("clears pending reconciliation from an older canonical patch ack", async () => { const target = { environmentId: environmentId("environment-1"), input: { @@ -390,20 +388,24 @@ describe("synced client preferences", () => { updatedAt: "2099-01-01T00:00:00.000Z", }, } as const; - const pendingByEnvironment = new Map([[target.environmentId, target.input.updatedAt]]); const canonical = { planModeEnabled: true, updatedAt: "2026-08-14T12:00:30.000Z", } as const; - const localPatch = settlePendingPlanModePreferencePatch({ - pendingByEnvironment, + const controller = createPlanModePreferenceReconciliationController(); + const persist = vi.fn(); + controller.setActiveEnvironmentIds([target.environmentId]); + controller.reconcile({ target, - result: AsyncResult.success(canonical), + patch: async () => AsyncResult.success(canonical), + persist, }); + await flushReconciliation(); + const next = reconcilePlanModePreferences({ - localPlanModeEnabled: localPatch?.planModeEnabled, - localUpdatedAt: localPatch?.syncedClientPreferencesUpdatedAt, + localPlanModeEnabled: canonical.planModeEnabled, + localUpdatedAt: canonical.updatedAt, environments: [ { environmentId: target.environmentId, @@ -414,8 +416,7 @@ describe("synced client preferences", () => { now: "2099-01-01T00:00:01.000Z", }); - expect(pendingByEnvironment.size).toBe(0); - expect(localPatch).toEqual({ + expect(persist).toHaveBeenCalledWith({ planModeEnabled: true, syncedClientPreferencesUpdatedAt: canonical.updatedAt, }); @@ -459,24 +460,6 @@ describe("synced client preferences", () => { ).toEqual({ localPatch: null, environmentPatches: [] }); }); - it("continues fan-out when one environment write fails", async () => { - const attempted: string[] = []; - const targets = createPlanModePreferenceWrite({ - value: true, - connectedEnvironmentIds: [environmentId("failing"), environmentId("healthy")], - currentUpdatedAts: [], - now: "2026-08-14T12:00:00.000Z", - }).environmentPatches; - - await expect( - fanOutPlanModePreferencePatches(targets, async (target) => { - attempted.push(target.environmentId); - if (target.environmentId === environmentId("failing")) throw new Error("offline"); - }), - ).resolves.toBeUndefined(); - expect(attempted).toEqual([environmentId("failing"), environmentId("healthy")]); - }); - it("retries failed reconciliation and succeeds within the cap", async () => { const environment = environmentId("environment-1"); const target = { diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index d9b890d7989e..5d8d289a5a49 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -16,7 +16,6 @@ import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; import { createPlanModePreferenceReconciliationController, createPlanModePreferenceWriteController, - fanOutPlanModePreferencePatches, reconcilePlanModePreferences, } from "./synced-client-preferences-model"; @@ -142,11 +141,13 @@ export function useUpdatePlanModePreference() { now: new Date().toISOString(), }); savePreferences(write.localPatch); - void fanOutPlanModePreferencePatches(write.environmentPatches, async (target) => { - const result = await patchPreferences(target); - const localPatch = writeController.settle({ target, result }); - if (localPatch !== null) savePreferences(localPatch); - }); + void Promise.allSettled( + write.environmentPatches.map(async (target) => { + const result = await patchPreferences(target); + const localPatch = writeController.settle({ target, result }); + if (localPatch !== null) savePreferences(localPatch); + }), + ); }, [ connectedEnvironmentIds, diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 18669108320b..4e11d385ed2a 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -14,6 +14,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, DEFAULT_SERVER_SETTINGS, + nextSyncedClientPreferencesUpdatedAt, type EnvironmentId, type PatchSyncedClientPreferencesRequest, ServerSettings, @@ -266,14 +267,6 @@ function useMergedSettings( return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); } -export function nextSyncedClientPreferencesUpdatedAt( - currentUpdatedAt: string | undefined, - now: string, -): string { - if (currentUpdatedAt === undefined || now > currentUpdatedAt) return now; - return new Date(Date.parse(currentUpdatedAt) + 1).toISOString(); -} - export function createSyncedPlanModeWrite(input: { readonly value: boolean; readonly serverPreferences: SyncedClientPreferences | undefined; @@ -290,7 +283,7 @@ export function createSyncedPlanModeWrite(input: { clientPatch: { planModeEnabled: input.value }, request: { patch: { planModeEnabled: input.value }, - updatedAt: nextSyncedClientPreferencesUpdatedAt(currentUpdatedAt, input.now), + updatedAt: nextSyncedClientPreferencesUpdatedAt([currentUpdatedAt], input.now), }, } as const; } @@ -353,7 +346,10 @@ export function resolveSyncedPlanModeHydrationAction(input: { return { type: "seed", value: input.clientValue, - updatedAt: nextSyncedClientPreferencesUpdatedAt(input.serverPreferences?.updatedAt, input.now), + updatedAt: nextSyncedClientPreferencesUpdatedAt( + [input.serverPreferences?.updatedAt], + input.now, + ), }; } @@ -369,51 +365,52 @@ const scheduleSyncedPlanModeRetry: SyncedPlanModeRetryScheduler = (retry) => { export function createSyncedPlanModeHydrationController( scheduleRetry: SyncedPlanModeRetryScheduler = scheduleSyncedPlanModeRetry, ) { + interface SyncedPlanModeEnvironmentState { + adoptedUpdatedAt?: string; + seedPendingUpdatedAt?: string; + writePending?: { readonly value: boolean; readonly updatedAt: string }; + writeInFlightUpdatedAt?: string; + readonly synchronizeAgainByOwner: Map void>; + cancelRetry?: () => void; + } + const imperativeSynchronizationOwner = Symbol(); - const adoptedUpdatedAtByEnvironment = new Map(); - const seedPendingByEnvironment = new Map(); - const writePendingByEnvironment = new Map< - EnvironmentId, - { readonly value: boolean; readonly updatedAt: string } - >(); - const writeInFlightByEnvironment = new Map(); - const synchronizeAgainByEnvironment = new Map void>>(); - const cancelRetryByEnvironment = new Map void>(); - const cancelRetry = (environmentId: EnvironmentId) => { - cancelRetryByEnvironment.get(environmentId)?.(); - cancelRetryByEnvironment.delete(environmentId); + const stateByEnvironment = new Map(); + const stateFor = (environmentId: EnvironmentId) => { + const current = stateByEnvironment.get(environmentId); + if (current !== undefined) return current; + const state: SyncedPlanModeEnvironmentState = { synchronizeAgainByOwner: new Map() }; + stateByEnvironment.set(environmentId, state); + return state; + }; + const cancelRetry = (state: SyncedPlanModeEnvironmentState) => { + state.cancelRetry?.(); + delete state.cancelRetry; }; const deactivate = (environmentId: EnvironmentId, owner: symbol) => { - const synchronizeAgainByOwner = synchronizeAgainByEnvironment.get(environmentId); - synchronizeAgainByOwner?.delete(owner); - if (synchronizeAgainByOwner !== undefined && synchronizeAgainByOwner.size > 0) return; - synchronizeAgainByEnvironment.delete(environmentId); - cancelRetry(environmentId); + const state = stateByEnvironment.get(environmentId); + if (state === undefined) return; + state.synchronizeAgainByOwner.delete(owner); + if (state.synchronizeAgainByOwner.size > 0) return; + cancelRetry(state); }; - const getSynchronizeAgain = (environmentId: EnvironmentId) => { + const getSynchronizeAgain = (state: SyncedPlanModeEnvironmentState) => { let latest: (() => void) | undefined; - for (const synchronizeAgain of synchronizeAgainByEnvironment.get(environmentId)?.values() ?? - []) { + for (const synchronizeAgain of state.synchronizeAgainByOwner.values()) { latest = synchronizeAgain; } return latest; }; - const requestRetry = (environmentId: EnvironmentId) => { - if (cancelRetryByEnvironment.has(environmentId) || getSynchronizeAgain(environmentId) == null) { - return; - } - cancelRetryByEnvironment.set( - environmentId, - scheduleRetry(() => { - cancelRetryByEnvironment.delete(environmentId); - getSynchronizeAgain(environmentId)?.(); - }), - ); + const requestRetry = (state: SyncedPlanModeEnvironmentState) => { + if (state.cancelRetry !== undefined || getSynchronizeAgain(state) === undefined) return; + state.cancelRetry = scheduleRetry(() => { + delete state.cancelRetry; + getSynchronizeAgain(state)?.(); + }); }; - const markAdopted = (environmentId: EnvironmentId, updatedAt: string) => { - const current = adoptedUpdatedAtByEnvironment.get(environmentId); - if (current === undefined || updatedAt > current) { - adoptedUpdatedAtByEnvironment.set(environmentId, updatedAt); + const markAdopted = (state: SyncedPlanModeEnvironmentState, updatedAt: string) => { + if (state.adoptedUpdatedAt === undefined || updatedAt > state.adoptedUpdatedAt) { + state.adoptedUpdatedAt = updatedAt; } }; @@ -423,31 +420,32 @@ export function createSyncedPlanModeHydrationController( readonly result: AtomCommandResult; readonly persist: (value: boolean) => void; }) => { - if (writeInFlightByEnvironment.get(input.environmentId) === input.requestedUpdatedAt) { - writeInFlightByEnvironment.delete(input.environmentId); + const state = stateByEnvironment.get(input.environmentId); + if (state === undefined) return; + if (state.writeInFlightUpdatedAt === input.requestedUpdatedAt) { + delete state.writeInFlightUpdatedAt; } if (input.result._tag === "Failure") { - if (seedPendingByEnvironment.get(input.environmentId) === input.requestedUpdatedAt) { - seedPendingByEnvironment.delete(input.environmentId); + if (state.seedPendingUpdatedAt === input.requestedUpdatedAt) { + delete state.seedPendingUpdatedAt; } - requestRetry(input.environmentId); + requestRetry(state); return; } - const pendingWrite = writePendingByEnvironment.get(input.environmentId); + const pendingWrite = state.writePending; const matchingWrite = pendingWrite?.updatedAt === input.requestedUpdatedAt; - const seedMatchesRequest = - seedPendingByEnvironment.get(input.environmentId) === input.requestedUpdatedAt; + const seedMatchesRequest = state.seedPendingUpdatedAt === input.requestedUpdatedAt; const matchingSeed = seedMatchesRequest && (pendingWrite === undefined || pendingWrite.updatedAt <= input.requestedUpdatedAt); - if (seedMatchesRequest) seedPendingByEnvironment.delete(input.environmentId); + if (seedMatchesRequest) delete state.seedPendingUpdatedAt; if (!matchingWrite && !matchingSeed) return; - cancelRetry(input.environmentId); - if (matchingWrite) writePendingByEnvironment.delete(input.environmentId); - markAdopted(input.environmentId, input.result.value.updatedAt); - if (getSynchronizeAgain(input.environmentId) === undefined) return; + cancelRetry(state); + if (matchingWrite) delete state.writePending; + markAdopted(state, input.result.value.updatedAt); + if (getSynchronizeAgain(state) === undefined) return; if (input.result.value.planModeEnabled !== undefined) { input.persist(input.result.value.planModeEnabled); } @@ -460,7 +458,7 @@ export function createSyncedPlanModeHydrationController( }) => { const { environmentId } = input.target; const requestedUpdatedAt = input.target.input.updatedAt; - writeInFlightByEnvironment.set(environmentId, requestedUpdatedAt); + stateFor(environmentId).writeInFlightUpdatedAt = requestedUpdatedAt; void input.patch(input.target).then((result) => { settlePatch({ environmentId, requestedUpdatedAt, result, persist: input.persist }); }); @@ -476,31 +474,29 @@ export function createSyncedPlanModeHydrationController( deactivate(environmentId, owner); return; } - const synchronizeAgainByOwner = - synchronizeAgainByEnvironment.get(environmentId) ?? new Map void>(); - synchronizeAgainByOwner.set(owner, () => synchronize(input, owner)); - synchronizeAgainByEnvironment.set(environmentId, synchronizeAgainByOwner); + const state = stateFor(environmentId); + state.synchronizeAgainByOwner.set(owner, () => synchronize(input, owner)); const deactivateSynchronization = () => deactivate(environmentId, owner); if (input.serverPreferences?.planModeEnabled !== undefined) { - seedPendingByEnvironment.delete(environmentId); + delete state.seedPendingUpdatedAt; } - const pendingWrite = writePendingByEnvironment.get(environmentId); + const pendingWrite = state.writePending; if ( pendingWrite !== undefined && input.serverPreferences !== undefined && input.serverPreferences.updatedAt >= pendingWrite.updatedAt ) { - writePendingByEnvironment.delete(environmentId); - writeInFlightByEnvironment.delete(environmentId); - cancelRetry(environmentId); + delete state.writePending; + delete state.writeInFlightUpdatedAt; + cancelRetry(state); } - const activePendingWrite = writePendingByEnvironment.get(environmentId); + const activePendingWrite = state.writePending; if ( input.canPatch && activePendingWrite !== undefined && (input.serverPreferences === undefined || input.serverPreferences.updatedAt < activePendingWrite.updatedAt) && - writeInFlightByEnvironment.get(environmentId) !== activePendingWrite.updatedAt + state.writeInFlightUpdatedAt !== activePendingWrite.updatedAt ) { dispatchPatch({ target: { @@ -515,24 +511,23 @@ export function createSyncedPlanModeHydrationController( }); } - const adoptedUpdatedAt = adoptedUpdatedAtByEnvironment.get(environmentId); const action = resolveSyncedPlanModeHydrationAction({ clientHydrated: input.clientHydrated, clientValue: input.clientValue, serverPreferences: input.serverPreferences, - seedPending: seedPendingByEnvironment.has(environmentId), + seedPending: state.seedPendingUpdatedAt !== undefined, ...(activePendingWrite === undefined ? {} : { writePending: activePendingWrite }), - ...(adoptedUpdatedAt === undefined ? {} : { adoptedUpdatedAt }), + ...(state.adoptedUpdatedAt === undefined ? {} : { adoptedUpdatedAt: state.adoptedUpdatedAt }), now: input.now, }); if (action.type === "adopt") { - markAdopted(environmentId, action.updatedAt); + markAdopted(state, action.updatedAt); if (input.clientValue !== action.value) input.persist(action.value); return deactivateSynchronization; } if (action.type !== "seed" || !input.canPatch) return deactivateSynchronization; - seedPendingByEnvironment.set(environmentId, action.updatedAt); + state.seedPendingUpdatedAt = action.updatedAt; dispatchPatch({ target: { environmentId, @@ -559,11 +554,12 @@ export function createSyncedPlanModeHydrationController( }) => { if (input.environmentId === null) return; const environmentId = input.environmentId; + const state = stateFor(environmentId); const controllerUpdatedAt = [ - adoptedUpdatedAtByEnvironment.get(environmentId), - seedPendingByEnvironment.get(environmentId), - writePendingByEnvironment.get(environmentId)?.updatedAt, - writeInFlightByEnvironment.get(environmentId), + state.adoptedUpdatedAt, + state.seedPendingUpdatedAt, + state.writePending?.updatedAt, + state.writeInFlightUpdatedAt, ].reduce( (latest, candidate) => candidate !== undefined && (latest === undefined || candidate > latest) @@ -577,10 +573,10 @@ export function createSyncedPlanModeHydrationController( ...(controllerUpdatedAt === undefined ? {} : { pendingUpdatedAt: controllerUpdatedAt }), now: input.now, }); - writePendingByEnvironment.set(environmentId, { + state.writePending = { value: input.value, updatedAt: next.request.updatedAt, - }); + }; if (!input.canPatch) return; dispatchPatch({ target: { environmentId, input: next.request }, @@ -593,13 +589,11 @@ export function createSyncedPlanModeHydrationController( synchronize, write, reset() { - for (const cancel of cancelRetryByEnvironment.values()) cancel(); - adoptedUpdatedAtByEnvironment.clear(); - seedPendingByEnvironment.clear(); - writePendingByEnvironment.clear(); - writeInFlightByEnvironment.clear(); - synchronizeAgainByEnvironment.clear(); - cancelRetryByEnvironment.clear(); + for (const state of stateByEnvironment.values()) { + cancelRetry(state); + state.synchronizeAgainByOwner.clear(); + } + stateByEnvironment.clear(); }, }; } diff --git a/packages/contracts/src/syncedClientPreferences.ts b/packages/contracts/src/syncedClientPreferences.ts index 8c9aa35eee15..b079c6f540e7 100644 --- a/packages/contracts/src/syncedClientPreferences.ts +++ b/packages/contracts/src/syncedClientPreferences.ts @@ -18,6 +18,18 @@ export const SyncedClientPreferencesUpdatedAt = IsoDateTime.check( ); 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), From c37046ee5a422fda2f54f66ed00b2a5983abfd0d Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 23:40:11 -0400 Subject: [PATCH 11/42] fix(mobile): deterministic multi-env settle and read-only fallback --- .../state/synced-client-preferences-model.ts | 15 ++++++--- .../state/synced-client-preferences.test.ts | 32 +++++++++++++++++++ apps/web/src/hooks/useSettings.test.ts | 26 +++++++++++++++ apps/web/src/hooks/useSettings.ts | 1 + 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index dba3e1c6b8a6..58fb457d942c 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -208,6 +208,7 @@ export function createPlanModePreferenceWrite(input: { export function createPlanModePreferenceWriteController() { let latestRequestedUpdatedAt: string | undefined; + let settledRequestedUpdatedAt: string | undefined; return { create(input: Parameters[0]) { @@ -219,16 +220,22 @@ export function createPlanModePreferenceWriteController() { ], }); latestRequestedUpdatedAt = write.localPatch.syncedClientPreferencesUpdatedAt; + settledRequestedUpdatedAt = undefined; return write; }, settle(input: { readonly target: PlanModePreferencePatchTarget; readonly result: AtomCommandResult; }): Partial | null { - if (input.target.input.updatedAt !== latestRequestedUpdatedAt) return null; - return input.result._tag === "Success" - ? canonicalPlanModePreferencePatch(input.result.value) - : null; + if ( + input.target.input.updatedAt !== latestRequestedUpdatedAt || + input.target.input.updatedAt === settledRequestedUpdatedAt || + input.result._tag === "Failure" + ) { + return null; + } + settledRequestedUpdatedAt = input.target.input.updatedAt; + return canonicalPlanModePreferencePatch(input.result.value); }, }; } diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index bf94b3592cc5..01e72d667057 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -157,6 +157,38 @@ describe("synced client preferences", () => { ).toBeNull(); }); + it("settles a multi-environment toggle from only the first response", () => { + const controller = createPlanModePreferenceWriteController(); + const write = controller.create({ + value: true, + connectedEnvironmentIds: [environmentId("environment-1"), environmentId("environment-2")], + currentUpdatedAts: [], + 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, + syncedClientPreferencesUpdatedAt: "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("keeps offline toggles device-local", () => { expect( createPlanModePreferenceWrite({ diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 8a20675c8e6a..3aabcb9a3a84 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -336,6 +336,32 @@ describe("synced plan mode", () => { expect(patch).not.toHaveBeenCalled(); }); + it("keeps the local fallback when server preferences are read-only", () => { + const primaryEnvironmentId = EnvironmentId.make("read-only"); + const controller = createSyncedPlanModeHydrationController(); + 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 = createSyncedPlanModeHydrationController(); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 4e11d385ed2a..230073ed9c41 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -521,6 +521,7 @@ export function createSyncedPlanModeHydrationController( now: input.now, }); if (action.type === "adopt") { + if (!input.canPatch) return deactivateSynchronization; markAdopted(state, action.updatedAt); if (input.clientValue !== action.value) input.persist(action.value); return deactivateSynchronization; From 73f17e56106044701b27569cef581b8bf481d24d Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 00:12:26 -0400 Subject: [PATCH 12/42] fix(mobile): per-field last-write-wins for synced preferences --- apps/mobile/src/lib/storage.test.ts | 44 +++ .../src/persistence/mobile-preferences.ts | 28 +- apps/mobile/src/state/preferences.test.ts | 22 +- apps/mobile/src/state/preferences.ts | 38 ++- .../state/synced-client-preferences-model.ts | 320 ++++++++++++------ .../state/synced-client-preferences.test.ts | 134 ++++++-- .../src/state/synced-client-preferences.ts | 25 +- .../Layers/OrchestrationEngine.test.ts | 2 + .../Layers/ProjectionPipeline.test.ts | 37 +- .../Layers/ProjectionPipeline.ts | 80 ++++- .../Layers/ProjectionSnapshotQuery.ts | 15 + ...rojector.synced-client-preferences.test.ts | 62 ++++ apps/server/src/orchestration/projector.ts | 34 +- ..._ProjectionSyncedClientPreferences.test.ts | 14 +- .../041_ProjectionSyncedClientPreferences.ts | 3 + apps/web/src/hooks/useSettings.test.ts | 22 +- apps/web/src/hooks/useSettings.ts | 40 ++- .../src/state/shellReducer.test.ts | 2 + packages/contracts/src/orchestration.test.ts | 1 + packages/contracts/src/settings.test.ts | 28 ++ .../contracts/src/syncedClientPreferences.ts | 26 ++ 21 files changed, 796 insertions(+), 181 deletions(-) create mode 100644 apps/server/src/orchestration/projector.synced-client-preferences.test.ts diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index c13ea0dee8c3..72d78974d6f7 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -192,6 +192,50 @@ describe("mobile connection storage", () => { 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("falls back to secure storage when SQLite cannot save preferences", async () => { mocks.setDatabaseFailures(true, true); await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 38b1ff29385b..ba8e832ec8fc 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -8,6 +8,8 @@ import * as Semaphore from "effect/Semaphore"; import { type SidebarProjectGroupingMode, SyncedClientPreferencesUpdatedAt, + SyncedClientPreferencesUpdatedAtByField, + type SyncedClientPreferencesUpdatedAtByField as SyncedClientPreferencesUpdatedAtByFieldValue, } from "@t3tools/contracts"; import * as MobileDatabase from "./mobile-database"; @@ -17,6 +19,9 @@ import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-sto 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 liveActivitiesEnabled?: boolean; @@ -35,6 +40,8 @@ export interface Preferences { * Offline cache of the most recently reconciled environment preference. */ 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 @@ -97,6 +104,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingMode?: SidebarProjectGroupingMode; autoSettleOnMerge?: boolean; planModeEnabled?: boolean; + syncedClientPreferencesUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByFieldValue; syncedClientPreferencesUpdatedAt?: string; legacyThreadListEnabled?: boolean; } = {}; @@ -141,6 +149,13 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if ( + parsed.syncedClientPreferencesUpdatedAtByField !== undefined && + isSyncedClientPreferencesUpdatedAtByField(parsed.syncedClientPreferencesUpdatedAtByField) + ) { + preferences.syncedClientPreferencesUpdatedAtByField = + parsed.syncedClientPreferencesUpdatedAtByField; + } if ( typeof parsed.syncedClientPreferencesUpdatedAt === "string" && isSyncedClientPreferencesUpdatedAt(parsed.syncedClientPreferencesUpdatedAt) @@ -333,7 +348,18 @@ export const make = Effect.fn("MobilePreferencesStore.make")(function* () { try: () => transform(current), catch: (cause) => new MobilePreferencesSaveError({ cause }), }); - const next: Preferences = { ...current, ...patch }; + const next: Preferences = { + ...current, + ...patch, + ...(patch.syncedClientPreferencesUpdatedAtByField === undefined + ? {} + : { + 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 127a34d3933b..0f633dfb2700 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -130,6 +130,9 @@ describe("mobile preferences state", () => { const state = makePreferencesState({ load: Effect.succeed({ planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + appearanceMode: "2026-08-14T13:00:00.000Z", + }, syncedClientPreferencesUpdatedAt: "2026-08-14T12:00:00.000Z", }), savePatch, @@ -144,17 +147,24 @@ describe("mobile preferences state", () => { }); registry.set(state.updatePreferencesAtom, { planModeEnabled: true, - syncedClientPreferencesUpdatedAt: "2026-08-14T12:01:00.000Z", + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:01:00.000Z", + }, }); registry.set(state.updatePreferencesAtom, { planModeEnabled: false, - syncedClientPreferencesUpdatedAt: "2026-08-14T12:02:00.000Z", + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:02:00.000Z", + appearanceMode: "2026-08-14T13:00:00.000Z", + }, }); registry.set(state.persistReconciledPreferencesAtom, { - expectedUpdatedAt: "2026-08-14T12:01:00.000Z", + expectedUpdatedAtByField: { planModeEnabled: "2026-08-14T12:01:00.000Z" }, patch: { planModeEnabled: true, - syncedClientPreferencesUpdatedAt: "2026-08-14T12:01:00.000Z", + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:01:00.000Z", + }, }, }); @@ -162,7 +172,9 @@ describe("mobile preferences state", () => { Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom))), ).toMatchObject({ planModeEnabled: false, - syncedClientPreferencesUpdatedAt: "2026-08-14T12:02:00.000Z", + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:02:00.000Z", + }, }); yield* Effect.promise(() => vi.waitFor(() => { diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index 9ce075b21e6f..f54d95f31a11 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"; @@ -16,7 +20,7 @@ interface OptimisticPreferences { } export interface ReconciledPreferencesPatch { - readonly expectedUpdatedAt: string; + readonly expectedUpdatedAtByField: SyncedClientPreferencesUpdatedAtByField; readonly patch: Partial; } @@ -63,25 +67,38 @@ 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( - Effect.flatMap((store) => store.savePatch(patch)), + Effect.flatMap((store) => store.savePatch(normalizedPatch)), Effect.tap((saved) => Effect.sync(() => { get.set(confirmedPreferencesAtom, saved); const optimistic = get(optimisticPatchAtom); const values = { ...optimistic.values } as Record; 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]; @@ -98,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]; @@ -124,7 +141,14 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime { + const expectedUpdatedAt = input.expectedUpdatedAtByField[field]; + return ( + expectedUpdatedAt !== undefined && + (current.value.syncedClientPreferencesUpdatedAtByField?.[field] ?? + current.value.syncedClientPreferencesUpdatedAt) !== expectedUpdatedAt + ); + }) ) { return Effect.void; } diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 58fb457d942c..8772def3688a 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -1,8 +1,13 @@ import { + getSyncedClientPreferenceUpdatedAt, nextSyncedClientPreferencesUpdatedAt, + SYNCED_CLIENT_PREFERENCE_FIELDS, type EnvironmentId, type PatchSyncedClientPreferencesRequest, + type SyncedClientPreferenceField, type SyncedClientPreferences, + type SyncedClientPreferencesPatch, + type SyncedClientPreferencesUpdatedAtByField, } from "@t3tools/contracts"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; @@ -29,6 +34,48 @@ export interface PlanModePreferencePatchTarget { readonly input: PatchSyncedClientPreferencesRequest; } +export interface LocalSyncedClientPreferencesState { + readonly values: Partial; + readonly updatedAtByField?: SyncedClientPreferencesUpdatedAtByField; + readonly legacyUpdatedAt?: string; +} + +export interface LocalSyncedClientPreferencesPatch { + readonly values: SyncedClientPreferencesPatch; + readonly updatedAtByField: SyncedClientPreferencesUpdatedAtByField; +} + +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 createPlanModePreferenceReconciliationController( scheduleRetry: PlanModePreferenceRetryScheduler = schedulePlanModePreferenceRetry, ) { @@ -150,11 +197,14 @@ export function createPlanModePreferenceReconciliationController( export function canonicalPlanModePreferencePatch( preferences: SyncedClientPreferences, ): Partial | null { - return preferences.planModeEnabled === undefined + const updatedAt = getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"); + return preferences.planModeEnabled === undefined || updatedAt === undefined ? null : { planModeEnabled: preferences.planModeEnabled, - syncedClientPreferencesUpdatedAt: preferences.updatedAt, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: updatedAt, + }, }; } @@ -175,30 +225,34 @@ export function nextMobileSyncedPreferencesUpdatedAt( ); } -export function createPlanModePreferenceWrite(input: { - readonly value: boolean; +export function createSyncedClientPreferencesWrite(input: { + readonly patch: SyncedClientPreferencesPatch; readonly connectedEnvironmentIds: ReadonlyArray; - readonly currentUpdatedAts: ReadonlyArray; - readonly authoritativeUpdatedAts?: ReadonlyArray; + readonly currentUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByField; + readonly legacyCurrentUpdatedAt?: string; + readonly authoritativePreferences?: ReadonlyArray; readonly now: string; }): { - readonly localPatch: Pick; + readonly localPatch: LocalSyncedClientPreferencesPatch; readonly environmentPatches: ReadonlyArray; } { + const fields = SYNCED_CLIENT_PREFERENCE_FIELDS.filter( + (field) => input.patch[field] !== undefined, + ); const updatedAt = nextMobileSyncedPreferencesUpdatedAt( - input.currentUpdatedAts, + fields.map((field) => input.currentUpdatedAtByField?.[field] ?? input.legacyCurrentUpdatedAt), input.now, - input.authoritativeUpdatedAts, + input.authoritativePreferences?.flatMap((preferences) => + fields.map((field) => getSyncedClientPreferenceUpdatedAt(preferences, field)), + ), ); - const request = { - patch: { planModeEnabled: input.value }, - updatedAt, - } as const; + const updatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField = { + ...input.currentUpdatedAtByField, + }; + for (const field of fields) setPreferenceUpdatedAt(updatedAtByField, field, updatedAt); + const request = { patch: input.patch, updatedAt }; return { - localPatch: { - planModeEnabled: input.value, - syncedClientPreferencesUpdatedAt: updatedAt, - }, + localPatch: { values: input.patch, updatedAtByField }, environmentPatches: input.connectedEnvironmentIds.map((environmentId) => ({ environmentId, input: request, @@ -206,6 +260,37 @@ export function createPlanModePreferenceWrite(input: { }; } +export function createPlanModePreferenceWrite(input: { + readonly value: boolean; + readonly connectedEnvironmentIds: ReadonlyArray; + readonly currentUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByField; + readonly legacyCurrentUpdatedAt?: string; + readonly authoritativePreferences?: ReadonlyArray; + readonly now: string; +}): { + readonly localPatch: { + readonly planModeEnabled: boolean; + readonly syncedClientPreferencesUpdatedAtByField: SyncedClientPreferencesUpdatedAtByField; + }; + readonly environmentPatches: ReadonlyArray; +} { + 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 createPlanModePreferenceWriteController() { let latestRequestedUpdatedAt: string | undefined; let settledRequestedUpdatedAt: string | undefined; @@ -214,12 +299,15 @@ export function createPlanModePreferenceWriteController() { create(input: Parameters[0]) { const write = createPlanModePreferenceWrite({ ...input, - authoritativeUpdatedAts: [ - latestRequestedUpdatedAt, - ...(input.authoritativeUpdatedAts ?? []), - ], + currentUpdatedAtByField: { + ...input.currentUpdatedAtByField, + ...(latestRequestedUpdatedAt === undefined + ? {} + : { planModeEnabled: latestRequestedUpdatedAt }), + }, }); - latestRequestedUpdatedAt = write.localPatch.syncedClientPreferencesUpdatedAt; + latestRequestedUpdatedAt = + write.localPatch.syncedClientPreferencesUpdatedAtByField.planModeEnabled; settledRequestedUpdatedAt = undefined; return write; }, @@ -240,103 +328,127 @@ export function createPlanModePreferenceWriteController() { }; } -export function reconcilePlanModePreferences(input: { - readonly localPlanModeEnabled: boolean | undefined; - readonly localUpdatedAt: string | undefined; +export function reconcileSyncedClientPreferences(input: { + readonly local: LocalSyncedClientPreferencesState; readonly environments: ReadonlyArray; readonly now: string; + readonly fields?: ReadonlyArray; }): { - readonly localPatch: Partial | null; + readonly localPatch: LocalSyncedClientPreferencesPatch | null; readonly environmentPatches: ReadonlyArray; } { if (input.environments.length === 0) { return { localPatch: null, environmentPatches: [] }; } - const environmentCandidates = input.environments.flatMap((environment) => - environment.preferences?.planModeEnabled === undefined - ? [] - : [ - { - source: environment.environmentId, - value: environment.preferences.planModeEnabled, - updatedAt: environment.preferences.updatedAt, - }, - ], - ); - environmentCandidates.sort( - (left, right) => - left.updatedAt.localeCompare(right.updatedAt) || left.source.localeCompare(right.source), - ); - const latestEnvironment = environmentCandidates.at(-1); - const latestObservedEnvironmentUpdatedAt = input.environments.reduce( - (latest, environment) => { - const candidate = environment.preferences?.updatedAt; - return candidate !== undefined && (latest === undefined || candidate > latest) - ? candidate - : latest; - }, - undefined, - ); + const localValues: MutableSyncedClientPreferencesPatch = {}; + const localUpdatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField = { + ...input.local.updatedAtByField, + }; + const environmentPatches: PlanModePreferencePatchTarget[] = []; + let localChanged = false; const hasPatchableEnvironment = input.environments.some( (environment) => environment.canPatch !== false, ); - const boundedLocalUpdatedAt = - hasPatchableEnvironment && - input.localUpdatedAt !== undefined && - latestObservedEnvironmentUpdatedAt !== undefined && - input.localUpdatedAt > latestObservedEnvironmentUpdatedAt - ? nextMobileSyncedPreferencesUpdatedAt([], latestObservedEnvironmentUpdatedAt, [ - latestObservedEnvironmentUpdatedAt, - ]) - : input.localUpdatedAt; - // Across environments, the newest plan-bearing stamp wins. Exact stamp ties - // use environment id for deterministic convergence; an unstamped legacy - // device value seeds only when no environment already owns the preference. - const localWins = - input.localPlanModeEnabled !== undefined && - boundedLocalUpdatedAt !== undefined && - (latestEnvironment === undefined || boundedLocalUpdatedAt > latestEnvironment.updatedAt); - const value = localWins - ? input.localPlanModeEnabled - : (latestEnvironment?.value ?? input.localPlanModeEnabled ?? false); - const winningUpdatedAt = localWins ? boundedLocalUpdatedAt : latestEnvironment?.updatedAt; - const environmentsNeedingReconciliation = input.environments.filter( - (environment) => - environment.preferences?.planModeEnabled !== value || - environment.preferences?.updatedAt !== winningUpdatedAt, - ); - const observedUpdatedAts = [ - boundedLocalUpdatedAt, - ...input.environments.map((environment) => environment.preferences?.updatedAt), - ]; - const needsNewStamp = - environmentsNeedingReconciliation.length > 0 && - observedUpdatedAts.some( - (updatedAt) => - updatedAt !== undefined && (winningUpdatedAt === undefined || updatedAt > winningUpdatedAt), + for (const field of input.fields ?? SYNCED_CLIENT_PREFERENCE_FIELDS) { + const environmentCandidates = input.environments.flatMap((environment) => { + const value = environment.preferences?.[field]; + const updatedAt = getSyncedClientPreferenceUpdatedAt(environment.preferences, field); + return value === undefined || updatedAt === undefined + ? [] + : [{ source: environment.environmentId, value, updatedAt }]; + }); + environmentCandidates.sort( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.source.localeCompare(right.source), ); - const updatedAt = needsNewStamp - ? nextMobileSyncedPreferencesUpdatedAt( - [boundedLocalUpdatedAt], - input.now, - input.environments.map((environment) => environment.preferences?.updatedAt), - ) - : (winningUpdatedAt ?? input.now); - const localPatch = - input.localPlanModeEnabled === value && input.localUpdatedAt === updatedAt - ? null - : { - planModeEnabled: value, - syncedClientPreferencesUpdatedAt: updatedAt, - }; - const request = { patch: { planModeEnabled: value }, updatedAt } as const; + const latestEnvironment = environmentCandidates.at(-1); + const latestObservedEnvironmentUpdatedAt = latestEnvironment?.updatedAt; + const localValue = input.local.values[field]; + const localUpdatedAt = localPreferenceUpdatedAt(input.local, field); + const boundedLocalUpdatedAt = + hasPatchableEnvironment && + localUpdatedAt !== undefined && + latestObservedEnvironmentUpdatedAt !== undefined && + localUpdatedAt > latestObservedEnvironmentUpdatedAt + ? nextMobileSyncedPreferencesUpdatedAt([], latestObservedEnvironmentUpdatedAt, [ + latestObservedEnvironmentUpdatedAt, + ]) + : 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 ( + 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; +}): { + readonly localPatch: Partial | null; + readonly environmentPatches: ReadonlyArray; +} { + const reconciliation = reconcileSyncedClientPreferences({ + local: { + values: + input.localPlanModeEnabled === undefined + ? {} + : { planModeEnabled: input.localPlanModeEnabled }, + ...(input.localUpdatedAt === undefined + ? {} + : { updatedAtByField: { planModeEnabled: input.localUpdatedAt } }), + }, + environments: input.environments, + now: input.now, + fields: ["planModeEnabled"], + }); + const planModeEnabled = reconciliation.localPatch?.values.planModeEnabled; return { - localPatch, - environmentPatches: (needsNewStamp ? input.environments : environmentsNeedingReconciliation) - .filter((environment) => environment.canPatch !== false) - .map(({ environmentId }) => ({ environmentId, input: request })), + 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 index 01e72d667057..503fbc35c483 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -7,8 +7,10 @@ import { createPlanModePreferenceReconciliationController, createPlanModePreferenceWrite, createPlanModePreferenceWriteController, + createSyncedClientPreferencesWrite, nextMobileSyncedPreferencesUpdatedAt, reconcilePlanModePreferences, + reconcileSyncedClientPreferences, } from "./synced-client-preferences-model"; const environmentId = (value: string) => EnvironmentId.make(value); @@ -83,7 +85,9 @@ describe("synced client preferences", () => { ).toEqual({ localPatch: { planModeEnabled: true, - syncedClientPreferencesUpdatedAt: "2026-08-14T12:00:00.000Z", + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, }, environmentPatches: [], }); @@ -93,14 +97,21 @@ describe("synced client preferences", () => { const write = createPlanModePreferenceWrite({ value: true, connectedEnvironmentIds: [environmentId("environment-1"), environmentId("environment-2")], - currentUpdatedAts: ["2026-08-14T12:00:00.000Z"], - authoritativeUpdatedAts: ["2026-08-14T12:02:00.000Z"], + 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, - syncedClientPreferencesUpdatedAt: "2026-08-14T12:02:00.001Z", + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:02:00.001Z", + }, }); expect(write.environmentPatches).toEqual([ { @@ -126,17 +137,17 @@ describe("synced client preferences", () => { const first = controller.create({ value: true, connectedEnvironmentIds: [environment], - currentUpdatedAts: [], now: "2026-08-14T12:00:00.000Z", }); const second = controller.create({ value: false, connectedEnvironmentIds: [environment], - currentUpdatedAts: [], now: "2026-08-14T12:00:00.000Z", }); - expect(second.localPatch.syncedClientPreferencesUpdatedAt).toBe("2026-08-14T12:00:00.001Z"); + expect(second.localPatch.syncedClientPreferencesUpdatedAtByField.planModeEnabled).toBe( + "2026-08-14T12:00:00.001Z", + ); expect( controller.settle({ target: second.environmentPatches[0]!, @@ -162,7 +173,6 @@ describe("synced client preferences", () => { const write = controller.create({ value: true, connectedEnvironmentIds: [environmentId("environment-1"), environmentId("environment-2")], - currentUpdatedAts: [], now: "2026-08-14T12:00:00.000Z", }); @@ -176,7 +186,9 @@ describe("synced client preferences", () => { }), ).toEqual({ planModeEnabled: true, - syncedClientPreferencesUpdatedAt: "2026-08-14T12:01:00.000Z", + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:01:00.000Z", + }, }); expect( controller.settle({ @@ -194,18 +206,50 @@ describe("synced client preferences", () => { createPlanModePreferenceWrite({ value: false, connectedEnvironmentIds: [], - currentUpdatedAts: [], now: "2026-08-14T12:00:00.000Z", }), ).toEqual({ localPatch: { planModeEnabled: false, - syncedClientPreferencesUpdatedAt: "2026-08-14T12:00:00.000Z", + 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, @@ -304,7 +348,7 @@ describe("synced client preferences", () => { }); const second = reconcilePlanModePreferences({ localPlanModeEnabled: first.localPatch?.planModeEnabled, - localUpdatedAt: first.localPatch?.syncedClientPreferencesUpdatedAt, + localUpdatedAt: first.localPatch?.syncedClientPreferencesUpdatedAtByField?.planModeEnabled, environments, now: "2026-08-14T12:01:01.000Z", }); @@ -315,7 +359,7 @@ describe("synced client preferences", () => { ); }); - it("advances once past a newer non-plan preference stamp", () => { + it("does not advance plan mode past a newer non-plan preference stamp", () => { const environments = [ { environmentId: environmentId("appearance-only"), @@ -340,18 +384,62 @@ describe("synced client preferences", () => { }); const second = reconcilePlanModePreferences({ localPlanModeEnabled: first.localPatch?.planModeEnabled, - localUpdatedAt: first.localPatch?.syncedClientPreferencesUpdatedAt, + localUpdatedAt: first.localPatch?.syncedClientPreferencesUpdatedAtByField?.planModeEnabled, environments, now: "2026-08-14T13:01:00.000Z", }); - expect(first.environmentPatches).toHaveLength(2); - expect(first.environmentPatches[0]?.input.updatedAt).toBe("2026-08-14T13:00:00.001Z"); + 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("patches only stale environments after a peer converges", () => { const reconciliation = reconcilePlanModePreferences({ localPlanModeEnabled: true, @@ -399,7 +487,9 @@ describe("synced client preferences", () => { expect(reconciliation.localPatch).toEqual({ planModeEnabled: true, - syncedClientPreferencesUpdatedAt: "2026-08-14T12:00:00.001Z", + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.001Z", + }, }); expect(reconciliation.environmentPatches).toEqual([ { @@ -450,7 +540,7 @@ describe("synced client preferences", () => { expect(persist).toHaveBeenCalledWith({ planModeEnabled: true, - syncedClientPreferencesUpdatedAt: canonical.updatedAt, + syncedClientPreferencesUpdatedAtByField: { planModeEnabled: canonical.updatedAt }, }); expect(next.environmentPatches).toEqual([]); }); @@ -528,7 +618,9 @@ describe("synced client preferences", () => { expect(patch).toHaveBeenCalledTimes(2); expect(persist).toHaveBeenCalledWith({ planModeEnabled: true, - syncedClientPreferencesUpdatedAt: target.input.updatedAt, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: target.input.updatedAt, + }, }); }); @@ -597,7 +689,9 @@ describe("synced client preferences", () => { expect(patch).toHaveBeenCalledTimes(4); expect(persist).toHaveBeenCalledWith({ planModeEnabled: true, - syncedClientPreferencesUpdatedAt: target.input.updatedAt, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: target.input.updatedAt, + }, }); }); diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index 5d8d289a5a49..1a29103f8a96 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -1,5 +1,8 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + getSyncedClientPreferenceUpdatedAt, +} from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo } from "react"; @@ -78,14 +81,19 @@ export function useSyncedClientPreferences(): void { for (const { environmentId, shell } of liveStates) { const updatedAt = shell.snapshot._tag === "Some" - ? shell.snapshot.value.syncedClientPreferences?.updatedAt + ? getSyncedClientPreferenceUpdatedAt( + shell.snapshot.value.syncedClientPreferences, + "planModeEnabled", + ) : undefined; reconciliationController.observe(environmentId, updatedAt); } if (!AsyncResult.isSuccess(preferencesResult) || liveStates.length === 0) return; const reconciliation = reconcilePlanModePreferences({ localPlanModeEnabled: preferencesResult.value.planModeEnabled, - localUpdatedAt: preferencesResult.value.syncedClientPreferencesUpdatedAt, + localUpdatedAt: + preferencesResult.value.syncedClientPreferencesUpdatedAtByField?.planModeEnabled ?? + preferencesResult.value.syncedClientPreferencesUpdatedAt, environments: liveStates.map(({ environmentId, shell, canPatch }) => ({ environmentId, canPatch, @@ -101,7 +109,7 @@ export function useSyncedClientPreferences(): void { patch: patchPreferences, persist: (patch) => persistReconciledPreferences({ - expectedUpdatedAt: target.input.updatedAt, + expectedUpdatedAtByField: { planModeEnabled: target.input.updatedAt }, patch, }), }); @@ -132,11 +140,10 @@ export function useUpdatePlanModePreference() { const write = writeController.create({ value, connectedEnvironmentIds, - currentUpdatedAts: [current.syncedClientPreferencesUpdatedAt], - authoritativeUpdatedAts: states.map(({ shell }) => - shell.snapshot._tag === "Some" - ? shell.snapshot.value.syncedClientPreferences?.updatedAt - : undefined, + currentUpdatedAtByField: current.syncedClientPreferencesUpdatedAtByField, + legacyCurrentUpdatedAt: current.syncedClientPreferencesUpdatedAt, + authoritativePreferences: states.map(({ shell }) => + shell.snapshot._tag === "Some" ? shell.snapshot.value.syncedClientPreferences : undefined, ), now: new Date().toISOString(), }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 7444af9842c3..7c9d7a1a2528 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -138,6 +138,7 @@ describe("OrchestrationEngine", () => { await expect(system.shellSnapshot()).resolves.toMatchObject({ syncedClientPreferences: { planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, updatedAt: "2026-08-14T12:00:00.000Z", }, }); @@ -158,6 +159,7 @@ describe("OrchestrationEngine", () => { snapshotSequence: staleResult.sequence, syncedClientPreferences: { planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, updatedAt: "2026-08-14T12:00:00.000Z", }, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 42867f9b17e6..1f621ddf7716 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1201,17 +1201,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }); yield* eventStore.append({ type: "client-preferences.patched", - eventId: EventId.make("preferences-stale"), + eventId: EventId.make("preferences-appearance-fresh"), aggregateKind: "client-preferences", aggregateId: "client-preferences", - occurredAt: "2026-08-14T11:00:00.000Z", - commandId: CommandId.make("preferences-stale"), + occurredAt: "2026-08-14T13:00:00.000Z", + commandId: CommandId.make("preferences-appearance-fresh"), causationEventId: null, - correlationId: CommandId.make("preferences-stale"), + correlationId: CommandId.make("preferences-appearance-fresh"), metadata: {}, payload: { - patch: { planModeEnabled: false }, - updatedAt: "2026-08-14T11:00:00.000Z", + patch: { appearanceMode: "light" }, + updatedAt: "2026-08-14T13:00:00.000Z", }, }); const equalStampEvent = yield* eventStore.append({ @@ -1229,6 +1229,21 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { 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); @@ -1237,20 +1252,26 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { 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, - appearanceMode: "dark", - updatedAt: "2026-08-14T12:00:00.000Z", + 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); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 38718d8eef2c..3b606127d73f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1615,24 +1615,92 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti 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}, ${appearanceMode}, ${themeId}, ${event.payload.updatedAt}) + VALUES ( + 1, + ${planModeEnabled}, + ${planModeEnabledUpdatedAt}, + ${appearanceMode}, + ${appearanceModeUpdatedAt}, + ${themeId}, + ${themeIdUpdatedAt}, + ${event.payload.updatedAt} + ) ON CONFLICT (singleton_id) DO UPDATE SET - plan_mode_enabled = COALESCE(excluded.plan_mode_enabled, plan_mode_enabled), - appearance_mode = COALESCE(excluded.appearance_mode, appearance_mode), - theme_id = COALESCE(excluded.theme_id, theme_id), - updated_at = excluded.updated_at - WHERE excluded.updated_at >= updated_at + 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")), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 1179c9f176bd..5814914e6c76 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -118,8 +118,11 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ 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({ @@ -226,6 +229,15 @@ function mapSyncedClientPreferences( ...(value.planModeEnabled === null ? {} : { planModeEnabled: value.planModeEnabled !== 0 }), ...(value.appearanceMode === null ? {} : { appearanceMode: value.appearanceMode }), ...(value.themeId === null ? {} : { themeId: value.themeId }), + updatedAtByField: { + ...(value.planModeEnabledUpdatedAt === null + ? {} + : { planModeEnabled: value.planModeEnabledUpdatedAt }), + ...(value.appearanceModeUpdatedAt === null + ? {} + : { appearanceMode: value.appearanceModeUpdatedAt }), + ...(value.themeIdUpdatedAt === null ? {} : { themeId: value.themeIdUpdatedAt }), + }, updatedAt: value.updatedAt, }), }); @@ -792,8 +804,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { 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 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..4ac6bd08e844 --- /dev/null +++ b/apps/server/src/orchestration/projector.synced-client-preferences.test.ts @@ -0,0 +1,62 @@ +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 }, + } as OrchestrationEvent; +} + +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", + }, + updatedAt: "2026-08-14T13:00:00.000Z", + }); + assert.strictEqual(withPlan.updatedAt, "2026-08-14T13:00:00.000Z"); + }), +); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 4421dc0200cb..a54df3605fdf 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,6 +1,7 @@ import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { ClientPreferencesPatchedPayload, + getSyncedClientPreferenceUpdatedAt, OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, @@ -215,15 +216,38 @@ export function projectEvent( ).pipe( Effect.map((payload) => { const current = nextBase.syncedClientPreferences; - if (current !== undefined && current.updatedAt > payload.updatedAt) { - return { ...nextBase, updatedAt: model.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, - ...payload.patch, - updatedAt: payload.updatedAt, + ...(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, }, }; }), diff --git a/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts index 9e289e895c2b..6245bba73d9f 100644 --- a/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts +++ b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts @@ -44,8 +44,11 @@ layer("041_ProjectionSyncedClientPreferences", (it) => { 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 ) `; @@ -71,7 +74,16 @@ layer("041_ProjectionSyncedClientPreferences", (it) => { assert.deepEqual( columns.map((column) => column.name), - ["singleton_id", "plan_mode_enabled", "appearance_mode", "theme_id", "updated_at"], + [ + "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, [ diff --git a/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.ts b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.ts index 082f86708ba1..7e95b7f37d0b 100644 --- a/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.ts +++ b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.ts @@ -8,8 +8,11 @@ export default Effect.gen(function* () { 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 ) `; diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 3aabcb9a3a84..4bf972c3df37 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -97,7 +97,7 @@ describe("synced plan mode", () => { ).toEqual({ type: "seed", value: true, - updatedAt: "2026-08-14T12:00:00.001Z", + updatedAt: "2026-08-14T11:00:00.000Z", }); expect( resolveSyncedPlanModeHydrationAction({ @@ -129,6 +129,26 @@ describe("synced plan mode", () => { }); }); + it("advances writes from the plan clock instead of a newer appearance clock", () => { + expect( + createSyncedPlanModeWrite({ + 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", + }), + ).toMatchObject({ + request: { updatedAt: "2026-08-14T12:30:00.000Z" }, + }); + }); + it("does not re-adopt stale server state while a local write is pending", () => { expect( resolveSyncedPlanModeHydrationAction({ diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 230073ed9c41..7dc81fb3238d 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -14,6 +14,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, DEFAULT_SERVER_SETTINGS, + getSyncedClientPreferenceUpdatedAt, nextSyncedClientPreferencesUpdatedAt, type EnvironmentId, type PatchSyncedClientPreferencesRequest, @@ -273,7 +274,10 @@ export function createSyncedPlanModeWrite(input: { readonly pendingUpdatedAt?: string; readonly now: string; }) { - const serverUpdatedAt = input.serverPreferences?.updatedAt; + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( + input.serverPreferences, + "planModeEnabled", + ); const currentUpdatedAt = input.pendingUpdatedAt !== undefined && (serverUpdatedAt === undefined || input.pendingUpdatedAt > serverUpdatedAt) @@ -325,31 +329,32 @@ export function resolveSyncedPlanModeHydrationAction(input: { readonly now: string; }): SyncedPlanModeHydrationAction { if (!input.clientHydrated) return { type: "none" }; + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( + input.serverPreferences, + "planModeEnabled", + ); if ( input.writePending !== undefined && - (input.serverPreferences === undefined || - input.serverPreferences.updatedAt < input.writePending.updatedAt) + (serverUpdatedAt === undefined || serverUpdatedAt < input.writePending.updatedAt) ) { return { type: "none" }; } if (input.serverPreferences?.planModeEnabled !== undefined) { return input.adoptedUpdatedAt !== undefined && - input.serverPreferences.updatedAt <= input.adoptedUpdatedAt + serverUpdatedAt !== undefined && + serverUpdatedAt <= input.adoptedUpdatedAt ? { type: "none" } : { type: "adopt", value: input.serverPreferences.planModeEnabled, - updatedAt: input.serverPreferences.updatedAt, + updatedAt: serverUpdatedAt ?? input.serverPreferences.updatedAt, }; } if (input.seedPending) return { type: "none" }; return { type: "seed", value: input.clientValue, - updatedAt: nextSyncedClientPreferencesUpdatedAt( - [input.serverPreferences?.updatedAt], - input.now, - ), + updatedAt: nextSyncedClientPreferencesUpdatedAt([serverUpdatedAt], input.now), }; } @@ -444,7 +449,11 @@ export function createSyncedPlanModeHydrationController( cancelRetry(state); if (matchingWrite) delete state.writePending; - markAdopted(state, input.result.value.updatedAt); + const resultUpdatedAt = getSyncedClientPreferenceUpdatedAt( + input.result.value, + "planModeEnabled", + ); + if (resultUpdatedAt !== undefined) markAdopted(state, resultUpdatedAt); if (getSynchronizeAgain(state) === undefined) return; if (input.result.value.planModeEnabled !== undefined) { input.persist(input.result.value.planModeEnabled); @@ -481,10 +490,14 @@ export function createSyncedPlanModeHydrationController( delete state.seedPendingUpdatedAt; } const pendingWrite = state.writePending; + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( + input.serverPreferences, + "planModeEnabled", + ); if ( pendingWrite !== undefined && - input.serverPreferences !== undefined && - input.serverPreferences.updatedAt >= pendingWrite.updatedAt + serverUpdatedAt !== undefined && + serverUpdatedAt >= pendingWrite.updatedAt ) { delete state.writePending; delete state.writeInFlightUpdatedAt; @@ -494,8 +507,7 @@ export function createSyncedPlanModeHydrationController( if ( input.canPatch && activePendingWrite !== undefined && - (input.serverPreferences === undefined || - input.serverPreferences.updatedAt < activePendingWrite.updatedAt) && + (serverUpdatedAt === undefined || serverUpdatedAt < activePendingWrite.updatedAt) && state.writeInFlightUpdatedAt !== activePendingWrite.updatedAt ) { dispatchPatch({ diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index 9f0457ce41ae..5d74c119691c 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -188,12 +188,14 @@ describe("applyShellStreamEvent", () => { 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); diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 8bc61d5881c9..c7e0c01a7e7f 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -973,6 +973,7 @@ it.effect("keeps new shell snapshots decodable by legacy clients", () => 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", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index da8f03d2ebbc..ef40780d636e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -12,6 +12,7 @@ import { import { SyncedClientPreferences, SyncedClientPreferencesPatch, + SyncedClientPreferencesUpdatedAt, PatchSyncedClientPreferencesRequest, } from "./syncedClientPreferences.ts"; @@ -24,6 +25,12 @@ const decodeSyncedClientPreferencesPatch = Schema.decodeUnknownSync(SyncedClient 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", () => { @@ -32,6 +39,11 @@ describe("SyncedClientPreferences", () => { 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, }); @@ -47,6 +59,7 @@ describe("SyncedClientPreferences", () => { "planModeEnabled", "themeId", "updatedAt", + "updatedAtByField", ]); expect(Object.keys(patch).sort()).toEqual(["appearanceMode", "planModeEnabled", "themeId"]); }); @@ -62,6 +75,21 @@ describe("SyncedClientPreferences", () => { } }); + 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(() => diff --git a/packages/contracts/src/syncedClientPreferences.ts b/packages/contracts/src/syncedClientPreferences.ts index b079c6f540e7..f9b13b4b5eca 100644 --- a/packages/contracts/src/syncedClientPreferences.ts +++ b/packages/contracts/src/syncedClientPreferences.ts @@ -36,13 +36,39 @@ const SyncedClientPreferenceFields = { 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; + /** Client preferences replicated through one environment's event log. */ 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; +} + /** Exactly the preference keys that clients may patch in this rollout. */ export const SyncedClientPreferencesPatch = Schema.Struct(SyncedClientPreferenceFields).check( Schema.makeFilter( From ee1eec9c801590c744627d0a217ae339fcd43772 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 01:17:10 -0400 Subject: [PATCH 13/42] fix(mobile): monotonic write stamps --- .../state/synced-client-preferences-model.ts | 10 ++++++--- .../state/synced-client-preferences.test.ts | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 8772def3688a..e9958314cefa 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -297,13 +297,17 @@ export function createPlanModePreferenceWriteController() { return { create(input: Parameters[0]) { + const persistedUpdatedAt = input.currentUpdatedAtByField?.planModeEnabled; + const currentUpdatedAt = + latestRequestedUpdatedAt === undefined || + (persistedUpdatedAt !== undefined && persistedUpdatedAt > latestRequestedUpdatedAt) + ? persistedUpdatedAt + : latestRequestedUpdatedAt; const write = createPlanModePreferenceWrite({ ...input, currentUpdatedAtByField: { ...input.currentUpdatedAtByField, - ...(latestRequestedUpdatedAt === undefined - ? {} - : { planModeEnabled: latestRequestedUpdatedAt }), + ...(currentUpdatedAt === undefined ? {} : { planModeEnabled: currentUpdatedAt }), }, }); latestRequestedUpdatedAt = diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 503fbc35c483..0e7296892a43 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -168,6 +168,27 @@ describe("synced client preferences", () => { ).toBeNull(); }); + it("advances past a local clock reconciled after the previous write", () => { + const controller = createPlanModePreferenceWriteController(); + const environment = environmentId("environment-1"); + controller.create({ + value: true, + connectedEnvironmentIds: [environment], + now: "2026-08-14T12:00:00.000Z", + }); + + const write = controller.create({ + value: false, + connectedEnvironmentIds: [environment], + currentUpdatedAtByField: { planModeEnabled: "2026-08-14T12:05:00.000Z" }, + now: "2026-08-14T12:01:00.000Z", + }); + + expect(write.localPatch.syncedClientPreferencesUpdatedAtByField.planModeEnabled).toBe( + "2026-08-14T12:05:00.001Z", + ); + }); + it("settles a multi-environment toggle from only the first response", () => { const controller = createPlanModePreferenceWriteController(); const write = controller.create({ From 8786028b00e948a3977e6e6d536b51f638da48c8 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 01:28:12 -0400 Subject: [PATCH 14/42] refactor(mobile): remove review-round slop --- .../features/threads/NewTaskDraftScreen.tsx | 3 +- .../src/features/threads/ThreadComposer.tsx | 3 +- .../src/persistence/mobile-preferences.ts | 3 - .../state/synced-client-preferences.test.ts | 6 +- .../src/state/use-thread-outbox-drain.ts | 3 +- .../Layers/ProjectionSnapshotQuery.ts | 78 +--- apps/server/src/ws.ts | 6 +- apps/web/src/hooks/synced-plan-mode.ts | 390 +++++++++++++++++ apps/web/src/hooks/useSettings.test.ts | 330 ++++++--------- apps/web/src/hooks/useSettings.ts | 399 +----------------- .../contracts/src/syncedClientPreferences.ts | 2 - 11 files changed, 550 insertions(+), 673 deletions(-) create mode 100644 apps/web/src/hooks/synced-plan-mode.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 5a3775fe0a41..91cb686ca94b 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -268,7 +268,6 @@ export function NewTaskDraftScreen(props: { composerSelectionRef.current = selection; setComposerSelection(selection); }, []); - const handleComposerSelectionChange = updateComposerSelection; useEffect(() => { const previousDraftKey = composerSelectionDraftKeyRef.current; composerSelectionDraftKeyRef.current = flow.draftKey; @@ -958,7 +957,7 @@ export function NewTaskDraftScreen(props: { skills={flow.selectedProviderSkills} selection={composerSelection} onChangeText={flow.setPrompt} - onSelectionChange={handleComposerSelectionChange} + onSelectionChange={updateComposerSelection} onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 1da5844cab4c..15bec52c1f4e 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -365,7 +365,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer composerSelectionRef.current = selection; setComposerSelection(selection); }, []); - const handleSelectionChange = updateComposerSelection; useEffect(() => { const end = props.draftMessage.length; const selection = composerSelectionRef.current; @@ -792,7 +791,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/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index ba8e832ec8fc..0e5161648e07 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -36,9 +36,6 @@ export interface Preferences { readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; readonly autoSettleOnMerge?: boolean; - /** - * Offline cache of the most recently reconciled environment preference. - */ readonly planModeEnabled?: boolean; readonly syncedClientPreferencesUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByFieldValue; /** @deprecated Aggregate-clock cache retained for older persisted blobs. */ diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 0e7296892a43..9b3bc14563aa 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -304,8 +304,7 @@ describe("synced client preferences", () => { it("reconciles when ES2023 change-by-copy array methods are unavailable", () => { const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, "toSorted"); - // oxlint-disable-next-line no-extend-native -- Simulate Hermes, which omits this method. - Object.defineProperty(Array.prototype, "toSorted", { + Reflect.defineProperty(Array.prototype, "toSorted", { configurable: true, value: undefined, }); @@ -338,8 +337,7 @@ describe("synced client preferences", () => { if (descriptor === undefined) { Reflect.deleteProperty(Array.prototype, "toSorted"); } else { - // oxlint-disable-next-line no-extend-native -- Restore the pre-test implementation. - Object.defineProperty(Array.prototype, "toSorted", descriptor); + Reflect.defineProperty(Array.prototype, "toSorted", descriptor); } } }); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 190b4d10a58f..2e6b8c590e80 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -306,8 +306,7 @@ export function useThreadOutboxDrain(): void { ); useEffect(() => { - // Wait for the stored mode before draining. Load errors resolve to defaults, - // so only an atom construction failure can leave this pending forever. + // Resolve queued interaction modes only after persisted preferences load. if (!planModePreferenceLoaded || dispatchingQueuedMessageId !== null) { return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5814914e6c76..3a02a70edc74 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -815,6 +815,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + 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, @@ -1569,14 +1580,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - getSyncedClientPreferencesRow(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:getSyncedClientPreferences:query", - "ProjectionSnapshotQuery.getSnapshot:getSyncedClientPreferences:decodeRow", - ), - ), - ), + readSyncedClientPreferences("getSnapshot:getSyncedClientPreferences"), ]), ) .pipe( @@ -1591,7 +1595,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointRows, latestTurnRows, stateRows, - syncedClientPreferencesRow, + syncedClientPreferences, ]) => Effect.gen(function* () { const messagesByThread = new Map>(); @@ -1602,9 +1606,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const latestTurnByThread = new Map(); let updatedAt: string | null = null; - const syncedClientPreferences = mapSyncedClientPreferences( - syncedClientPreferencesRow, - ); if (syncedClientPreferences !== undefined) { updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); } @@ -1855,14 +1856,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - getSyncedClientPreferencesRow(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getCommandReadModel:getSyncedClientPreferences:query", - "ProjectionSnapshotQuery.getCommandReadModel:getSyncedClientPreferences:decodeRow", - ), - ), - ), + readSyncedClientPreferences("getCommandReadModel:getSyncedClientPreferences"), ]), ) .pipe( @@ -1874,13 +1868,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessionRows, latestTurnRows, stateRows, - syncedClientPreferencesRow, + syncedClientPreferences, ]) => Effect.sync(() => { let updatedAt: string | null = null; - const syncedClientPreferences = mapSyncedClientPreferences( - syncedClientPreferencesRow, - ); if (syncedClientPreferences !== undefined) { updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); } @@ -2072,14 +2063,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - getSyncedClientPreferencesRow(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:getSyncedClientPreferences:query", - "ProjectionSnapshotQuery.getShellSnapshot:getSyncedClientPreferences:decodeRow", - ), - ), - ), + readSyncedClientPreferences("getShellSnapshot:getSyncedClientPreferences"), ]), ) .pipe( @@ -2090,13 +2074,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessionRows, latestTurnRows, stateRows, - syncedClientPreferencesRow, + syncedClientPreferences, ]) => Effect.gen(function* () { let updatedAt: string | null = null; - const syncedClientPreferences = mapSyncedClientPreferences( - syncedClientPreferencesRow, - ); if (syncedClientPreferences !== undefined) { updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); } @@ -2239,14 +2220,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - getSyncedClientPreferencesRow(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getArchivedShellSnapshot:getSyncedClientPreferences:query", - "ProjectionSnapshotQuery.getArchivedShellSnapshot:getSyncedClientPreferences:decodeRow", - ), - ), - ), + readSyncedClientPreferences("getArchivedShellSnapshot:getSyncedClientPreferences"), ]), ) .pipe( @@ -2257,13 +2231,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessionRows, latestTurnRows, stateRows, - syncedClientPreferencesRow, + syncedClientPreferences, ]) => Effect.gen(function* () { let updatedAt: string | null = null; - const syncedClientPreferences = mapSyncedClientPreferences( - syncedClientPreferencesRow, - ); if (syncedClientPreferences !== undefined) { updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); } @@ -2378,16 +2349,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); const getSyncedClientPreferences: ProjectionSnapshotQueryShape["getSyncedClientPreferences"] = - () => - getSyncedClientPreferencesRow(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSyncedClientPreferences:query", - "ProjectionSnapshotQuery.getSyncedClientPreferences:decodeRow", - ), - ), - Effect.map(mapSyncedClientPreferences), - ); + () => readSyncedClientPreferences("getSyncedClientPreferences"); const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => readProjectionCounts(undefined).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c9415b15c32b..632b97a29d56 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -602,8 +602,6 @@ const makeWsRpcLayer = ( Effect.orElseSucceed(() => Option.none()), ); - const getSyncedClientPreferences = () => projectionSnapshotQuery.getSyncedClientPreferences(); - const clientPreferencesUpdated = ( sequence: number, ): Effect.Effect, never, never> => @@ -1571,7 +1569,7 @@ const makeWsRpcLayer = ( [WS_METHODS.syncedClientPreferencesGet]: (_input) => observeRpcEffect( WS_METHODS.syncedClientPreferencesGet, - getSyncedClientPreferences().pipe( + projectionSnapshotQuery.getSyncedClientPreferences().pipe( Effect.map((preferences) => preferences ?? null), Effect.mapError( (cause) => @@ -1594,7 +1592,7 @@ const makeWsRpcLayer = ( updatedAt, }); yield* dispatchNormalizedCommand(normalizedCommand); - const preferences = yield* getSyncedClientPreferences().pipe( + const preferences = yield* projectionSnapshotQuery.getSyncedClientPreferences().pipe( Effect.mapError( (cause) => new OrchestrationGetSnapshotError({ diff --git a/apps/web/src/hooks/synced-plan-mode.ts b/apps/web/src/hooks/synced-plan-mode.ts new file mode 100644 index 000000000000..518d24a485a1 --- /dev/null +++ b/apps/web/src/hooks/synced-plan-mode.ts @@ -0,0 +1,390 @@ +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 SyncedClientPreferences, +} 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; + }); +} + +export function createSyncedPlanModeWrite(input: { + readonly value: boolean; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly pendingUpdatedAt?: string; + readonly now: string; +}) { + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( + input.serverPreferences, + "planModeEnabled", + ); + const currentUpdatedAt = + input.pendingUpdatedAt !== undefined && + (serverUpdatedAt === undefined || input.pendingUpdatedAt > serverUpdatedAt) + ? input.pendingUpdatedAt + : serverUpdatedAt; + return { + clientPatch: { planModeEnabled: input.value }, + request: { + patch: { planModeEnabled: input.value }, + updatedAt: nextSyncedClientPreferencesUpdatedAt([currentUpdatedAt], input.now), + }, + } as const; +} + +type SyncedPlanModePatchTarget = { + readonly environmentId: EnvironmentId; + readonly input: PatchSyncedClientPreferencesRequest; +}; + +type SyncedPlanModePatch = ( + target: SyncedPlanModePatchTarget, +) => Promise>; + +export interface SyncedPlanModeHydrationInput { + readonly environmentId: EnvironmentId | null; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly clientHydrated: boolean; + readonly clientValue: boolean; + readonly live: boolean; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly canPatch: boolean; + readonly now: string; + readonly patch: SyncedPlanModePatch; + readonly persist: (value: boolean) => void; +} + +export type SyncedPlanModeHydrationAction = + | { readonly type: "none" } + | { readonly type: "adopt"; readonly value: boolean; readonly updatedAt: string } + | { readonly type: "seed"; readonly value: boolean; readonly updatedAt: string }; + +export function resolveSyncedPlanModeHydrationAction(input: { + readonly clientHydrated: boolean; + readonly clientValue: boolean; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly seedPending: boolean; + readonly writePending?: { readonly value: boolean; readonly updatedAt: string }; + readonly adoptedUpdatedAt?: string; + readonly now: string; +}): SyncedPlanModeHydrationAction { + if (!input.clientHydrated) return { type: "none" }; + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( + input.serverPreferences, + "planModeEnabled", + ); + if ( + input.writePending !== undefined && + (serverUpdatedAt === undefined || serverUpdatedAt < input.writePending.updatedAt) + ) { + return { type: "none" }; + } + if (input.serverPreferences?.planModeEnabled !== undefined) { + return input.adoptedUpdatedAt !== undefined && + serverUpdatedAt !== undefined && + serverUpdatedAt <= input.adoptedUpdatedAt + ? { type: "none" } + : { + type: "adopt", + value: input.serverPreferences.planModeEnabled, + updatedAt: serverUpdatedAt ?? input.serverPreferences.updatedAt, + }; + } + if (input.seedPending) return { type: "none" }; + return { + type: "seed", + value: input.clientValue, + updatedAt: nextSyncedClientPreferencesUpdatedAt([serverUpdatedAt], input.now), + }; +} + +const SYNCED_PLAN_MODE_RETRY_DELAY_MS = 1_000; + +type SyncedPlanModeRetryScheduler = (retry: () => void) => () => void; + +const scheduleSyncedPlanModeRetry: SyncedPlanModeRetryScheduler = (retry) => { + const timer = setTimeout(retry, SYNCED_PLAN_MODE_RETRY_DELAY_MS); + return () => clearTimeout(timer); +}; + +export function createSyncedPlanModeHydrationController( + scheduleRetry: SyncedPlanModeRetryScheduler = scheduleSyncedPlanModeRetry, +) { + interface SyncedPlanModeEnvironmentState { + adoptedUpdatedAt?: string; + seedPendingUpdatedAt?: string; + writePending?: { readonly value: boolean; readonly updatedAt: string }; + writeInFlightUpdatedAt?: string; + readonly synchronizeAgainByOwner: Map void>; + cancelRetry?: () => void; + } + + const imperativeSynchronizationOwner = Symbol(); + const stateByEnvironment = new Map(); + const stateFor = (environmentId: EnvironmentId) => { + const current = stateByEnvironment.get(environmentId); + if (current !== undefined) return current; + const state: SyncedPlanModeEnvironmentState = { synchronizeAgainByOwner: new Map() }; + stateByEnvironment.set(environmentId, state); + return state; + }; + const cancelRetry = (state: SyncedPlanModeEnvironmentState) => { + 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: SyncedPlanModeEnvironmentState) => { + let latest: (() => void) | undefined; + for (const synchronizeAgain of state.synchronizeAgainByOwner.values()) { + latest = synchronizeAgain; + } + return latest; + }; + const requestRetry = (state: SyncedPlanModeEnvironmentState) => { + if (state.cancelRetry !== undefined || getSynchronizeAgain(state) === undefined) return; + state.cancelRetry = scheduleRetry(() => { + delete state.cancelRetry; + getSynchronizeAgain(state)?.(); + }); + }; + const markAdopted = (state: SyncedPlanModeEnvironmentState, 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: boolean) => void; + }) => { + const state = stateByEnvironment.get(input.environmentId); + if (state === undefined) return; + if (state.writeInFlightUpdatedAt === input.requestedUpdatedAt) { + delete state.writeInFlightUpdatedAt; + } + if (input.result._tag === "Failure") { + if (state.seedPendingUpdatedAt === input.requestedUpdatedAt) { + 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); + if (matchingWrite) delete state.writePending; + const resultUpdatedAt = getSyncedClientPreferenceUpdatedAt( + input.result.value, + "planModeEnabled", + ); + if (resultUpdatedAt !== undefined) markAdopted(state, resultUpdatedAt); + if (getSynchronizeAgain(state) === undefined) return; + if (input.result.value.planModeEnabled !== undefined) { + input.persist(input.result.value.planModeEnabled); + } + }; + + const dispatchPatch = (input: { + readonly target: SyncedPlanModePatchTarget; + readonly patch: SyncedPlanModePatch; + readonly persist: (value: boolean) => void; + }) => { + const { environmentId } = input.target; + const requestedUpdatedAt = input.target.input.updatedAt; + stateFor(environmentId).writeInFlightUpdatedAt = requestedUpdatedAt; + void input.patch(input.target).then((result) => { + settlePatch({ environmentId, requestedUpdatedAt, result, persist: input.persist }); + }); + }; + + const synchronize = ( + input: SyncedPlanModeHydrationInput, + owner = imperativeSynchronizationOwner, + ) => { + const environmentId = input.environmentId; + if (environmentId === null) return; + if (environmentId !== input.primaryEnvironmentId || !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?.planModeEnabled !== undefined) { + delete state.seedPendingUpdatedAt; + } + const pendingWrite = state.writePending; + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( + input.serverPreferences, + "planModeEnabled", + ); + if ( + pendingWrite !== undefined && + serverUpdatedAt !== undefined && + serverUpdatedAt >= pendingWrite.updatedAt + ) { + delete state.writePending; + delete state.writeInFlightUpdatedAt; + cancelRetry(state); + } + const activePendingWrite = state.writePending; + if ( + input.canPatch && + activePendingWrite !== undefined && + (serverUpdatedAt === undefined || serverUpdatedAt < activePendingWrite.updatedAt) && + state.writeInFlightUpdatedAt !== activePendingWrite.updatedAt + ) { + dispatchPatch({ + target: { + environmentId, + input: { + patch: { planModeEnabled: activePendingWrite.value }, + updatedAt: activePendingWrite.updatedAt, + }, + }, + patch: input.patch, + persist: input.persist, + }); + } + + const action = resolveSyncedPlanModeHydrationAction({ + clientHydrated: input.clientHydrated, + clientValue: input.clientValue, + serverPreferences: input.serverPreferences, + seedPending: state.seedPendingUpdatedAt !== undefined, + ...(activePendingWrite === undefined ? {} : { writePending: activePendingWrite }), + ...(state.adoptedUpdatedAt === undefined ? {} : { adoptedUpdatedAt: state.adoptedUpdatedAt }), + now: input.now, + }); + 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: { planModeEnabled: action.value }, + updatedAt: action.updatedAt, + }, + }, + patch: input.patch, + persist: input.persist, + }); + + return deactivateSynchronization; + }; + + const write = (input: { + readonly environmentId: EnvironmentId | null; + readonly value: boolean; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly canPatch: boolean; + readonly now: string; + readonly patch: SyncedPlanModePatch; + readonly persist: (value: boolean) => void; + }) => { + if (input.environmentId === null) return; + const environmentId = input.environmentId; + const state = stateFor(environmentId); + const controllerUpdatedAt = [ + state.adoptedUpdatedAt, + state.seedPendingUpdatedAt, + state.writePending?.updatedAt, + state.writeInFlightUpdatedAt, + ].reduce( + (latest, candidate) => + candidate !== undefined && (latest === undefined || candidate > latest) + ? candidate + : latest, + undefined, + ); + const next = createSyncedPlanModeWrite({ + value: input.value, + serverPreferences: input.serverPreferences, + ...(controllerUpdatedAt === undefined ? {} : { pendingUpdatedAt: controllerUpdatedAt }), + now: input.now, + }); + state.writePending = { + value: input.value, + updatedAt: next.request.updatedAt, + }; + if (!input.canPatch) return; + dispatchPatch({ + target: { environmentId, input: next.request }, + patch: input.patch, + persist: input.persist, + }); + }; + + return { + synchronize, + write, + reset() { + for (const state of stateByEnvironment.values()) { + cancelRetry(state); + state.synchronizeAgainByOwner.clear(); + } + stateByEnvironment.clear(); + }, + }; +} + +export function useSyncedPlanModeHydrationEffect( + controller: ReturnType, + input: SyncedPlanModeHydrationInput, +): 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.test.ts b/apps/web/src/hooks/useSettings.test.ts index 4bf972c3df37..1de71a5a6777 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -9,58 +9,16 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import * as Option from "effect/Option"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { act, createElement, Fragment } from "react"; -import { createRoot } from "react-dom/client"; import { describe, expect, it, vi } from "vite-plus/test"; import { createSyncedClientPreferencesSliceAtom, createSyncedPlanModeHydrationController, createSyncedPlanModeWrite, - mergeEnvironmentSettings, - resolveEnvironmentIdentificationMode, resolveSyncedPlanModeHydrationAction, type SyncedPlanModeHydrationInput, - useSyncedPlanModeHydrationEffect, -} from "./useSettings"; - -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 as unknown, - documentElement: null as unknown, - }; - 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: "", - }; - document.body = container; - document.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); - return createRoot(container as unknown as Element); -} +} from "./synced-plan-mode"; +import { mergeEnvironmentSettings, resolveEnvironmentIdentificationMode } from "./useSettings"; describe("synced plan mode", () => { it("adopts an environment value over the local cache", () => { @@ -168,7 +126,7 @@ describe("synced plan mode", () => { ).toEqual({ type: "none" }); }); - it("keeps divergent values stable with both environment hydration hooks mounted", async () => { + it("keeps divergent values stable across primary and secondary synchronization", async () => { const primaryEnvironmentId = EnvironmentId.make("primary"); const secondaryEnvironmentId = EnvironmentId.make("secondary"); const controller = createSyncedPlanModeHydrationController(); @@ -185,65 +143,53 @@ describe("synced plan mode", () => { updatedAt: "2026-08-14T12:00:00.000Z", }), ); - const HydrationHook = ({ input }: { input: SyncedPlanModeHydrationInput }) => { - useSyncedPlanModeHydrationEffect(controller, input); - return null; - }; - const root = createHookTestRoot(); - - try { - for (let render = 0; render < 10; render += 1) { - await act(async () => { - root.render( - createElement( - Fragment, - null, - createElement(HydrationHook, { - input: { - 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, - }, - }), - createElement(HydrationHook, { - input: { - 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, - }, - }), - ), - ); - }); - } - - expect(localValue).toBe(true); - expect(persisted).toEqual([true]); - expect(patch).not.toHaveBeenCalled(); - } finally { - await act(async () => root.unmount()); - vi.unstubAllGlobals(); + 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 () => { @@ -505,55 +451,41 @@ describe("synced plan mode", () => { }), ); const persist = vi.fn(); - const HydrationHook = ({ input }: { input: SyncedPlanModeHydrationInput }) => { - useSyncedPlanModeHydrationEffect(controller, input); - return null; - }; - const root = createHookTestRoot(); - - try { - 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 SyncedPlanModeHydrationInput; + 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 SyncedPlanModeHydrationInput; + const owner = Symbol(); + const deactivate = controller.synchronize(input, owner); + await Promise.resolve(); + expect(scheduledRetries).toHaveLength(1); - await act(async () => { - root.render(createElement(HydrationHook, { input })); - }); - expect(scheduledRetries).toHaveLength(1); - - await act(async () => { - root.render( - createElement(HydrationHook, { - input: { - ...input, - environmentId: nextPrimaryEnvironmentId, - primaryEnvironmentId: nextPrimaryEnvironmentId, - serverPreferences: { - planModeEnabled: false, - updatedAt: "2026-08-14T12:01:00.000Z", - }, - }, - }), - ); - }); - scheduledRetries[0]?.(); - await Promise.resolve(); + 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(); - } finally { - await act(async () => root.unmount()); - vi.unstubAllGlobals(); - } + expect(patch).toHaveBeenCalledOnce(); + expect(persist).not.toHaveBeenCalled(); }); it.each([ @@ -576,65 +508,49 @@ describe("synced plan mode", () => { ); const persisted: boolean[] = []; const persist = (value: boolean) => persisted.push(value); - const HydrationHook = ({ input }: { input: SyncedPlanModeHydrationInput }) => { - useSyncedPlanModeHydrationEffect(controller, input); - return null; - }; - const root = createHookTestRoot(); + 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 SyncedPlanModeHydrationInput; + const owner = Symbol(); + const deactivate = controller.synchronize(input, owner); + expect(patch).toHaveBeenCalledOnce(); - try { - 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 SyncedPlanModeHydrationInput; - - await act(async () => { - root.render(createElement(HydrationHook, { input })); - }); - expect(patch).toHaveBeenCalledOnce(); - - if (switchPrimary) { - await act(async () => { - root.render( - createElement(HydrationHook, { - input: { - ...input, - environmentId: nextPrimaryEnvironmentId, - primaryEnvironmentId: nextPrimaryEnvironmentId, - clientValue: false, - serverPreferences: { - planModeEnabled: false, - updatedAt: "2026-08-14T12:01:00.000Z", - }, - }, - }), - ); - }); - } - - await act(async () => { - resolvePatch( - AsyncResult.success({ - planModeEnabled: true, - updatedAt: "2026-08-14T12:00:00.000Z", - }), - ); - await Promise.resolve(); - }); - - expect(persisted).toEqual(expectedPersisted); - } finally { - await act(async () => root.unmount()); - vi.unstubAllGlobals(); + 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?.(); }, ); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 7dc81fb3238d..5da8e8d2167c 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -9,21 +9,16 @@ * access is intentionally named as such so environment-sensitive consumers * cannot silently read the wrong server's settings. */ -import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, DEFAULT_SERVER_SETTINGS, - getSyncedClientPreferenceUpdatedAt, - nextSyncedClientPreferencesUpdatedAt, type EnvironmentId, - type PatchSyncedClientPreferencesRequest, ServerSettings, type ServerSettingsPatch, type SyncedClientPreferences, } from "@t3tools/contracts"; -import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; import { type ClientSettingsPatch, type ClientSettings, @@ -41,13 +36,18 @@ import { themeAllowsSidebarArtwork, } from "~/themePalette"; import * as Struct from "effect/Struct"; -import * as Option from "effect/Option"; 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, + createSyncedClientPreferencesSliceAtom, + createSyncedPlanModeHydrationController, + useSyncedPlanModeHydrationEffect, +} from "./synced-plan-mode"; import { useTheme } from "./useTheme"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -60,20 +60,9 @@ let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; let clientSettingsHydrated = false; let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; -const SHELL_NOT_LIVE = Symbol("shell-not-live"); const EMPTY_SYNCED_CLIENT_PREFERENCES_ATOM = Atom.make(SHELL_NOT_LIVE); const EMPTY_AUTH_SESSION_ATOM = Atom.make(null); -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; - }); -} - const syncedClientPreferencesSliceAtom = Atom.family((environmentId: EnvironmentId) => createSyncedClientPreferencesSliceAtom(environmentShell.stateValueAtom(environmentId)).pipe( Atom.withLabel(`web:synced-client-preferences:${environmentId}`), @@ -268,374 +257,8 @@ function useMergedSettings( return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); } -export function createSyncedPlanModeWrite(input: { - readonly value: boolean; - readonly serverPreferences: SyncedClientPreferences | undefined; - readonly pendingUpdatedAt?: string; - readonly now: string; -}) { - const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( - input.serverPreferences, - "planModeEnabled", - ); - const currentUpdatedAt = - input.pendingUpdatedAt !== undefined && - (serverUpdatedAt === undefined || input.pendingUpdatedAt > serverUpdatedAt) - ? input.pendingUpdatedAt - : serverUpdatedAt; - return { - clientPatch: { planModeEnabled: input.value }, - request: { - patch: { planModeEnabled: input.value }, - updatedAt: nextSyncedClientPreferencesUpdatedAt([currentUpdatedAt], input.now), - }, - } as const; -} - -type SyncedPlanModePatchTarget = { - readonly environmentId: EnvironmentId; - readonly input: PatchSyncedClientPreferencesRequest; -}; - -type SyncedPlanModePatch = ( - target: SyncedPlanModePatchTarget, -) => Promise>; - -export interface SyncedPlanModeHydrationInput { - readonly environmentId: EnvironmentId | null; - readonly primaryEnvironmentId: EnvironmentId | null; - readonly clientHydrated: boolean; - readonly clientValue: boolean; - readonly live: boolean; - readonly serverPreferences: SyncedClientPreferences | undefined; - readonly canPatch: boolean; - readonly now: string; - readonly patch: SyncedPlanModePatch; - readonly persist: (value: boolean) => void; -} - -export type SyncedPlanModeHydrationAction = - | { readonly type: "none" } - | { readonly type: "adopt"; readonly value: boolean; readonly updatedAt: string } - | { readonly type: "seed"; readonly value: boolean; readonly updatedAt: string }; - -export function resolveSyncedPlanModeHydrationAction(input: { - readonly clientHydrated: boolean; - readonly clientValue: boolean; - readonly serverPreferences: SyncedClientPreferences | undefined; - readonly seedPending: boolean; - readonly writePending?: { readonly value: boolean; readonly updatedAt: string }; - readonly adoptedUpdatedAt?: string; - readonly now: string; -}): SyncedPlanModeHydrationAction { - if (!input.clientHydrated) return { type: "none" }; - const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( - input.serverPreferences, - "planModeEnabled", - ); - if ( - input.writePending !== undefined && - (serverUpdatedAt === undefined || serverUpdatedAt < input.writePending.updatedAt) - ) { - return { type: "none" }; - } - if (input.serverPreferences?.planModeEnabled !== undefined) { - return input.adoptedUpdatedAt !== undefined && - serverUpdatedAt !== undefined && - serverUpdatedAt <= input.adoptedUpdatedAt - ? { type: "none" } - : { - type: "adopt", - value: input.serverPreferences.planModeEnabled, - updatedAt: serverUpdatedAt ?? input.serverPreferences.updatedAt, - }; - } - if (input.seedPending) return { type: "none" }; - return { - type: "seed", - value: input.clientValue, - updatedAt: nextSyncedClientPreferencesUpdatedAt([serverUpdatedAt], input.now), - }; -} - -const SYNCED_PLAN_MODE_RETRY_DELAY_MS = 1_000; - -type SyncedPlanModeRetryScheduler = (retry: () => void) => () => void; - -const scheduleSyncedPlanModeRetry: SyncedPlanModeRetryScheduler = (retry) => { - const timer = setTimeout(retry, SYNCED_PLAN_MODE_RETRY_DELAY_MS); - return () => clearTimeout(timer); -}; - -export function createSyncedPlanModeHydrationController( - scheduleRetry: SyncedPlanModeRetryScheduler = scheduleSyncedPlanModeRetry, -) { - interface SyncedPlanModeEnvironmentState { - adoptedUpdatedAt?: string; - seedPendingUpdatedAt?: string; - writePending?: { readonly value: boolean; readonly updatedAt: string }; - writeInFlightUpdatedAt?: string; - readonly synchronizeAgainByOwner: Map void>; - cancelRetry?: () => void; - } - - const imperativeSynchronizationOwner = Symbol(); - const stateByEnvironment = new Map(); - const stateFor = (environmentId: EnvironmentId) => { - const current = stateByEnvironment.get(environmentId); - if (current !== undefined) return current; - const state: SyncedPlanModeEnvironmentState = { synchronizeAgainByOwner: new Map() }; - stateByEnvironment.set(environmentId, state); - return state; - }; - const cancelRetry = (state: SyncedPlanModeEnvironmentState) => { - 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: SyncedPlanModeEnvironmentState) => { - let latest: (() => void) | undefined; - for (const synchronizeAgain of state.synchronizeAgainByOwner.values()) { - latest = synchronizeAgain; - } - return latest; - }; - const requestRetry = (state: SyncedPlanModeEnvironmentState) => { - if (state.cancelRetry !== undefined || getSynchronizeAgain(state) === undefined) return; - state.cancelRetry = scheduleRetry(() => { - delete state.cancelRetry; - getSynchronizeAgain(state)?.(); - }); - }; - const markAdopted = (state: SyncedPlanModeEnvironmentState, 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: boolean) => void; - }) => { - const state = stateByEnvironment.get(input.environmentId); - if (state === undefined) return; - if (state.writeInFlightUpdatedAt === input.requestedUpdatedAt) { - delete state.writeInFlightUpdatedAt; - } - if (input.result._tag === "Failure") { - if (state.seedPendingUpdatedAt === input.requestedUpdatedAt) { - 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); - if (matchingWrite) delete state.writePending; - const resultUpdatedAt = getSyncedClientPreferenceUpdatedAt( - input.result.value, - "planModeEnabled", - ); - if (resultUpdatedAt !== undefined) markAdopted(state, resultUpdatedAt); - if (getSynchronizeAgain(state) === undefined) return; - if (input.result.value.planModeEnabled !== undefined) { - input.persist(input.result.value.planModeEnabled); - } - }; - - const dispatchPatch = (input: { - readonly target: SyncedPlanModePatchTarget; - readonly patch: SyncedPlanModePatch; - readonly persist: (value: boolean) => void; - }) => { - const { environmentId } = input.target; - const requestedUpdatedAt = input.target.input.updatedAt; - stateFor(environmentId).writeInFlightUpdatedAt = requestedUpdatedAt; - void input.patch(input.target).then((result) => { - settlePatch({ environmentId, requestedUpdatedAt, result, persist: input.persist }); - }); - }; - - const synchronize = ( - input: SyncedPlanModeHydrationInput, - owner = imperativeSynchronizationOwner, - ) => { - const environmentId = input.environmentId; - if (environmentId === null) return; - if (environmentId !== input.primaryEnvironmentId || !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?.planModeEnabled !== undefined) { - delete state.seedPendingUpdatedAt; - } - const pendingWrite = state.writePending; - const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( - input.serverPreferences, - "planModeEnabled", - ); - if ( - pendingWrite !== undefined && - serverUpdatedAt !== undefined && - serverUpdatedAt >= pendingWrite.updatedAt - ) { - delete state.writePending; - delete state.writeInFlightUpdatedAt; - cancelRetry(state); - } - const activePendingWrite = state.writePending; - if ( - input.canPatch && - activePendingWrite !== undefined && - (serverUpdatedAt === undefined || serverUpdatedAt < activePendingWrite.updatedAt) && - state.writeInFlightUpdatedAt !== activePendingWrite.updatedAt - ) { - dispatchPatch({ - target: { - environmentId, - input: { - patch: { planModeEnabled: activePendingWrite.value }, - updatedAt: activePendingWrite.updatedAt, - }, - }, - patch: input.patch, - persist: input.persist, - }); - } - - const action = resolveSyncedPlanModeHydrationAction({ - clientHydrated: input.clientHydrated, - clientValue: input.clientValue, - serverPreferences: input.serverPreferences, - seedPending: state.seedPendingUpdatedAt !== undefined, - ...(activePendingWrite === undefined ? {} : { writePending: activePendingWrite }), - ...(state.adoptedUpdatedAt === undefined ? {} : { adoptedUpdatedAt: state.adoptedUpdatedAt }), - now: input.now, - }); - 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: { planModeEnabled: action.value }, - updatedAt: action.updatedAt, - }, - }, - patch: input.patch, - persist: input.persist, - }); - - return deactivateSynchronization; - }; - - const write = (input: { - readonly environmentId: EnvironmentId | null; - readonly value: boolean; - readonly serverPreferences: SyncedClientPreferences | undefined; - readonly canPatch: boolean; - readonly now: string; - readonly patch: SyncedPlanModePatch; - readonly persist: (value: boolean) => void; - }) => { - if (input.environmentId === null) return; - const environmentId = input.environmentId; - const state = stateFor(environmentId); - const controllerUpdatedAt = [ - state.adoptedUpdatedAt, - state.seedPendingUpdatedAt, - state.writePending?.updatedAt, - state.writeInFlightUpdatedAt, - ].reduce( - (latest, candidate) => - candidate !== undefined && (latest === undefined || candidate > latest) - ? candidate - : latest, - undefined, - ); - const next = createSyncedPlanModeWrite({ - value: input.value, - serverPreferences: input.serverPreferences, - ...(controllerUpdatedAt === undefined ? {} : { pendingUpdatedAt: controllerUpdatedAt }), - now: input.now, - }); - state.writePending = { - value: input.value, - updatedAt: next.request.updatedAt, - }; - if (!input.canPatch) return; - dispatchPatch({ - target: { environmentId, input: next.request }, - patch: input.patch, - persist: input.persist, - }); - }; - - return { - synchronize, - write, - reset() { - for (const state of stateByEnvironment.values()) { - cancelRetry(state); - state.synchronizeAgainByOwner.clear(); - } - stateByEnvironment.clear(); - }, - }; -} - const syncedPlanModeHydrationController = createSyncedPlanModeHydrationController(); -export function useSyncedPlanModeHydrationEffect( - controller: ReturnType, - input: SyncedPlanModeHydrationInput, -): 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, - ], - ); -} - function useEnvironmentSyncedClientPreferences(environmentId: EnvironmentId | null) { const preferences = useAtomValue( environmentId === null @@ -788,9 +411,8 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { serverEnvironment.updateSettings, "server settings update", ); - const syncedEnvironmentId = environmentId; - const synced = useEnvironmentSyncedClientPreferences(syncedEnvironmentId); - const canPatchSyncedPreferences = useCanPatchSyncedClientPreferences(syncedEnvironmentId); + const synced = useEnvironmentSyncedClientPreferences(environmentId); + const canPatchSyncedPreferences = useCanPatchSyncedClientPreferences(environmentId); const patchSyncedClientPreferences = useAtomCommand( serverEnvironment.patchSyncedClientPreferences, { @@ -818,7 +440,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { } if (clientPatch.planModeEnabled !== undefined) { syncedPlanModeHydrationController.write({ - environmentId: syncedEnvironmentId, + environmentId, value: clientPatch.planModeEnabled, serverPreferences: synced.preferences, canPatch: canPatchSyncedPreferences, @@ -838,7 +460,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { patchSyncedClientPreferences, persistServerSettings, synced.preferences, - syncedEnvironmentId, ], ); diff --git a/packages/contracts/src/syncedClientPreferences.ts b/packages/contracts/src/syncedClientPreferences.ts index f9b13b4b5eca..97ade40c7f78 100644 --- a/packages/contracts/src/syncedClientPreferences.ts +++ b/packages/contracts/src/syncedClientPreferences.ts @@ -51,7 +51,6 @@ export const SyncedClientPreferencesUpdatedAtByField = Schema.Struct({ export type SyncedClientPreferencesUpdatedAtByField = typeof SyncedClientPreferencesUpdatedAtByField.Type; -/** Client preferences replicated through one environment's event log. */ export const SyncedClientPreferences = Schema.Struct({ ...SyncedClientPreferenceFields, // Optional while mixed-version clients may still receive aggregate-only snapshots. @@ -69,7 +68,6 @@ export function getSyncedClientPreferenceUpdatedAt( return preferences.updatedAtByField?.[field] ?? preferences.updatedAt; } -/** Exactly the preference keys that clients may patch in this rollout. */ export const SyncedClientPreferencesPatch = Schema.Struct(SyncedClientPreferenceFields).check( Schema.makeFilter( (patch) => From f187a21a27c08a10127115fd82882aa71cbe1c91 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 01:56:00 -0400 Subject: [PATCH 15/42] fix(mobile): address review findings --- ...rojector.synced-client-preferences.test.ts | 44 ++++++++++++++ apps/server/src/orchestration/projector.ts | 18 +++++- apps/web/src/hooks/synced-plan-mode.ts | 37 ++++++++++-- .../useSettings.environment-target.test.ts | 22 ++++++- apps/web/src/hooks/useSettings.test.ts | 60 +++++++++++++++++++ apps/web/src/hooks/useSettings.ts | 36 +++++++++-- 6 files changed, 207 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration/projector.synced-client-preferences.test.ts b/apps/server/src/orchestration/projector.synced-client-preferences.test.ts index 4ac6bd08e844..47c8d7f075c2 100644 --- a/apps/server/src/orchestration/projector.synced-client-preferences.test.ts +++ b/apps/server/src/orchestration/projector.synced-client-preferences.test.ts @@ -54,9 +54,53 @@ it.effect("merges synced preferences by field when a later event is globally sta 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 a54df3605fdf..d4c7547a3414 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -215,7 +215,23 @@ export function projectEvent( "payload", ).pipe( Effect.map((payload) => { - const current = nextBase.syncedClientPreferences; + 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) <= diff --git a/apps/web/src/hooks/synced-plan-mode.ts b/apps/web/src/hooks/synced-plan-mode.ts index 518d24a485a1..911920016872 100644 --- a/apps/web/src/hooks/synced-plan-mode.ts +++ b/apps/web/src/hooks/synced-plan-mode.ts @@ -130,6 +130,7 @@ export function createSyncedPlanModeHydrationController( seedPendingUpdatedAt?: string; writePending?: { readonly value: boolean; readonly updatedAt: string }; writeInFlightUpdatedAt?: string; + pendingAdoption?: { readonly value: boolean; readonly updatedAt: string }; readonly synchronizeAgainByOwner: Map void>; cancelRetry?: () => void; } @@ -208,11 +209,13 @@ export function createSyncedPlanModeHydrationController( input.result.value, "planModeEnabled", ); - if (resultUpdatedAt !== undefined) markAdopted(state, resultUpdatedAt); - if (getSynchronizeAgain(state) === undefined) return; - if (input.result.value.planModeEnabled !== undefined) { - input.persist(input.result.value.planModeEnabled); + const resultValue = input.result.value.planModeEnabled; + 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: { @@ -249,6 +252,25 @@ export function createSyncedPlanModeHydrationController( input.serverPreferences, "planModeEnabled", ); + 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 && @@ -328,6 +350,7 @@ export function createSyncedPlanModeHydrationController( state.seedPendingUpdatedAt, state.writePending?.updatedAt, state.writeInFlightUpdatedAt, + state.pendingAdoption?.updatedAt, ].reduce( (latest, candidate) => candidate !== undefined && (latest === undefined || candidate > latest) @@ -345,6 +368,7 @@ export function createSyncedPlanModeHydrationController( value: input.value, updatedAt: next.request.updatedAt, }; + delete state.pendingAdoption; if (!input.canPatch) return; dispatchPatch({ target: { environmentId, input: next.request }, @@ -356,6 +380,11 @@ export function createSyncedPlanModeHydrationController( 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); diff --git a/apps/web/src/hooks/useSettings.environment-target.test.ts b/apps/web/src/hooks/useSettings.environment-target.test.ts index c02b50f7441e..f6b92ed155c5 100644 --- a/apps/web/src/hooks/useSettings.environment-target.test.ts +++ b/apps/web/src/hooks/useSettings.environment-target.test.ts @@ -89,11 +89,16 @@ vi.mock("~/state/use-atom-command", () => ({ atom === testState.patchPreferencesAtom ? testState.patchPreferences : testState.updateSettings, })); -import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "./useSettings"; +import { + __resetClientSettingsPersistenceForTests, + useEnvironmentSettings, + useUpdateEnvironmentSettings, +} from "./useSettings"; describe("useUpdateEnvironmentSettings", () => { beforeEach(() => { hooks.reset(); + __resetClientSettingsPersistenceForTests(); testState.patchPreferences.mockReset(); testState.patchPreferences.mockImplementation( async (target: { @@ -133,4 +138,19 @@ describe("useUpdateEnvironmentSettings", () => { 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); + }); }); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 1de71a5a6777..b0a0e318516e 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -632,6 +632,66 @@ describe("synced plan mode", () => { expect(patch).toHaveBeenCalledOnce(); }); + it("adopts a canonical patch response after synchronization resumes", async () => { + const environmentId = EnvironmentId.make("primary"); + const controller = createSyncedPlanModeHydrationController(); + const previous = { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + let resolvePatch!: ( + result: Awaited["patch"]>>, + ) => void; + const patch = vi.fn["patch"]>( + () => + new Promise((resolve) => { + resolvePatch = resolve; + }), + ); + let localValue = false; + const persisted: boolean[] = []; + const persist = (value: boolean) => { + localValue = value; + persisted.push(value); + }; + const input = { + environmentId, + primaryEnvironmentId: environmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: previous, + canPatch: true, + now: previous.updatedAt, + patch, + persist, + } satisfies SyncedPlanModeHydrationInput; + const deactivate = controller.synchronize(input); + + localValue = true; + controller.write({ + ...input, + value: localValue, + now: "2026-08-14T12:01:00.000Z", + }); + deactivate?.(); + resolvePatch( + AsyncResult.success({ + planModeEnabled: false, + updatedAt: "2026-08-14T12:02:00.000Z", + }), + ); + await Promise.resolve(); + + expect(persisted).toEqual([]); + + controller.synchronize({ ...input, clientValue: localValue, now: "2026-08-14T12:03:00.000Z" }); + + expect(localValue).toBe(false); + expect(persisted).toEqual([false]); + expect(patch).toHaveBeenCalledOnce(); + }); + it("keeps the latest rapid toggle when responses settle out of order", async () => { const primaryEnvironmentId = EnvironmentId.make("primary"); const controller = createSyncedPlanModeHydrationController(); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 5da8e8d2167c..e0bae5953cff 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -14,6 +14,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, DEFAULT_SERVER_SETTINGS, + getSyncedClientPreferenceUpdatedAt, type EnvironmentId, ServerSettings, type ServerSettingsPatch, @@ -225,13 +226,26 @@ export function mergeEnvironmentSettings( clientSettings: ClientSettings, syncedClientPreferences?: SyncedClientPreferences, syncedPlanModeCanOverrideClient = true, + pendingPlanModeWrite?: { readonly value: boolean; readonly updatedAt: string }, ): UnifiedSettings { + const syncedPlanModeUpdatedAt = getSyncedClientPreferenceUpdatedAt( + syncedClientPreferences, + "planModeEnabled", + ); + const pendingPlanModeIsNewer = + pendingPlanModeWrite !== undefined && + (syncedPlanModeUpdatedAt === undefined || + pendingPlanModeWrite.updatedAt > syncedPlanModeUpdatedAt); return { ...serverSettings, ...clientSettings, - ...(!syncedPlanModeCanOverrideClient || syncedClientPreferences?.planModeEnabled === undefined + ...(!syncedPlanModeCanOverrideClient ? {} - : { planModeEnabled: syncedClientPreferences.planModeEnabled }), + : pendingPlanModeIsNewer + ? { planModeEnabled: pendingPlanModeWrite.value } + : syncedClientPreferences?.planModeEnabled === undefined + ? {} + : { planModeEnabled: syncedClientPreferences.planModeEnabled }), }; } @@ -239,6 +253,7 @@ 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(); @@ -250,8 +265,15 @@ function useMergedSettings( clientSettings, syncedClientPreferences, syncedPlanModeCanOverrideClient, + pendingPlanModeWrite, ), - [clientSettings, serverSettings, syncedClientPreferences, syncedPlanModeCanOverrideClient], + [ + clientSettings, + pendingPlanModeWrite, + serverSettings, + syncedClientPreferences, + syncedPlanModeCanOverrideClient, + ], ); return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); @@ -312,7 +334,11 @@ function useSyncedPlanModeHydration(environmentId: EnvironmentId | null) { persist: persistSyncedPlanMode, }); - return { ...synced, canPatch } as const; + return { + ...synced, + canPatch, + pendingWrite: syncedPlanModeHydrationController.getPendingWrite(environmentId), + } as const; } export function useClientSettings( @@ -382,6 +408,7 @@ export function useEnvironmentSettings( serverSettings ?? DEFAULT_SERVER_SETTINGS, synced.preferences, synced.canPatch, + synced.pendingWrite, selector, ); } @@ -396,6 +423,7 @@ export function usePrimarySettings( useAtomValue(primaryServerSettingsAtom), synced.preferences, synced.canPatch, + synced.pendingWrite, selector, ); } From e7ceaca2c928d9f12a4ad38e679d0fa51b06b8a1 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 02:23:15 -0400 Subject: [PATCH 16/42] fix(mobile): preference write coherence and hydration gating --- .../threads/use-plan-mode-enabled.test.ts | 39 ++++++ .../features/threads/use-plan-mode-enabled.ts | 4 +- apps/mobile/src/state/preferences.test.ts | 80 +++++++++++ apps/mobile/src/state/preferences.ts | 2 +- .../state/synced-client-preferences-model.ts | 29 ++++ .../state/synced-client-preferences.test.ts | 62 +++++++++ .../src/state/synced-client-preferences.ts | 125 +++++++++++++----- 7 files changed, 308 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/src/features/threads/use-plan-mode-enabled.test.ts 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..d25920f5b249 --- /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: null as unknown, + 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 index 0faf023a1be8..213597e87ec6 100644 --- a/apps/mobile/src/features/threads/use-plan-mode-enabled.ts +++ b/apps/mobile/src/features/threads/use-plan-mode-enabled.ts @@ -2,11 +2,13 @@ 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 loaded = AsyncResult.isSuccess(preferencesResult); + const reconciliationReady = usePlanModePreferenceReconciliationReady(); + const loaded = AsyncResult.isSuccess(preferencesResult) && reconciliationReady; return { enabled: resolvePlanModeEnabled(loaded ? preferencesResult.value.planModeEnabled : undefined), loaded, diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index 0f633dfb2700..ef6469027d30 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -334,4 +334,84 @@ 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") }), + ); + } + persisted = { + ...persisted, + ...patch, + ...(patch.syncedClientPreferencesUpdatedAtByField === undefined + ? {} + : { + syncedClientPreferencesUpdatedAtByField: { + ...persisted.syncedClientPreferencesUpdatedAtByField, + ...patch.syncedClientPreferencesUpdatedAtByField, + }, + }), + }; + 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 f54d95f31a11..5ee21dbc9940 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -91,7 +91,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime store.savePatch(normalizedPatch)), + Effect.flatMap((store) => store.savePatch(patch)), Effect.tap((saved) => Effect.sync(() => { get.set(confirmedPreferencesAtom, saved); diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index e9958314cefa..b8500202281f 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -10,6 +10,8 @@ import { 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 type { Preferences } from "../persistence/mobile-preferences"; @@ -45,6 +47,33 @@ export interface LocalSyncedClientPreferencesPatch { readonly updatedAtByField: SyncedClientPreferencesUpdatedAtByField; } +export function isPlanModePreferenceReconciliationReady(input: { + readonly connectionsLoaded: boolean; + readonly environmentCount: number; + readonly currentKey: string; + readonly appliedKey: string | null; +}): boolean { + return ( + input.connectionsLoaded && + (input.environmentCount === 0 || input.appliedKey === input.currentKey) + ); +} + +export function hasPlanModePreferenceReconciliationAttempted( + states: ReadonlyArray<{ + readonly connectionState: EnvironmentConnectionPhase; + readonly shellStatus: EnvironmentShellStatus; + }>, +): boolean { + return states.every( + ({ connectionState, shellStatus }) => + (connectionState === "connected" && shellStatus === "live") || + connectionState === "available" || + connectionState === "error" || + connectionState === "offline", + ); +} + type MutableSyncedClientPreferencesPatch = { -readonly [Field in SyncedClientPreferenceField]?: SyncedClientPreferencesPatch[Field]; }; diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 9b3bc14563aa..18cabe3eec71 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -8,6 +8,8 @@ import { createPlanModePreferenceWrite, createPlanModePreferenceWriteController, createSyncedClientPreferencesWrite, + hasPlanModePreferenceReconciliationAttempted, + isPlanModePreferenceReconciliationReady, nextMobileSyncedPreferencesUpdatedAt, reconcilePlanModePreferences, reconcileSyncedClientPreferences, @@ -43,6 +45,66 @@ describe("synced client preferences", () => { 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", () => { + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: "current", + appliedKey: "current", + }), + ).toBe(true); + }); + + it("does not accept a stale live shell while its environment is reconnecting", () => { + expect( + hasPlanModePreferenceReconciliationAttempted([ + { connectionState: "reconnecting", shellStatus: "live" }, + ]), + ).toBe(false); + }); + + it("allows the device fallback after an offline reconciliation attempt", () => { + expect( + hasPlanModePreferenceReconciliationAttempted([ + { connectionState: "offline", shellStatus: "cached" }, + ]), + ).toBe(true); + }); + it("bounds excessively future-skewed local stamps", () => { expect( nextMobileSyncedPreferencesUpdatedAt( diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index 1a29103f8a96..7c6adcc2179a 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -6,7 +6,9 @@ import { import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo } from "react"; +import { environmentCatalog } from "../connection/catalog"; import { environmentShell } from "./shell"; +import { environmentPresentations } from "./presentation"; import { mobilePreferencesAtom, persistReconciledMobilePreferencesAtom, @@ -15,53 +17,91 @@ import { import { serverEnvironment } from "./server"; import { environmentSession } from "./session"; import { useAtomCommand } from "./use-atom-command"; -import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; import { createPlanModePreferenceReconciliationController, createPlanModePreferenceWriteController, + hasPlanModePreferenceReconciliationAttempted, + isPlanModePreferenceReconciliationReady, reconcilePlanModePreferences, } from "./synced-client-preferences-model"; -function useConnectedEnvironmentPreferenceStates() { - const { connectedEnvironments } = useRemoteConnectionStatus(); - const connectedEnvironmentIds = useMemo( - () => - connectedEnvironments - .filter((environment) => environment.connectionState === "connected") - .map((environment) => environment.environmentId), - [connectedEnvironments], - ); - const statesAtom = useMemo( - () => - Atom.make((get) => - connectedEnvironmentIds.map((environmentId) => ({ +const connectedEnvironmentPreferenceStatesAtom = Atom.make((get) => { + const catalog = get(environmentCatalog.catalogValueAtom); + const localPreferences = get(mobilePreferencesAtom); + 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 = JSON.stringify({ + local: AsyncResult.isSuccess(localPreferences) + ? [ + localPreferences.value.planModeEnabled, + localPreferences.value.syncedClientPreferencesUpdatedAtByField?.planModeEnabled ?? + localPreferences.value.syncedClientPreferencesUpdatedAt, + ] + : null, + environments: states + .map(({ environmentId, connectionState, shell, canPatch }) => { + const preferences = + shell.snapshot._tag === "Some" ? shell.snapshot.value.syncedClientPreferences : undefined; + return [ environmentId, - shell: get(environmentShell.stateValueAtom(environmentId)), - session: get(environmentSession.sessionStateValueAtom(environmentId)), - })), - ), - [connectedEnvironmentIds], - ); - const states = useAtomValue(statesAtom).map(({ environmentId, shell, session }) => ({ - environmentId, - shell, - canPatch: - session?.authenticated === true && - session.scopes?.includes(AuthOrchestrationOperateScope) === true, - })); + connectionState, + shell.status, + canPatch, + preferences?.planModeEnabled, + getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"), + ]; + }) + .sort(([left], [right]) => String(left).localeCompare(String(right))), + }); return { + connectionsLoaded: catalog.isReady, connectedEnvironmentIds: states - .filter((state) => state.canPatch) + .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, + }); } export function useSyncedClientPreferences(): void { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const persistReconciledPreferences = useAtomSet(persistReconciledMobilePreferencesAtom); - const { states } = useConnectedEnvironmentPreferenceStates(); + const { connectionsLoaded, reconciliationKey, states } = + useConnectedEnvironmentPreferenceStates(); + const setReconciledKey = useAtomSet(planModePreferenceReconciledKeyAtom); const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { label: "synced client preferences reconciliation", reportFailure: false, @@ -74,7 +114,9 @@ export function useSyncedClientPreferences(): void { useEffect(() => () => reconciliationController.reset(), [reconciliationController]); useEffect(() => { - const liveStates = states.filter(({ shell }) => shell.status === "live"); + const liveStates = states.filter( + ({ connectionState, shell }) => connectionState === "connected" && shell.status === "live", + ); reconciliationController.setActiveEnvironmentIds( liveStates.filter(({ canPatch }) => canPatch).map(({ environmentId }) => environmentId), ); @@ -88,7 +130,24 @@ export function useSyncedClientPreferences(): void { : undefined; reconciliationController.observe(environmentId, updatedAt); } - if (!AsyncResult.isSuccess(preferencesResult) || liveStates.length === 0) return; + if (!connectionsLoaded || states.length === 0) { + setReconciledKey(null); + 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(reconciliationKey); + return; + } const reconciliation = reconcilePlanModePreferences({ localPlanModeEnabled: preferencesResult.value.planModeEnabled, localUpdatedAt: @@ -114,12 +173,16 @@ export function useSyncedClientPreferences(): void { }), }); } + setReconciledKey(reconciliationKey); }, [ + connectionsLoaded, patchPreferences, persistReconciledPreferences, preferencesResult, + reconciliationKey, reconciliationController, savePreferences, + setReconciledKey, states, ]); } From b9c3507d38da92cc5da0efb4a3ee402594f4cd2a Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 02:48:01 -0400 Subject: [PATCH 17/42] fix(mobile): stable reconciliation readiness under reconnects --- .../state/synced-client-preferences-model.ts | 40 ++++++++-- .../state/synced-client-preferences.test.ts | 74 ++++++++++++++++++- .../src/state/synced-client-preferences.ts | 34 +++------ 3 files changed, 114 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index b8500202281f..3713c222db7b 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -47,6 +47,29 @@ export interface LocalSyncedClientPreferencesPatch { readonly updatedAtByField: SyncedClientPreferencesUpdatedAtByField; } +export function createPlanModePreferenceReconciliationKey( + states: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly connectionState: EnvironmentConnectionPhase; + readonly shellStatus: EnvironmentShellStatus; + readonly preferences: SyncedClientPreferences | undefined; + }>, +): string { + return JSON.stringify( + states + .filter( + ({ connectionState, shellStatus }) => + connectionState === "connected" && shellStatus === "live", + ) + .map(({ environmentId, preferences }) => [ + environmentId, + preferences?.planModeEnabled, + getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"), + ]) + .sort(([left], [right]) => String(left).localeCompare(String(right))), + ); +} + export function isPlanModePreferenceReconciliationReady(input: { readonly connectionsLoaded: boolean; readonly environmentCount: number; @@ -65,12 +88,17 @@ export function hasPlanModePreferenceReconciliationAttempted( readonly shellStatus: EnvironmentShellStatus; }>, ): boolean { - return states.every( - ({ connectionState, shellStatus }) => - (connectionState === "connected" && shellStatus === "live") || - connectionState === "available" || - connectionState === "error" || - connectionState === "offline", + return ( + states.some( + ({ connectionState, shellStatus }) => + connectionState === "connected" && shellStatus === "live", + ) || + states.every( + ({ connectionState }) => + connectionState === "available" || + connectionState === "error" || + connectionState === "offline", + ) ); } diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 18cabe3eec71..1854a03ef153 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -4,6 +4,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; import { + createPlanModePreferenceReconciliationKey, createPlanModePreferenceReconciliationController, createPlanModePreferenceWrite, createPlanModePreferenceWriteController, @@ -89,6 +90,51 @@ describe("synced client preferences", () => { ).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("does not accept a stale live shell while its environment is reconnecting", () => { expect( hasPlanModePreferenceReconciliationAttempted([ @@ -97,11 +143,31 @@ describe("synced client preferences", () => { ).toBe(false); }); - it("allows the device fallback after an offline reconciliation attempt", () => { + 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( - hasPlanModePreferenceReconciliationAttempted([ - { connectionState: "offline", shellStatus: "cached" }, - ]), + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: offlineKey, + appliedKey: null, + }), + ).toBe(false); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: offlineKey, + appliedKey: offlineKey, + }), ).toBe(true); }); diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index 7c6adcc2179a..c685665fbe3a 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -18,6 +18,7 @@ import { serverEnvironment } from "./server"; import { environmentSession } from "./session"; import { useAtomCommand } from "./use-atom-command"; import { + createPlanModePreferenceReconciliationKey, createPlanModePreferenceReconciliationController, createPlanModePreferenceWriteController, hasPlanModePreferenceReconciliationAttempted, @@ -27,7 +28,6 @@ import { const connectedEnvironmentPreferenceStatesAtom = Atom.make((get) => { const catalog = get(environmentCatalog.catalogValueAtom); - const localPreferences = get(mobilePreferencesAtom); const presentations = get(environmentPresentations.presentationsAtom); const states = [...presentations.entries()].map(([environmentId, presentation]) => { const shell = get(environmentShell.stateValueAtom(environmentId)); @@ -41,29 +41,15 @@ const connectedEnvironmentPreferenceStatesAtom = Atom.make((get) => { session.scopes?.includes(AuthOrchestrationOperateScope) === true, }; }); - const reconciliationKey = JSON.stringify({ - local: AsyncResult.isSuccess(localPreferences) - ? [ - localPreferences.value.planModeEnabled, - localPreferences.value.syncedClientPreferencesUpdatedAtByField?.planModeEnabled ?? - localPreferences.value.syncedClientPreferencesUpdatedAt, - ] - : null, - environments: states - .map(({ environmentId, connectionState, shell, canPatch }) => { - const preferences = - shell.snapshot._tag === "Some" ? shell.snapshot.value.syncedClientPreferences : undefined; - return [ - environmentId, - connectionState, - shell.status, - canPatch, - preferences?.planModeEnabled, - getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"), - ]; - }) - .sort(([left], [right]) => String(left).localeCompare(String(right))), - }); + 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 From 4e90cc8aa52fd26f4e85021a8ae23d728d831a3e Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 03:11:33 -0400 Subject: [PATCH 18/42] fix(mobile): gating coverage and share-transfer race --- .../features/threads/NewTaskDraftScreen.tsx | 13 ++++++++- .../threads/new-task-flow-provider.tsx | 3 ++ .../features/threads/new-task-submit.test.ts | 11 ++++++++ .../src/features/threads/new-task-submit.ts | 8 ++++++ .../state/synced-client-preferences-model.ts | 27 ++++++++++++------ .../state/synced-client-preferences.test.ts | 28 +++++++++++++++++++ 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 91cb686ca94b..272702734904 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -72,6 +72,7 @@ import { import { canSubmitNewTaskDraft, resolveNewTaskComposerSelection, + shouldApplyNewTaskCommandSelection, shouldInterpretNewTaskSubmit, } from "./new-task-submit"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; @@ -697,7 +698,14 @@ export function NewTaskDraftScreen(props: { const handleCommandSelect = useCallback( (item: ComposerCommandItem): void => { const draftKey = flow.draftKey; - if (!draftKey || !composerTrigger || item.type !== "slash-command") { + if ( + !shouldApplyNewTaskCommandSelection({ + incomingShareTransferPending: isIncomingShareTransferPending, + }) || + !draftKey || + !composerTrigger || + item.type !== "slash-command" + ) { return; } const interactionMode = resolveSlashCommandInteractionMode({ @@ -730,6 +738,7 @@ export function NewTaskDraftScreen(props: { flow.prompt, flow.setInteractionMode, flow.setPrompt, + isIncomingShareTransferPending, planModeEnabled, updateComposerSelection, ], @@ -782,6 +791,7 @@ export function NewTaskDraftScreen(props: { text: draft.text, incomingShareReady: isIncomingShareReady, importingShare: isImportingShare, + planModePreferenceLoaded: flow.planModePreferenceLoaded, submitting: flow.submitting, workspaceMode, selectedBranchName, @@ -931,6 +941,7 @@ export function NewTaskDraftScreen(props: { text: flow.prompt, incomingShareReady: isIncomingShareReady, importingShare: isImportingShare, + planModePreferenceLoaded: flow.planModePreferenceLoaded, submitting: flow.submitting, workspaceMode: flow.workspaceMode, selectedBranchName: flow.selectedBranchName, 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 c9b08b75382d..5d274d487f0f 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -145,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; @@ -1025,6 +1026,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { runtimeMode, interactionMode, planModeEnabled, + planModePreferenceLoaded, expandedProvider, environments, selectedProject, @@ -1077,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 index 6309f6040d98..0bb886e74855 100644 --- a/apps/mobile/src/features/threads/new-task-submit.test.ts +++ b/apps/mobile/src/features/threads/new-task-submit.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { canSubmitNewTaskDraft, resolveNewTaskComposerSelection, + shouldApplyNewTaskCommandSelection, shouldInterpretNewTaskSubmit, } from "./new-task-submit"; @@ -11,6 +12,7 @@ describe("new-task submission eligibility", () => { text: "Implement the task", incomingShareReady: true, importingShare: false, + planModePreferenceLoaded: true, submitting: false, workspaceMode: "local" as const, selectedBranchName: null, @@ -29,6 +31,10 @@ describe("new-task submission eligibility", () => { 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( @@ -59,6 +65,11 @@ describe("new-task submission eligibility", () => { 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({ diff --git a/apps/mobile/src/features/threads/new-task-submit.ts b/apps/mobile/src/features/threads/new-task-submit.ts index 6ed591468dd3..d007fd306118 100644 --- a/apps/mobile/src/features/threads/new-task-submit.ts +++ b/apps/mobile/src/features/threads/new-task-submit.ts @@ -4,6 +4,12 @@ export function shouldInterpretNewTaskSubmit(input: { 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; @@ -23,6 +29,7 @@ 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; @@ -31,6 +38,7 @@ export function canSubmitNewTaskDraft(input: { 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/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 3713c222db7b..c6ca2943f718 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -57,15 +57,24 @@ export function createPlanModePreferenceReconciliationKey( ): string { return JSON.stringify( states - .filter( - ({ connectionState, shellStatus }) => - connectionState === "connected" && shellStatus === "live", - ) - .map(({ environmentId, preferences }) => [ - environmentId, - preferences?.planModeEnabled, - getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"), - ]) + .map(({ environmentId, connectionState, shellStatus, preferences }) => { + if (connectionState === "connected" && shellStatus === "live") { + return [ + environmentId, + "live", + preferences?.planModeEnabled, + getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"), + ]; + } + if ( + connectionState === "available" || + connectionState === "error" || + connectionState === "offline" + ) { + return [environmentId, "fallback"]; + } + return [environmentId, "pending"]; + }) .sort(([left], [right]) => String(left).localeCompare(String(right))), ); } diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 1854a03ef153..6d73d0f63bfa 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -171,6 +171,34 @@ describe("synced client preferences", () => { ).toBe(true); }); + it("closes device fallback gating when an offline environment starts reconnecting", () => { + 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" }, + ]); + + expect(connectingKey).toBe(reconnectingKey); + expect(connectingKey).not.toBe(offlineKey); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: reconnectingKey, + appliedKey: offlineKey, + }), + ).toBe(false); + }); + it("bounds excessively future-skewed local stamps", () => { expect( nextMobileSyncedPreferencesUpdatedAt( From 65b10cd0c14cc7b2e150cabc8a7654dfa47a6281 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 10:25:52 -0400 Subject: [PATCH 19/42] fix(mobile): settle reconciliation readiness semantics and secondary retry --- .../state/synced-client-preferences-model.ts | 39 ++++----- .../state/synced-client-preferences.test.ts | 85 ++++++++++++++++++- .../src/state/synced-client-preferences.ts | 17 +++- apps/web/src/hooks/synced-plan-mode.ts | 52 +++++++++--- .../useSettings.environment-target.test.ts | 39 ++++++++- apps/web/src/hooks/useSettings.test.ts | 46 ++++++++++ 6 files changed, 237 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index c6ca2943f718..f25ecc0aa13f 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -55,28 +55,20 @@ export function createPlanModePreferenceReconciliationKey( readonly preferences: SyncedClientPreferences | undefined; }>, ): string { - return JSON.stringify( - states - .map(({ environmentId, connectionState, shellStatus, preferences }) => { - if (connectionState === "connected" && shellStatus === "live") { - return [ - environmentId, - "live", - preferences?.planModeEnabled, - getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"), - ]; - } - if ( - connectionState === "available" || - connectionState === "error" || - connectionState === "offline" - ) { - return [environmentId, "fallback"]; - } - return [environmentId, "pending"]; - }) - .sort(([left], [right]) => String(left).localeCompare(String(right))), - ); + // Readiness is a monotonic watermark: after the first pass, only a newer live + // preference stamp can close it; connection churn, removals, and fallback states cannot. + return states.reduce((newestUpdatedAt, { connectionState, shellStatus, preferences }) => { + if (connectionState !== "connected" || shellStatus !== "live") return newestUpdatedAt; + const updatedAt = getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"); + return updatedAt !== undefined && updatedAt > newestUpdatedAt ? updatedAt : newestUpdatedAt; + }, ""); +} + +export function advancePlanModePreferenceReconciliationKey( + appliedKey: string | null, + currentKey: string, +): string { + return appliedKey === null || currentKey > appliedKey ? currentKey : appliedKey; } export function isPlanModePreferenceReconciliationReady(input: { @@ -87,7 +79,8 @@ export function isPlanModePreferenceReconciliationReady(input: { }): boolean { return ( input.connectionsLoaded && - (input.environmentCount === 0 || input.appliedKey === input.currentKey) + (input.environmentCount === 0 || + (input.appliedKey !== null && input.currentKey <= input.appliedKey)) ); } diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 6d73d0f63bfa..add52cf7b66f 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -4,6 +4,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; import { + advancePlanModePreferenceReconciliationKey, createPlanModePreferenceReconciliationKey, createPlanModePreferenceReconciliationController, createPlanModePreferenceWrite, @@ -135,6 +136,35 @@ describe("synced client preferences", () => { ).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([ @@ -171,7 +201,7 @@ describe("synced client preferences", () => { ).toBe(true); }); - it("closes device fallback gating when an offline environment starts reconnecting", () => { + it("reconciles a newly live newer preference without blocking reconnect churn", () => { const environment = { environmentId: environmentId("environment-1"), shellStatus: "cached", @@ -186,9 +216,20 @@ describe("synced client preferences", () => { 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).not.toBe(offlineKey); + expect(connectingKey).toBe(offlineKey); expect( isPlanModePreferenceReconciliationReady({ connectionsLoaded: true, @@ -196,7 +237,47 @@ describe("synced client preferences", () => { 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([ + { + environmentId: environmentId("older"), + connectionState: "connected", + shellStatus: "live", + preferences: { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ]); + + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey, + appliedKey: "2026-08-14T12:01:00.000Z", + }), + ).toBe(true); }); it("bounds excessively future-skewed local stamps", () => { diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index c685665fbe3a..e19d18592ac1 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -18,6 +18,7 @@ import { serverEnvironment } from "./server"; import { environmentSession } from "./session"; import { useAtomCommand } from "./use-atom-command"; import { + advancePlanModePreferenceReconciliationKey, createPlanModePreferenceReconciliationKey, createPlanModePreferenceReconciliationController, createPlanModePreferenceWriteController, @@ -87,6 +88,7 @@ export function useSyncedClientPreferences(): void { 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", @@ -116,10 +118,18 @@ export function useSyncedClientPreferences(): void { : undefined; reconciliationController.observe(environmentId, updatedAt); } - if (!connectionsLoaded || states.length === 0) { + 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 }) => ({ @@ -131,7 +141,7 @@ export function useSyncedClientPreferences(): void { 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(reconciliationKey); + setReconciledKey(nextReconciledKey); return; } const reconciliation = reconcilePlanModePreferences({ @@ -159,12 +169,13 @@ export function useSyncedClientPreferences(): void { }), }); } - setReconciledKey(reconciliationKey); + setReconciledKey(nextReconciledKey); }, [ connectionsLoaded, patchPreferences, persistReconciledPreferences, preferencesResult, + reconciledKey, reconciliationKey, reconciliationController, savePreferences, diff --git a/apps/web/src/hooks/synced-plan-mode.ts b/apps/web/src/hooks/synced-plan-mode.ts index 911920016872..0ed0673b6493 100644 --- a/apps/web/src/hooks/synced-plan-mode.ts +++ b/apps/web/src/hooks/synced-plan-mode.ts @@ -114,11 +114,12 @@ export function resolveSyncedPlanModeHydrationAction(input: { } const SYNCED_PLAN_MODE_RETRY_DELAY_MS = 1_000; +const SYNCED_PLAN_MODE_MAX_ATTEMPTS = 3; -type SyncedPlanModeRetryScheduler = (retry: () => void) => () => void; +type SyncedPlanModeRetryScheduler = (retry: () => void, delayMs: number) => () => void; -const scheduleSyncedPlanModeRetry: SyncedPlanModeRetryScheduler = (retry) => { - const timer = setTimeout(retry, SYNCED_PLAN_MODE_RETRY_DELAY_MS); +const scheduleSyncedPlanModeRetry: SyncedPlanModeRetryScheduler = (retry, delayMs) => { + const timer = setTimeout(retry, delayMs); return () => clearTimeout(timer); }; @@ -133,6 +134,7 @@ export function createSyncedPlanModeHydrationController( pendingAdoption?: { readonly value: boolean; readonly updatedAt: string }; readonly synchronizeAgainByOwner: Map void>; cancelRetry?: () => void; + patchAttempt: number; } const imperativeSynchronizationOwner = Symbol(); @@ -140,7 +142,10 @@ export function createSyncedPlanModeHydrationController( const stateFor = (environmentId: EnvironmentId) => { const current = stateByEnvironment.get(environmentId); if (current !== undefined) return current; - const state: SyncedPlanModeEnvironmentState = { synchronizeAgainByOwner: new Map() }; + const state: SyncedPlanModeEnvironmentState = { + synchronizeAgainByOwner: new Map(), + patchAttempt: 0, + }; stateByEnvironment.set(environmentId, state); return state; }; @@ -163,11 +168,18 @@ export function createSyncedPlanModeHydrationController( return latest; }; const requestRetry = (state: SyncedPlanModeEnvironmentState) => { - if (state.cancelRetry !== undefined || getSynchronizeAgain(state) === undefined) return; + if ( + state.patchAttempt >= SYNCED_PLAN_MODE_MAX_ATTEMPTS || + state.cancelRetry !== undefined || + getSynchronizeAgain(state) === undefined + ) { + return; + } + const delayMs = SYNCED_PLAN_MODE_RETRY_DELAY_MS * 2 ** (state.patchAttempt - 1); state.cancelRetry = scheduleRetry(() => { delete state.cancelRetry; getSynchronizeAgain(state)?.(); - }); + }, delayMs); }; const markAdopted = (state: SyncedPlanModeEnvironmentState, updatedAt: string) => { if (state.adoptedUpdatedAt === undefined || updatedAt > state.adoptedUpdatedAt) { @@ -187,9 +199,10 @@ export function createSyncedPlanModeHydrationController( delete state.writeInFlightUpdatedAt; } if (input.result._tag === "Failure") { - if (state.seedPendingUpdatedAt === input.requestedUpdatedAt) { - delete state.seedPendingUpdatedAt; - } + 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; } @@ -204,6 +217,7 @@ export function createSyncedPlanModeHydrationController( if (!matchingWrite && !matchingSeed) return; cancelRetry(state); + state.patchAttempt = 0; if (matchingWrite) delete state.writePending; const resultUpdatedAt = getSyncedClientPreferenceUpdatedAt( input.result.value, @@ -225,7 +239,9 @@ export function createSyncedPlanModeHydrationController( }) => { const { environmentId } = input.target; const requestedUpdatedAt = input.target.input.updatedAt; - stateFor(environmentId).writeInFlightUpdatedAt = requestedUpdatedAt; + const state = stateFor(environmentId); + state.patchAttempt += 1; + state.writeInFlightUpdatedAt = requestedUpdatedAt; void input.patch(input.target).then((result) => { settlePatch({ environmentId, requestedUpdatedAt, result, persist: input.persist }); }); @@ -237,7 +253,7 @@ export function createSyncedPlanModeHydrationController( ) => { const environmentId = input.environmentId; if (environmentId === null) return; - if (environmentId !== input.primaryEnvironmentId || !input.live) { + if (!input.live) { deactivate(environmentId, owner); return; } @@ -279,13 +295,22 @@ export function createSyncedPlanModeHydrationController( delete state.writePending; delete state.writeInFlightUpdatedAt; cancelRetry(state); + state.patchAttempt = 0; } const activePendingWrite = state.writePending; + if ( + state.patchAttempt >= SYNCED_PLAN_MODE_MAX_ATTEMPTS && + state.cancelRetry === undefined && + state.writeInFlightUpdatedAt === undefined + ) { + state.patchAttempt = 0; + } if ( input.canPatch && activePendingWrite !== undefined && (serverUpdatedAt === undefined || serverUpdatedAt < activePendingWrite.updatedAt) && - state.writeInFlightUpdatedAt !== activePendingWrite.updatedAt + state.writeInFlightUpdatedAt !== activePendingWrite.updatedAt && + state.cancelRetry === undefined ) { dispatchPatch({ target: { @@ -299,6 +324,7 @@ export function createSyncedPlanModeHydrationController( persist: input.persist, }); } + if (environmentId !== input.primaryEnvironmentId) return deactivateSynchronization; const action = resolveSyncedPlanModeHydrationAction({ clientHydrated: input.clientHydrated, @@ -345,6 +371,8 @@ export function createSyncedPlanModeHydrationController( if (input.environmentId === null) return; const environmentId = input.environmentId; const state = stateFor(environmentId); + cancelRetry(state); + state.patchAttempt = 0; const controllerUpdatedAt = [ state.adoptedUpdatedAt, state.seedPendingUpdatedAt, diff --git a/apps/web/src/hooks/useSettings.environment-target.test.ts b/apps/web/src/hooks/useSettings.environment-target.test.ts index f6b92ed155c5..6ae229e6503f 100644 --- a/apps/web/src/hooks/useSettings.environment-target.test.ts +++ b/apps/web/src/hooks/useSettings.environment-target.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, type PatchSyncedClientPreferencesRequest } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { reactHookHarness as hooks } from "../test/reactHookHarness"; @@ -114,6 +115,10 @@ describe("useUpdateEnvironmentSettings", () => { testState.sessionEnvironmentIds.length = 0; }); + afterEach(() => { + vi.useRealTimers(); + }); + it("patches synced preferences in the supplied secondary environment", () => { const secondaryEnvironmentId = EnvironmentId.make("secondary"); hooks.beginRender(); @@ -153,4 +158,36 @@ describe("useUpdateEnvironmentSettings", () => { 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 b0a0e318516e..885e92b065f8 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -428,6 +428,52 @@ describe("synced plan mode", () => { controller.reset(); }); + it("bounds automatic patch retries", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const scheduledRetries: Array<{ readonly delayMs: number; readonly run: () => void }> = []; + const controller = createSyncedPlanModeHydrationController((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 SyncedPlanModeHydrationInput; + + 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("does not retry or persist a failed patch after changing primaries", async () => { const previousPrimaryEnvironmentId = EnvironmentId.make("previous-primary"); const nextPrimaryEnvironmentId = EnvironmentId.make("next-primary"); From 74c6466769567ef10cbd07887c2711d4b0e2fffc Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 10:59:52 -0400 Subject: [PATCH 20/42] fix(mobile): deterministic equal-stamp preference tiebreak --- .../state/synced-client-preferences-model.ts | 29 +++++-- .../state/synced-client-preferences.test.ts | 78 +++++++++++++++++++ .../src/state/synced-client-preferences.ts | 15 ++-- 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index f25ecc0aa13f..1f4c71b678a3 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -138,8 +138,16 @@ function localPreferenceUpdatedAt( export function createPlanModePreferenceReconciliationController( scheduleRetry: PlanModePreferenceRetryScheduler = schedulePlanModePreferenceRetry, ) { + const reconciliationKey = (value: boolean | undefined, updatedAt: string | undefined) => + value === undefined || updatedAt === undefined + ? undefined + : `${updatedAt}:${value ? "1" : "0"}`; + const targetReconciliationKey = (target: PlanModePreferencePatchTarget) => + reconciliationKey(target.input.patch.planModeEnabled, target.input.updatedAt); + interface Reconciliation { readonly target: PlanModePreferencePatchTarget; + readonly key: string | undefined; attempt: number; patch: () => Promise; persist: (patch: Partial) => void; @@ -148,7 +156,7 @@ export function createPlanModePreferenceReconciliationController( interface EnvironmentReconciliation { reconciliation?: Reconciliation; - settledUpdatedAt?: string; + settledKey?: string; } const environmentReconciliations = new Map(); @@ -185,7 +193,7 @@ export function createPlanModePreferenceReconciliationController( return; } state.reconciliation = undefined; - state.settledUpdatedAt = reconciliation.target.input.updatedAt; + state.settledKey = reconciliation.key; const localPatch = canonicalPlanModePreferencePatch(preferences); if (localPatch !== null) reconciliation.persist(localPatch); }, @@ -207,9 +215,14 @@ export function createPlanModePreferenceReconciliationController( } } }, - observe(environmentId: EnvironmentId, updatedAt: string | undefined) { + observe( + environmentId: EnvironmentId, + value: boolean | undefined, + updatedAt: string | undefined, + ) { const reconciliation = environmentReconciliations.get(environmentId)?.reconciliation; - if (reconciliation?.target.input.updatedAt === updatedAt) cancel(environmentId); + const observedKey = reconciliationKey(value, updatedAt); + if (observedKey !== undefined && reconciliation?.key === observedKey) cancel(environmentId); }, reconcile(input: { readonly target: PlanModePreferencePatchTarget; @@ -220,11 +233,12 @@ export function createPlanModePreferenceReconciliationController( }) { const { environmentId } = input.target; const state = environmentReconciliations.get(environmentId); - if (state === undefined || state.settledUpdatedAt === input.target.input.updatedAt) { + const key = targetReconciliationKey(input.target); + if (state === undefined || (key !== undefined && state.settledKey === key)) { return; } const current = state.reconciliation; - if (current?.target.input.updatedAt === input.target.input.updatedAt) { + if (key !== undefined && current?.key === key) { current.patch = async () => { const result = await input.patch(input.target); return result._tag === "Success" ? result.value : null; @@ -233,9 +247,10 @@ export function createPlanModePreferenceReconciliationController( return; } if (current !== undefined) cancel(environmentId); - state.settledUpdatedAt = undefined; + state.settledKey = undefined; const reconciliation: Reconciliation = { target: input.target, + key, attempt: 0, patch: async () => { const result = await input.patch(input.target); diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index add52cf7b66f..c47a12d3b44d 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -539,6 +539,84 @@ describe("synced client preferences", () => { 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 = createPlanModePreferenceReconciliationController(); + 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", { diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index e19d18592ac1..01a5724a3682 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -109,14 +109,13 @@ export function useSyncedClientPreferences(): void { liveStates.filter(({ canPatch }) => canPatch).map(({ environmentId }) => environmentId), ); for (const { environmentId, shell } of liveStates) { - const updatedAt = - shell.snapshot._tag === "Some" - ? getSyncedClientPreferenceUpdatedAt( - shell.snapshot.value.syncedClientPreferences, - "planModeEnabled", - ) - : undefined; - reconciliationController.observe(environmentId, updatedAt); + const preferences = + shell.snapshot._tag === "Some" ? shell.snapshot.value.syncedClientPreferences : undefined; + reconciliationController.observe( + environmentId, + preferences?.planModeEnabled, + getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"), + ); } if (!connectionsLoaded) { setReconciledKey(null); From 2a0643219604cc581bd642d2c65a2a395e998003 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 11:31:27 -0400 Subject: [PATCH 21/42] fix(mobile): consistent hydration gate and read-only reconcile --- .../src/features/threads/ThreadComposer.tsx | 34 ++++++++++++------- .../src/features/threads/plan-mode.test.ts | 16 +++++++++ apps/mobile/src/features/threads/plan-mode.ts | 7 ++++ .../threads/thread-composer-send.test.ts | 31 +++++++++++++++++ .../features/threads/thread-composer-send.ts | 12 +++++++ .../state/synced-client-preferences-model.ts | 8 +++-- .../state/synced-client-preferences.test.ts | 20 +++++++++++ 7 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-composer-send.test.ts create mode 100644 apps/mobile/src/features/threads/thread-composer-send.ts diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 15bec52c1f4e..4f058d31587c 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -66,6 +66,7 @@ import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { sendThreadComposerMessage } from "./thread-composer-send"; import { type ExistingThreadSettingsRouteSession, useExistingThreadSettingsRoutePresentation, @@ -75,12 +76,13 @@ import { type NavigationWithFinishTransitioning, } from "./use-thread-settings-sheet-presentation"; import { + canSubmitExistingThreadDraft, getBuiltInComposerSlashCommands, replaceCurrentComposerTrigger, resolveComposerSubmitInteractionMode, resolveSlashCommandInteractionMode, } from "./plan-mode"; -import { usePlanModeEnabled } from "./use-plan-mode-enabled"; +import { usePlanModePreferenceState } from "./use-plan-mode-enabled"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). @@ -294,8 +296,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // Opening and presentation count as active so the composer stays expanded // while focus moves between its native editor and the settings picker. const isExpanded = isFocused || settingsSheetPresentation.isActive; - const canSend = hasContent; - // Notify the parent from the derived value, not focus events: the parent // sizes the feed inset from this, and blur-during-sheet would otherwise // report collapsed while the composer still renders expanded. @@ -336,7 +336,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; - const planModeEnabled = usePlanModeEnabled(); + const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = + usePlanModePreferenceState(); + const canSend = canSubmitExistingThreadDraft({ hasContent, planModePreferenceLoaded }); const connectionStatus = composerConnectionStatus({ connectionError: props.connectionError, connectionState: props.connectionState, @@ -515,6 +517,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer 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({ @@ -532,20 +535,25 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer 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, diff --git a/apps/mobile/src/features/threads/plan-mode.test.ts b/apps/mobile/src/features/threads/plan-mode.test.ts index 78ad9f232045..aeb0c6b760d5 100644 --- a/apps/mobile/src/features/threads/plan-mode.test.ts +++ b/apps/mobile/src/features/threads/plan-mode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + canSubmitExistingThreadDraft, getBuiltInComposerSlashCommands, getPlanModeComposerSlashCommands, replaceCurrentComposerTrigger, @@ -84,6 +85,21 @@ describe("mobile plan mode", () => { }); 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({ diff --git a/apps/mobile/src/features/threads/plan-mode.ts b/apps/mobile/src/features/threads/plan-mode.ts index 1e36addfedca..859cbc469314 100644 --- a/apps/mobile/src/features/threads/plan-mode.ts +++ b/apps/mobile/src/features/threads/plan-mode.ts @@ -123,6 +123,13 @@ export function resolveComposerInteractionMode(input: { : 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; 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/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 1f4c71b678a3..687a8ed75916 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -430,10 +430,14 @@ export function reconcileSyncedClientPreferences(input: { ); for (const field of input.fields ?? SYNCED_CLIENT_PREFERENCE_FIELDS) { + const localValue = input.local.values[field]; + const localUpdatedAt = localPreferenceUpdatedAt(input.local, field); const environmentCandidates = input.environments.flatMap((environment) => { const value = environment.preferences?.[field]; const updatedAt = getSyncedClientPreferenceUpdatedAt(environment.preferences, field); - return value === undefined || updatedAt === undefined + return value === undefined || + updatedAt === undefined || + (environment.canPatch === false && localUpdatedAt !== undefined) ? [] : [{ source: environment.environmentId, value, updatedAt }]; }); @@ -443,8 +447,6 @@ export function reconcileSyncedClientPreferences(input: { ); const latestEnvironment = environmentCandidates.at(-1); const latestObservedEnvironmentUpdatedAt = latestEnvironment?.updatedAt; - const localValue = input.local.values[field]; - const localUpdatedAt = localPreferenceUpdatedAt(input.local, field); const boundedLocalUpdatedAt = hasPatchableEnvironment && localUpdatedAt !== undefined && diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index c47a12d3b44d..253fd759a3ba 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -330,6 +330,26 @@ describe("synced client preferences", () => { }); }); + 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, From 33efe9cff436dd86ee66d089f00664c5a7761f72 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 12:20:37 -0400 Subject: [PATCH 22/42] fix(mobile): stamp preservation and retry budget semantics --- .../state/synced-client-preferences-model.ts | 4 +- .../state/synced-client-preferences.test.ts | 65 ++++++++++++++++++- apps/web/src/hooks/synced-plan-mode.ts | 8 +-- apps/web/src/hooks/useSettings.test.ts | 64 ++++++++++++++++++ 4 files changed, 131 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 687a8ed75916..97935ccba8f5 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -451,7 +451,9 @@ export function reconcileSyncedClientPreferences(input: { hasPatchableEnvironment && localUpdatedAt !== undefined && latestObservedEnvironmentUpdatedAt !== undefined && - localUpdatedAt > latestObservedEnvironmentUpdatedAt + localUpdatedAt > latestObservedEnvironmentUpdatedAt && + Date.parse(localUpdatedAt) > + Date.parse(input.now) + SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS ? nextMobileSyncedPreferencesUpdatedAt([], latestObservedEnvironmentUpdatedAt, [ latestObservedEnvironmentUpdatedAt, ]) diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 253fd759a3ba..674245d18c37 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -822,7 +822,68 @@ describe("synced client preferences", () => { ]); }); - it("bounds a fast device clock just after the newest observed environment stamp", () => { + 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", @@ -836,7 +897,7 @@ describe("synced client preferences", () => { }, }, ], - now: "2099-01-01T00:00:01.000Z", + now: "2026-08-14T12:01:00.000Z", }); expect(reconciliation.localPatch).toEqual({ diff --git a/apps/web/src/hooks/synced-plan-mode.ts b/apps/web/src/hooks/synced-plan-mode.ts index 0ed0673b6493..bb815f49af01 100644 --- a/apps/web/src/hooks/synced-plan-mode.ts +++ b/apps/web/src/hooks/synced-plan-mode.ts @@ -298,16 +298,10 @@ export function createSyncedPlanModeHydrationController( state.patchAttempt = 0; } const activePendingWrite = state.writePending; - if ( - state.patchAttempt >= SYNCED_PLAN_MODE_MAX_ATTEMPTS && - state.cancelRetry === undefined && - state.writeInFlightUpdatedAt === undefined - ) { - state.patchAttempt = 0; - } if ( input.canPatch && activePendingWrite !== undefined && + state.patchAttempt < SYNCED_PLAN_MODE_MAX_ATTEMPTS && (serverUpdatedAt === undefined || serverUpdatedAt < activePendingWrite.updatedAt) && state.writeInFlightUpdatedAt !== activePendingWrite.updatedAt && state.cancelRetry === undefined diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 885e92b065f8..d5f8e8cc8423 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -474,6 +474,70 @@ describe("synced plan mode", () => { controller.reset(); }); + it("resets an exhausted retry budget only for a new write", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const scheduledRetries: Array<() => void> = []; + const controller = createSyncedPlanModeHydrationController((retry) => { + scheduledRetries.push(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: true, + live: true, + serverPreferences: previous, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch, + persist: vi.fn(), + } satisfies SyncedPlanModeHydrationInput; + + controller.synchronize(hydrationInput); + controller.write({ + environmentId: primaryEnvironmentId, + value: true, + 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: false, + 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"); From 4cf411d1453cd8ff0f62a0725dc0c877103df390 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 12:55:57 -0400 Subject: [PATCH 23/42] fix(mobile): value-aware reconciliation watermark --- .../state/synced-client-preferences-model.ts | 88 +++++++++++++++---- .../state/synced-client-preferences.test.ts | 75 +++++++++++++--- 2 files changed, 134 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 97935ccba8f5..276fae581102 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -47,6 +47,44 @@ export interface LocalSyncedClientPreferencesPatch { 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); +} + +type PlanModePreferenceReconciliationIdentity = readonly [ + updatedAt: string, + source: EnvironmentId, + value: boolean, +]; + +function parsePlanModePreferenceReconciliationKey( + key: string, +): PlanModePreferenceReconciliationIdentity | undefined { + return key === "" + ? undefined + : (JSON.parse(key) as unknown as PlanModePreferenceReconciliationIdentity); +} + +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; @@ -55,20 +93,36 @@ export function createPlanModePreferenceReconciliationKey( readonly preferences: SyncedClientPreferences | undefined; }>, ): string { - // Readiness is a monotonic watermark: after the first pass, only a newer live - // preference stamp can close it; connection churn, removals, and fallback states cannot. - return states.reduce((newestUpdatedAt, { connectionState, shellStatus, preferences }) => { - if (connectionState !== "connected" || shellStatus !== "live") return newestUpdatedAt; - const updatedAt = getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"); - return updatedAt !== undefined && updatedAt > newestUpdatedAt ? updatedAt : newestUpdatedAt; - }, ""); + // 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 { - return appliedKey === null || currentKey > appliedKey ? currentKey : appliedKey; + if (appliedKey === null) return currentKey; + return comparePlanModePreferenceReconciliationIdentities( + parsePlanModePreferenceReconciliationKey(currentKey), + parsePlanModePreferenceReconciliationKey(appliedKey), + ) >= 0 + ? currentKey + : appliedKey; } export function isPlanModePreferenceReconciliationReady(input: { @@ -77,11 +131,14 @@ export function isPlanModePreferenceReconciliationReady(input: { readonly currentKey: string; readonly appliedKey: string | null; }): boolean { - return ( - input.connectionsLoaded && - (input.environmentCount === 0 || - (input.appliedKey !== null && input.currentKey <= input.appliedKey)) - ); + 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( @@ -441,10 +498,7 @@ export function reconcileSyncedClientPreferences(input: { ? [] : [{ source: environment.environmentId, value, updatedAt }]; }); - environmentCandidates.sort( - (left, right) => - left.updatedAt.localeCompare(right.updatedAt) || left.source.localeCompare(right.source), - ); + environmentCandidates.sort(compareEnvironmentPreferenceCandidates); const latestEnvironment = environmentCandidates.at(-1); const latestObservedEnvironmentUpdatedAt = latestEnvironment?.updatedAt; const boundedLocalUpdatedAt = diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 674245d18c37..7b76e41e7026 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -18,6 +18,12 @@ import { } 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 () => { @@ -81,12 +87,16 @@ describe("synced client preferences", () => { }); 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: "current", - appliedKey: "current", + currentKey, + appliedKey: currentKey, }), ).toBe(true); }); @@ -259,15 +269,10 @@ describe("synced client preferences", () => { it("keeps gating open for a newly live preference older than the applied watermark", () => { const currentKey = createPlanModePreferenceReconciliationKey([ - { - environmentId: environmentId("older"), - connectionState: "connected", - shellStatus: "live", - preferences: { - planModeEnabled: false, - updatedAt: "2026-08-14T12:00:00.000Z", - }, - }, + livePlanModeEnvironment("older", false, "2026-08-14T12:00:00.000Z"), + ]); + const appliedKey = createPlanModePreferenceReconciliationKey([ + livePlanModeEnvironment("newer", true, "2026-08-14T12:01:00.000Z"), ]); expect( @@ -275,7 +280,53 @@ describe("synced client preferences", () => { connectionsLoaded: true, environmentCount: 1, currentKey, - appliedKey: "2026-08-14T12:01:00.000Z", + 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); }); From f2de8bd788f177a3376a9cdd38ed4c45cf108f9e Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 19:44:15 -0400 Subject: [PATCH 24/42] refactor(mobile): apply anti-slop lint pass --- .../features/threads/NewTaskDraftScreen.tsx | 26 ++++--- .../src/features/threads/new-task-submit.ts | 2 +- .../threads/use-plan-mode-enabled.test.ts | 8 +- .../src/persistence/mobile-preferences.ts | 24 +++--- apps/mobile/src/state/preferences.test.ts | 20 ++--- .../state/synced-client-preferences-model.ts | 78 +++++++++---------- apps/mobile/src/state/thread-outbox-model.ts | 5 +- .../Layers/OrchestrationEngine.ts | 19 ++--- .../Layers/ProjectionPipeline.test.ts | 5 +- .../Layers/ProjectionSnapshotQuery.ts | 49 ++++++++---- ...rojector.synced-client-preferences.test.ts | 2 +- ..._ProjectionSyncedClientPreferences.test.ts | 5 +- apps/web/src/hooks/synced-plan-mode.ts | 22 ++++-- .../useSettings.environment-target.test.ts | 15 ++-- apps/web/src/hooks/useSettings.ts | 17 ++-- packages/client-runtime/src/state/shell.ts | 8 +- 16 files changed, 162 insertions(+), 143 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 272702734904..c58d932be57c 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -861,7 +861,7 @@ export function NewTaskDraftScreen(props: { startInFlightRef.current = true; flow.setSubmitting(true); try { - const result = await createProjectThread({ + let createInput: Parameters[0] = { project: selectedProject, modelSelection, envMode: workspaceMode, @@ -872,17 +872,19 @@ export function NewTaskDraftScreen(props: { interactionMode, initialMessageText, initialAttachments: draft.attachments, - ...(editingPendingTask - ? { - turnMetadata: { - threadId: editingPendingTask.threadId, - commandId: editingPendingTask.commandId, - messageId: editingPendingTask.messageId, - createdAt: editingPendingTask.createdAt, - }, - } - : {}), - }); + }; + 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)) { diff --git a/apps/mobile/src/features/threads/new-task-submit.ts b/apps/mobile/src/features/threads/new-task-submit.ts index d007fd306118..f25c62993764 100644 --- a/apps/mobile/src/features/threads/new-task-submit.ts +++ b/apps/mobile/src/features/threads/new-task-submit.ts @@ -15,7 +15,7 @@ export function resolveNewTaskComposerSelection(input: { readonly draftKey: string | null; readonly promptLength: number; readonly selection: { readonly start: number; readonly end: number }; -}): { readonly start: number; readonly end: number } { +}) { if (input.previousDraftKey !== input.draftKey) { return { start: input.promptLength, end: input.promptLength }; } 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 index d25920f5b249..042211b5813c 100644 --- a/apps/mobile/src/features/threads/use-plan-mode-enabled.test.ts +++ b/apps/mobile/src/features/threads/use-plan-mode-enabled.test.ts @@ -1,10 +1,10 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const testState = vi.hoisted(() => ({ - preferences: null as unknown, - reconciliationReady: false, -})); +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, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 0e5161648e07..2f3aeb1da3d1 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -153,10 +153,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { preferences.syncedClientPreferencesUpdatedAtByField = parsed.syncedClientPreferencesUpdatedAtByField; } - if ( - typeof parsed.syncedClientPreferencesUpdatedAt === "string" && - isSyncedClientPreferencesUpdatedAt(parsed.syncedClientPreferencesUpdatedAt) - ) { + if (isSyncedClientPreferencesUpdatedAt(parsed.syncedClientPreferencesUpdatedAt)) { preferences.syncedClientPreferencesUpdatedAt = parsed.syncedClientPreferencesUpdatedAt; } if (typeof parsed.legacyThreadListEnabled === "boolean") { @@ -345,18 +342,19 @@ export const make = Effect.fn("MobilePreferencesStore.make")(function* () { try: () => transform(current), catch: (cause) => new MobilePreferencesSaveError({ cause }), }); - const next: Preferences = { + let next: Preferences = { ...current, ...patch, - ...(patch.syncedClientPreferencesUpdatedAtByField === undefined - ? {} - : { - syncedClientPreferencesUpdatedAtByField: { - ...current.syncedClientPreferencesUpdatedAtByField, - ...patch.syncedClientPreferencesUpdatedAtByField, - }, - }), }; + 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 ef6469027d30..19596f23e34b 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -360,18 +360,20 @@ describe("mobile preferences state", () => { new MobilePreferencesSaveError({ cause: new Error("write failed") }), ); } - persisted = { + let next = { ...persisted, ...patch, - ...(patch.syncedClientPreferencesUpdatedAtByField === undefined - ? {} - : { - syncedClientPreferencesUpdatedAtByField: { - ...persisted.syncedClientPreferencesUpdatedAtByField, - ...patch.syncedClientPreferencesUpdatedAtByField, - }, - }), }; + if (patch.syncedClientPreferencesUpdatedAtByField !== undefined) { + next = { + ...next, + syncedClientPreferencesUpdatedAtByField: { + ...persisted.syncedClientPreferencesUpdatedAtByField, + ...patch.syncedClientPreferencesUpdatedAtByField, + }, + }; + } + persisted = next; return persisted; }), }); diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 276fae581102..c9b9bee8c09f 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -1,8 +1,8 @@ import { + EnvironmentId, getSyncedClientPreferenceUpdatedAt, nextSyncedClientPreferencesUpdatedAt, SYNCED_CLIENT_PREFERENCE_FIELDS, - type EnvironmentId, type PatchSyncedClientPreferencesRequest, type SyncedClientPreferenceField, type SyncedClientPreferences, @@ -12,6 +12,7 @@ import { 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"; @@ -59,18 +60,21 @@ function compareEnvironmentPreferenceCandidates( return left.updatedAt.localeCompare(right.updatedAt) || left.source.localeCompare(right.source); } -type PlanModePreferenceReconciliationIdentity = readonly [ - updatedAt: string, - source: EnvironmentId, - value: boolean, -]; +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 - : (JSON.parse(key) as unknown as PlanModePreferenceReconciliationIdentity); + return key === "" ? undefined : decodePlanModePreferenceReconciliationIdentity(key); } function comparePlanModePreferenceReconciliationIdentities( @@ -363,10 +367,7 @@ export function createSyncedClientPreferencesWrite(input: { readonly legacyCurrentUpdatedAt?: string; readonly authoritativePreferences?: ReadonlyArray; readonly now: string; -}): { - readonly localPatch: LocalSyncedClientPreferencesPatch; - readonly environmentPatches: ReadonlyArray; -} { +}) { const fields = SYNCED_CLIENT_PREFERENCE_FIELDS.filter( (field) => input.patch[field] !== undefined, ); @@ -398,13 +399,7 @@ export function createPlanModePreferenceWrite(input: { readonly legacyCurrentUpdatedAt?: string; readonly authoritativePreferences?: ReadonlyArray; readonly now: string; -}): { - readonly localPatch: { - readonly planModeEnabled: boolean; - readonly syncedClientPreferencesUpdatedAtByField: SyncedClientPreferencesUpdatedAtByField; - }; - readonly environmentPatches: ReadonlyArray; -} { +}) { const write = createSyncedClientPreferencesWrite({ patch: { planModeEnabled: input.value }, connectedEnvironmentIds: input.connectedEnvironmentIds, @@ -434,12 +429,15 @@ export function createPlanModePreferenceWriteController() { (persistedUpdatedAt !== undefined && persistedUpdatedAt > latestRequestedUpdatedAt) ? persistedUpdatedAt : latestRequestedUpdatedAt; + const currentUpdatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField = { + ...input.currentUpdatedAtByField, + }; + if (currentUpdatedAt !== undefined) { + currentUpdatedAtByField.planModeEnabled = currentUpdatedAt; + } const write = createPlanModePreferenceWrite({ ...input, - currentUpdatedAtByField: { - ...input.currentUpdatedAtByField, - ...(currentUpdatedAt === undefined ? {} : { planModeEnabled: currentUpdatedAt }), - }, + currentUpdatedAtByField, }); latestRequestedUpdatedAt = write.localPatch.syncedClientPreferencesUpdatedAtByField.planModeEnabled; @@ -468,10 +466,7 @@ export function reconcileSyncedClientPreferences(input: { readonly environments: ReadonlyArray; readonly now: string; readonly fields?: ReadonlyArray; -}): { - readonly localPatch: LocalSyncedClientPreferencesPatch | null; - readonly environmentPatches: ReadonlyArray; -} { +}) { if (input.environments.length === 0) { return { localPatch: null, environmentPatches: [] }; } @@ -558,20 +553,21 @@ export function reconcilePlanModePreferences(input: { readonly localUpdatedAt: string | undefined; readonly environments: ReadonlyArray; readonly now: string; -}): { - readonly localPatch: Partial | null; - readonly environmentPatches: ReadonlyArray; -} { - const reconciliation = reconcileSyncedClientPreferences({ - local: { - values: - input.localPlanModeEnabled === undefined - ? {} - : { planModeEnabled: input.localPlanModeEnabled }, - ...(input.localUpdatedAt === undefined +}) { + let local: LocalSyncedClientPreferencesState = { + values: + input.localPlanModeEnabled === undefined ? {} - : { updatedAtByField: { planModeEnabled: input.localUpdatedAt } }), - }, + : { 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"], diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 10e15799cdf1..505ff93c1031 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -106,10 +106,7 @@ export function resolveQueuedThreadSendDecision( message: QueuedThreadMessage, thread: ThreadSettingsSnapshot, readPlanModeEnabled: () => boolean, -): { - readonly settings: ThreadSettingsSnapshot; - readonly readStartTurnInteractionMode: () => ThreadSettingsSnapshot["interactionMode"]; -} { +) { const planModeEnabled = readPlanModeEnabled(); return { settings: resolveQueuedThreadSettings(message, thread, planModeEnabled), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 3a355aa05ddc..a08176e39b73 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,10 +1,4 @@ -import type { - OrchestrationEvent, - OrchestrationReadModel, - ProjectId, - SyncedClientPreferencesAggregateId, - 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"; @@ -59,28 +53,25 @@ interface CommandEnvelope { startedAtMs: number; } -function commandToAggregateRef(command: OrchestrationCommand): { - readonly aggregateKind: "client-preferences" | "project" | "thread"; - readonly aggregateId: SyncedClientPreferencesAggregateId | 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 1f621ddf7716..b9282cfb2345 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -159,8 +159,9 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { `; assert.deepEqual(messageRows, [{ messageId: "message-1", text: "hello" }]); - const syncedPreferenceRows = yield* sql>` - SELECT * FROM projection_synced_client_preferences + const syncedPreferenceRows = yield* sql<{ readonly singletonId: number }>` + SELECT singleton_id AS "singletonId" + FROM projection_synced_client_preferences `; assert.deepEqual(syncedPreferenceRows, []); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3a02a70edc74..22d175e82ffa 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -225,21 +225,38 @@ function mapSyncedClientPreferences( ): SyncedClientPreferences | undefined { return Option.match(row, { onNone: () => undefined, - onSome: (value) => ({ - ...(value.planModeEnabled === null ? {} : { planModeEnabled: value.planModeEnabled !== 0 }), - ...(value.appearanceMode === null ? {} : { appearanceMode: value.appearanceMode }), - ...(value.themeId === null ? {} : { themeId: value.themeId }), - updatedAtByField: { - ...(value.planModeEnabledUpdatedAt === null - ? {} - : { planModeEnabled: value.planModeEnabledUpdatedAt }), - ...(value.appearanceModeUpdatedAt === null - ? {} - : { appearanceMode: value.appearanceModeUpdatedAt }), - ...(value.themeIdUpdatedAt === null ? {} : { themeId: value.themeIdUpdatedAt }), - }, - updatedAt: value.updatedAt, - }), + 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 }; + }, }); } @@ -2348,7 +2365,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { })), ); - const getSyncedClientPreferences: ProjectionSnapshotQueryShape["getSyncedClientPreferences"] = + const getSyncedClientPreferences: ProjectionSnapshotQuery["Service"]["getSyncedClientPreferences"] = () => readSyncedClientPreferences("getSyncedClientPreferences"); const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => diff --git a/apps/server/src/orchestration/projector.synced-client-preferences.test.ts b/apps/server/src/orchestration/projector.synced-client-preferences.test.ts index 47c8d7f075c2..f8541c5020cd 100644 --- a/apps/server/src/orchestration/projector.synced-client-preferences.test.ts +++ b/apps/server/src/orchestration/projector.synced-client-preferences.test.ts @@ -26,7 +26,7 @@ function preferenceEvent(input: { correlationId: null, metadata: {}, payload: { patch: input.patch, updatedAt: input.updatedAt }, - } as OrchestrationEvent; + }; } it.effect("merges synced preferences by field when a later event is globally stale", () => diff --git a/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts index 6245bba73d9f..2db38e2ed07c 100644 --- a/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts +++ b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts @@ -58,8 +58,9 @@ layer("041_ProjectionSyncedClientPreferences", (it) => { const columns = yield* sql<{ readonly name: string }>` PRAGMA table_info(projection_synced_client_preferences) `; - const rows = yield* sql>` - SELECT * FROM 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; diff --git a/apps/web/src/hooks/synced-plan-mode.ts b/apps/web/src/hooks/synced-plan-mode.ts index bb815f49af01..b9bbc6c6bcdb 100644 --- a/apps/web/src/hooks/synced-plan-mode.ts +++ b/apps/web/src/hooks/synced-plan-mode.ts @@ -320,15 +320,20 @@ export function createSyncedPlanModeHydrationController( } if (environmentId !== input.primaryEnvironmentId) return deactivateSynchronization; - const action = resolveSyncedPlanModeHydrationAction({ + let hydrationInput: Parameters[0] = { clientHydrated: input.clientHydrated, clientValue: input.clientValue, serverPreferences: input.serverPreferences, seedPending: state.seedPendingUpdatedAt !== undefined, - ...(activePendingWrite === undefined ? {} : { writePending: activePendingWrite }), - ...(state.adoptedUpdatedAt === undefined ? {} : { adoptedUpdatedAt: state.adoptedUpdatedAt }), now: input.now, - }); + }; + if (activePendingWrite !== undefined) { + hydrationInput = { ...hydrationInput, writePending: activePendingWrite }; + } + if (state.adoptedUpdatedAt !== undefined) { + hydrationInput = { ...hydrationInput, adoptedUpdatedAt: state.adoptedUpdatedAt }; + } + const action = resolveSyncedPlanModeHydrationAction(hydrationInput); if (action.type === "adopt") { if (!input.canPatch) return deactivateSynchronization; markAdopted(state, action.updatedAt); @@ -380,12 +385,15 @@ export function createSyncedPlanModeHydrationController( : latest, undefined, ); - const next = createSyncedPlanModeWrite({ + let writeInput: Parameters[0] = { value: input.value, serverPreferences: input.serverPreferences, - ...(controllerUpdatedAt === undefined ? {} : { pendingUpdatedAt: controllerUpdatedAt }), now: input.now, - }); + }; + if (controllerUpdatedAt !== undefined) { + writeInput = { ...writeInput, pendingUpdatedAt: controllerUpdatedAt }; + } + const next = createSyncedPlanModeWrite(writeInput); state.writePending = { value: input.value, updatedAt: next.request.updatedAt, diff --git a/apps/web/src/hooks/useSettings.environment-target.test.ts b/apps/web/src/hooks/useSettings.environment-target.test.ts index 6ae229e6503f..8585ddab7ca8 100644 --- a/apps/web/src/hooks/useSettings.environment-target.test.ts +++ b/apps/web/src/hooks/useSettings.environment-target.test.ts @@ -6,12 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { reactHookHarness as hooks } from "../test/reactHookHarness"; const testState = vi.hoisted(() => ({ - primaryEnvironmentId: "primary" as EnvironmentId, + primaryEnvironmentIds: new Array(), sessionAtom: Symbol("session"), updateSettingsAtom: Symbol("update-settings"), patchPreferencesAtom: Symbol("patch-preferences"), serverSettingsAtom: Symbol("server-settings"), - sessionEnvironmentIds: [] as EnvironmentId[], + sessionEnvironmentIds: new Array(), updateSettings: vi.fn(), patchPreferences: vi.fn(), setClientSettings: vi.fn(async () => undefined), @@ -25,7 +25,10 @@ vi.mock("react", async (importOriginal) => { useCallback: reactHookHarness.useCallback, useEffect: (effect: () => void | (() => void)) => effect(), useMemo: reactHookHarness.useMemo, - useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => getSnapshot(), + useSyncExternalStore: ( + _subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => Snapshot, + ): Snapshot => getSnapshot(), }; }); @@ -35,7 +38,7 @@ vi.mock("react/compiler-runtime", async () => { }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: unknown) => + useAtomValue: (atom: symbol) => atom === testState.serverSettingsAtom ? {} : atom === testState.sessionAtom @@ -58,7 +61,7 @@ vi.mock("~/localApi", () => ({ })); vi.mock("~/state/environments", () => ({ - usePrimaryEnvironment: () => ({ environmentId: testState.primaryEnvironmentId }), + usePrimaryEnvironment: () => ({ environmentId: testState.primaryEnvironmentIds[0] }), })); vi.mock("~/state/server", () => ({ @@ -112,6 +115,8 @@ describe("useUpdateEnvironmentSettings", () => { }), ); testState.setClientSettings.mockClear(); + testState.primaryEnvironmentIds.length = 0; + testState.primaryEnvironmentIds.push(EnvironmentId.make("primary")); testState.sessionEnvironmentIds.length = 0; }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index e0bae5953cff..b9c221db912f 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -236,17 +236,18 @@ export function mergeEnvironmentSettings( pendingPlanModeWrite !== undefined && (syncedPlanModeUpdatedAt === undefined || pendingPlanModeWrite.updatedAt > syncedPlanModeUpdatedAt); - return { + let merged: UnifiedSettings = { ...serverSettings, ...clientSettings, - ...(!syncedPlanModeCanOverrideClient - ? {} - : pendingPlanModeIsNewer - ? { planModeEnabled: pendingPlanModeWrite.value } - : syncedClientPreferences?.planModeEnabled === undefined - ? {} - : { planModeEnabled: syncedClientPreferences.planModeEnabled }), }; + if (syncedPlanModeCanOverrideClient) { + if (pendingPlanModeIsNewer) { + merged = { ...merged, planModeEnabled: pendingPlanModeWrite.value }; + } else if (syncedClientPreferences?.planModeEnabled !== undefined) { + merged = { ...merged, planModeEnabled: syncedClientPreferences.planModeEnabled }; + } + } + return merged; } function useMergedSettings( diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index a2cd2b25e461..c1d48f635711 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -229,10 +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 { - clientPreferencesStreamItem: true as const, - ...(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 From 0ca623a2b23aca14359b6ae26a10cbb1841fadb9 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 10:28:29 -0400 Subject: [PATCH 25/42] feat(mobile): themes with appearance override and custom import Adds a Color scheme section to mobile Appearance: a System/Light/Dark override and a six-theme picker (T3 Code default plus T3 Chat, Grove, Ocean, Ember, Iris) applied across Uniwind tokens, navigation chrome, markdown, and the native terminal/review/source surfaces. The default theme is byte-for-byte identical to the shipped palette (pinned by a global.css parity test). Supports pasting ThemeFile v1 JSON with the full web-accepted color syntax (hex, named, rgb, hsl, hwb, lab, lch, oklab, oklch, color()) normalized to RN-safe hex, with size/count caps and clean removal fallback. Fixes CSS hex alpha byte order in the Android review/terminal and iOS terminal decoders and ships a scheme-neutral splash. Closes #6405 --- apps/mobile/app.config.ts | 17 +- apps/mobile/global.css | 4 + .../modules/t3reviewdiff/T3ReviewDiffView.kt | 27 +- .../expo/modules/t3terminal/T3TerminalView.kt | 18 +- .../t3-terminal/ios/T3TerminalView.swift | 38 +- .../withIosSplashScreenInlineBackground.cjs | 30 + apps/mobile/src/App.tsx | 99 +- apps/mobile/src/Stack.tsx | 13 +- .../src/features/files/SourceFileSurface.tsx | 28 +- .../files/nativeSourceFileAdapter.test.ts | 53 ++ .../features/files/nativeSourceFileAdapter.ts | 12 + .../src/features/review/ReviewSheet.tsx | 6 +- .../review/nativeReviewDiffAdapter.test.ts | 85 ++ .../review/nativeReviewDiffAdapter.ts | 28 +- .../review/useNativeReviewDiffBridge.ts | 14 +- .../SettingsAppearanceRouteScreen.tsx | 2 + .../AppearancePreferencesProvider.tsx | 183 +++- .../sections/ColorSchemeAppearanceSection.tsx | 289 ++++++ .../terminal/NativeTerminalSurface.tsx | 7 +- .../terminal/ThreadTerminalRouteScreen.tsx | 22 +- .../features/terminal/terminalTheme.test.ts | 90 +- .../src/features/terminal/terminalTheme.ts | 24 +- .../src/features/threads/ThreadFeed.tsx | 110 +-- apps/mobile/src/lib/mobileTheme.test.ts | 314 +++++++ apps/mobile/src/lib/mobileTheme.ts | 823 +++++++++++++++++ apps/mobile/src/lib/mobileThemeFile.test.ts | 259 ++++++ apps/mobile/src/lib/mobileThemeFile.ts | 871 ++++++++++++++++++ apps/mobile/src/lib/splashConfig.test.ts | 17 + apps/mobile/src/lib/splashConfig.ts | 10 + apps/mobile/src/lib/storage.test.ts | 102 ++ .../src/persistence/mobile-preferences.ts | 24 + docs/README.md | 1 + docs/user/appearance.md | 19 + 33 files changed, 3454 insertions(+), 185 deletions(-) create mode 100644 apps/mobile/plugins/withIosSplashScreenInlineBackground.cjs create mode 100644 apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx create mode 100644 apps/mobile/src/lib/mobileTheme.test.ts create mode 100644 apps/mobile/src/lib/mobileTheme.ts create mode 100644 apps/mobile/src/lib/mobileThemeFile.test.ts create mode 100644 apps/mobile/src/lib/mobileThemeFile.ts create mode 100644 apps/mobile/src/lib/splashConfig.test.ts create mode 100644 apps/mobile/src/lib/splashConfig.ts create mode 100644 docs/user/appearance.md 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..51cf2ada4156 --- /dev/null +++ b/apps/mobile/plugins/withIosSplashScreenInlineBackground.cjs @@ -0,0 +1,30 @@ +const { withMod } = require("expo/config-plugins"); + +module.exports = function withIosSplashScreenInlineBackground(config) { + return withMod(config, { + platform: "ios", + mod: "splashScreenStoryboard", + action: async (nextConfig) => { + const document = nextConfig.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 nextConfig; + }, + }); +}; diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 06bd4bc57733..789fca73334a 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"; @@ -57,44 +57,75 @@ function SplashScreenCoordinator() { return null; } -export default function App() { - const colorScheme = useColorScheme(); +function ThemedApp() { + const { effectiveColorScheme } = 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, + colors: { + ...base.colors, + background: navigationBackground, + card: navigationCard, + text: navigationText, + border: navigationBorder, + primary: navigationPrimary, + }, + }; + }, [ + effectiveColorScheme, + navigationBackground, + navigationBorder, + navigationCard, + navigationPrimary, + navigationText, + ]); + + 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 942d9225044e..97497e450f37 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -97,11 +97,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, + headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined, @@ -116,12 +112,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, @@ -514,7 +504,6 @@ export const RootStack = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/files`, options: { ...GLASS_HEADER_OPTIONS, - contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE, title: "Files", }, }), diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 9774130eb2eb..e275267621ab 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,17 @@ 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 ?? undefined)), + [ + nativeSurfaceColors?.accent, + nativeSurfaceColors?.border, + nativeSurfaceColors?.foreground, + nativeSurfaceColors?.mutedForeground, + nativeSurfaceColors?.sheetBackground, + 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..90a0213d3cb3 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts @@ -7,10 +7,22 @@ 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"; +import { + createNativeReviewDiffTheme, + type NativeReviewDiffThemeOverrides, +} from "../review/nativeReviewDiffAdapter"; +import type { TerminalAppearanceScheme } from "../terminal/terminalTheme"; export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; +export function createNativeSourceFileTheme( + scheme: TerminalAppearanceScheme, + overrides?: NativeReviewDiffThemeOverrides, +) { + return createNativeReviewDiffTheme(scheme, overrides); +} + export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle( resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), ); 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..b9146d74fb63 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,12 +118,13 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray>( () => new Set(), ); - const theme = useMemo(() => createNativeReviewDiffTheme(scheme), [scheme]); + const theme = useMemo( + () => createNativeReviewDiffTheme(scheme, nativeSurfaceColors ?? undefined), + [ + nativeSurfaceColors?.accent, + nativeSurfaceColors?.border, + nativeSurfaceColors?.foreground, + nativeSurfaceColors?.mutedForeground, + nativeSurfaceColors?.sheetBackground, + 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/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 7eaafc0e9e65..0ec7a18622b3 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,7 +1,16 @@ -import { createContext, use, useCallback, useEffect, useMemo, type ReactNode } from "react"; +import { + createContext, + use, + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; +import { useColorScheme } from "react-native"; import { Uniwind } from "uniwind"; @@ -12,13 +21,43 @@ import { type AppearancePreferences, type ResolvedAppearance, } from "../../../lib/appearancePreferences"; +import { + resolveColorSchemeOverride, + removeImportedMobileTheme, + resolveMobileNativeSurfaceColors, + resolveMobileThemePreferences, + resolveMobileThemeVariables, + isMobileThemeId, + type MobileAppearanceMode, + type MobileNativeSurfaceColors, + type MobileThemeAppearance, + type MobileThemeId, + type MobileThemePreferences, +} from "../../../lib/mobileTheme"; +import { + addImportedMobileTheme, + importedMobileThemesKey, + parseMobileThemeFileJson, + sanitizeImportedMobileThemes, + type ImportedMobileTheme, +} from "../../../lib/mobileThemeFile"; +import type { Preferences } from "../../../persistence/mobile-preferences"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/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; @@ -30,25 +69,44 @@ 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); +function applyThemeVariables( + themeId: MobileThemeId, + importedThemes: ReadonlyArray, +) { const currentTheme = Uniwind.currentTheme; for (const theme of ["light", "dark"] as const) { if (theme !== currentTheme) { - Uniwind.updateCSSVariables(theme, variables); + Uniwind.updateCSSVariables( + theme, + resolveMobileThemeVariables(themeId, theme, importedThemes), + ); } } + Uniwind.updateCSSVariables( + currentTheme, + resolveMobileThemeVariables(themeId, currentTheme, importedThemes), + ); +} + +/** Injects scaled `--text-*` variables so className text sizes re-resolve live. */ +function applyTextScaleVariables(baseFontSize: number) { + const variables = resolveTextScaleVariables(baseFontSize); + const currentTheme = Uniwind.currentTheme; + + for (const theme of ["light", "dark"] as const) { + if (theme !== currentTheme) Uniwind.updateCSSVariables(theme, variables); + } Uniwind.updateCSSVariables(currentTheme, variables); } export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const systemColorScheme = useColorScheme(); + const [hasAppliedInitialPreferences, setHasAppliedInitialPreferences] = useState(false); const preferences = useMemo( () => resolveAppearancePreferences( @@ -56,15 +114,60 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN ), [preferencesResult], ); - const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; + const importedThemesSource = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.importedThemes + : null; + const importedThemesCacheKey = importedMobileThemesKey(importedThemesSource); + const importedThemes = useMemo( + () => sanitizeImportedMobileThemes(JSON.parse(importedThemesCacheKey) as unknown), + [importedThemesCacheKey], + ); + 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], + ); + + useEffect(() => { + applyThemeVariables(themePreferences.themeId, importedThemes); + }, [importedThemes, themePreferences.themeId]); + + useEffect(() => { + Uniwind.setTheme(resolveColorSchemeOverride(themePreferences.appearanceMode) ?? "system"); + }, [themePreferences.appearanceMode]); useEffect(() => { applyTextScaleVariables(preferences.baseFontSize); + }, [preferences.baseFontSize]); + + useEffect(() => { cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); - }, [preferences]); + }, [preferences.baseFontSize, preferences.terminalFontSize]); + + useEffect(() => { + if (preferencesLoaded) setHasAppliedInitialPreferences(true); + }, [preferencesLoaded]); + + const isReady = preferencesLoaded && hasAppliedInitialPreferences; const updatePreferences = useCallback( - (patch: Partial) => { + (patch: Partial) => { savePreferences(patch); }, [savePreferences], @@ -98,16 +201,76 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [updatePreferences], ); + const setAppearanceMode = useCallback( + (value: MobileAppearanceMode) => { + updatePreferences({ appearanceMode: value }); + }, + [updatePreferences], + ); + + const setThemeId = useCallback( + (value: MobileThemeId) => { + if (!isMobileThemeId(value, importedThemes)) return; + updatePreferences({ themeId: value }); + }, + [importedThemes, updatePreferences], + ); + + const importThemeJson = useCallback( + (source: string) => { + const importedTheme = parseMobileThemeFileJson(source); + const next = addImportedMobileTheme(importedThemes, importedTheme); + updatePreferences({ importedThemes: next, themeId: importedTheme.id }); + }, + [importedThemes, updatePreferences], + ); + + const removeImportedTheme = useCallback( + (removedThemeId: string) => { + const patch = removeImportedMobileTheme( + importedThemes, + removedThemeId, + themePreferences.themeId, + ); + if (patch) updatePreferences(patch); + }, + [importedThemes, themePreferences.themeId, updatePreferences], + ); + 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..a8550114de6e --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx @@ -0,0 +1,289 @@ +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_MODES, + resolveMobileThemePickerOptions, + type MobileAppearanceMode, + type MobileThemePickerOption, +} from "../../../../lib/mobileTheme"; +import { useThemeColor } from "../../../../lib/useThemeColor"; +import { SettingsSection } from "../../components/SettingsSection"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; + +const APPEARANCE_MODE_LABELS: Readonly> = { + system: "System", + light: "Light", + dark: "Dark", +}; + +function chunk(items: readonly T[], size: number): ReadonlyArray> { + return Array.from({ length: Math.ceil(items.length / size) }, (_, index) => + items.slice(index * size, (index + 1) * size), + ); +} + +function ThemePreview(props: { readonly theme: MobileThemePickerOption }) { + return ( + + + + + + + + + ); +} + +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(), and oklch(). + + { + 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( + () => chunk(resolveMobileThemePickerOptions(importedThemes), 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_MODES.map((mode) => { + const selected = appearanceMode === mode; + return ( + setAppearanceMode(mode)} + 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" + } + > + + {APPEARANCE_MODE_LABELS[mode]} + + + ); + })} + + + + + + + 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..a6635f7500d4 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,17 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) [selectedEnvironmentConnection?.environmentLabel], ); - const terminalTheme = getPierreTerminalTheme(appearanceScheme); + const terminalTheme = useMemo( + () => getPierreTerminalTheme(appearanceScheme, nativeSurfaceColors ?? undefined), + [ + appearanceScheme, + nativeSurfaceColors?.border, + nativeSurfaceColors?.mutedForeground, + nativeSurfaceColors?.terminalBackground, + nativeSurfaceColors?.terminalCursor, + nativeSurfaceColors?.terminalForeground, + ], + ); const usesNativeHeaderGlass = Platform.OS === "ios"; const pendingModifier = pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; @@ -1219,6 +1231,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) <> @@ -1248,7 +1262,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..1f572b7c3176 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, +): 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/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index c5edb822ae5b..62b4445eb320 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,15 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { [nativeReviewDiffData.rows], ); const nativeReviewDiffTheme = useMemo( - () => createNativeReviewDiffTheme(appearanceScheme), - [appearanceScheme], + () => createNativeReviewDiffTheme(appearanceScheme, nativeSurfaceColors ?? undefined), + [ + appearanceScheme, + nativeSurfaceColors?.accent, + nativeSurfaceColors?.border, + nativeSurfaceColors?.foreground, + nativeSurfaceColors?.mutedForeground, + nativeSurfaceColors?.sheetBackground, + ], ); const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); const nativeThemeJson = useMemo( diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts new file mode 100644 index 000000000000..b347df8d3bb4 --- /dev/null +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -0,0 +1,314 @@ +import * as NodeFS from "node:fs"; + +import { describe, expect, it } from "vite-plus/test"; + +import { + removeImportedMobileTheme, + resolveColorSchemeOverride, + 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(); + }); + + 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", + }); + }); + + 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("replaces an existing hex alpha for imported colors", () => { + expect(withAlpha("#11223380", "1f")).toBe("#1122331f"); + }); +}); + +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..fe4e8b29fe3b --- /dev/null +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -0,0 +1,823 @@ +import { + type ImportedMobileTheme, + normalizeMobileThemeColorLiteral, + type PortableThemeColorOverrides, +} from "./mobileThemeFile"; + +export const MOBILE_APPEARANCE_MODES = ["system", "light", "dark"] as const; +export type MobileAppearanceMode = (typeof MOBILE_APPEARANCE_MODES)[number]; +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>> +>; + +const T3_CODE_COLORS: Readonly> = { + 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)", + }, +}; + +function normalizeMobileThemeColors(colors: MobileCoreThemeColors): MobileCoreThemeColors { + return Object.fromEntries( + Object.entries(colors).map(([role, color]) => { + const normalized = normalizeMobileThemeColorLiteral(color); + if (!normalized) throw new Error(`Invalid T3 Code fallback color for "${role}": ${color}`); + return [role, normalized]; + }), + ) as unknown as MobileCoreThemeColors; +} + +const NORMALIZED_T3_CODE_COLORS: Readonly> = { + light: normalizeMobileThemeColors(T3_CODE_COLORS.light), + dark: normalizeMobileThemeColors(T3_CODE_COLORS.dark), +}; + +const T3_CHAT_COLORS: MobileThemeVariants = { + 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", + }, +}; + +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>, +): MobileThemeVariants { + return { + light: { ...MANAGED_LIGHT_COLORS, ...light }, + dark: { ...MANAGED_DARK_COLORS, ...dark }, + }; +} + +const THEME_VARIANTS: Readonly< + Record, MobileThemeVariants> +> = { + "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", + }, + ), +}; + +export function isMobileAppearanceMode(value: unknown): value is MobileAppearanceMode { + return MOBILE_APPEARANCE_MODES.some((mode) => mode === value); +} + +export function isMobileBuiltInThemeId(value: unknown): value is MobileBuiltInThemeId { + return MOBILE_THEME_OPTIONS.some((theme) => theme.id === value); +} + +export function isMobileThemeId( + value: unknown, + importedThemes: ReadonlyArray = [], +): value is MobileThemeId { + return ( + isMobileBuiltInThemeId(value) || + (typeof value === "string" && importedThemes.some((theme) => theme.id === value)) + ); +} + +export function resolveMobileThemePreferences( + stored: { readonly appearanceMode?: unknown; readonly themeId?: unknown } | 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; + return { + importedThemes: next, + ...(selectedThemeId === removedThemeId ? { themeId: DEFAULT_MOBILE_THEME_ID } : {}), + }; +} + +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 (themeId === "t3-code") return fallback; + if (isMobileBuiltInThemeId(themeId)) { + const alternateThemeId = themeId as Exclude; + return { ...fallback, ...THEME_VARIANTS[alternateThemeId][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 } = {}; + for (const role of [ + "canvas", + "surface", + "surfaceRaised", + "surfaceOverlay", + "text", + "textMuted", + "border", + "input", + "accent", + "accentForeground", + "secondary", + "secondaryForeground", + "muted", + "mutedForeground", + "placeholder", + "error", + "errorForeground", + "errorSurface", + "messageSurface", + "messageForeground", + "codeBackground", + "codeForeground", + "sidebar", + "sidebarForeground", + "sidebarMutedForeground", + "sidebarControlSurface", + ] as const) { + 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, + }; +} + +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}`); + } + return `${color.slice(0, 7)}${alpha}`; +} + +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 = [], +): Readonly> { + 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..b04133023bfb --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeFile.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + addImportedMobileTheme, + importedMobileThemesKey, + 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.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 theme-variable key stable when unrelated preferences reload themes", () => { + const storedThemes = [themeFile()]; + const reloadedThemes = JSON.parse(JSON.stringify(storedThemes)) as unknown; + + expect(importedMobileThemesKey(reloadedThemes)).toBe(importedMobileThemesKey(storedThemes)); + }); + + 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..ba19e9b13671 --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeFile.ts @@ -0,0 +1,871 @@ +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; +} + +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: Readonly> = { + 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", +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isThemeAppearance(value: unknown): value is MobileThemeAppearance { + return value === "light" || value === "dark"; +} + +function isThemeId(value: unknown): value is string { + return typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value); +} + +function isThemeLabel(value: unknown): value is string { + return typeof value === "string" && 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: unknown): Readonly<{ id: string; label: string }> | undefined { + return isRecord(value) && + typeof value.id === "string" && + /^[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; +} + +export function importedMobileThemesKey(value: unknown): string { + return JSON.stringify(value) ?? "null"; +} + +function clampByte(value: number): number { + return Math.round(Math.min(255, Math.max(0, value))); +} + +function byteToHex(value: number): string { + return clampByte(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 parseAlpha(value: string | undefined): number | null { + if (value === undefined || value.trim() === "") return 1; + const token = value.trim().toLowerCase(); + if (token === "none") return 0; + const percent = token.endsWith("%"); + const number = parseCssNumber(percent ? token.slice(0, -1) : token); + if (number === null) return null; + const alpha = percent ? number / 100 : number; + return alpha >= 0 && alpha <= 1 ? alpha : null; +} + +function parseClampedAlpha(value: string | undefined): number | null { + if (value === undefined) return 1; + const token = value.trim().toLowerCase(); + if (token === "") return null; + if (token === "none") return 0; + const percent = token.endsWith("%"); + const number = parseCssNumber(percent ? token.slice(0, -1) : token); + if (number === null) return null; + return Math.min(1, Math.max(0, percent ? number / 100 : number)); +} + +function parseScaledComponent(value: string, percentageScale: number): number | null { + const token = value.trim().toLowerCase(); + if (token === "none") return 0; + const percent = token.endsWith("%"); + const number = parseCssNumber(percent ? token.slice(0, -1) : token); + if (number === null) return null; + return percent ? (number * percentageScale) / 100 : number; +} + +function parseRgbChannel(value: string): number | null { + const token = value.trim(); + const percent = token.endsWith("%"); + const number = parseCssNumber(percent ? token.slice(0, -1) : token); + if (number === null) return null; + const channel = percent ? (number / 100) * 255 : number; + return channel >= 0 && channel <= 255 ? channel : null; +} + +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 = parseAlpha(parsed.alpha); + return channels.every((channel): channel is number => channel !== null) && alpha !== null + ? rgbaToHex(channels[0], channels[1], channels[2], alpha) + : null; +} + +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 token = value.trim(); + if (!token.endsWith("%")) return null; + const number = parseCssNumber(token.slice(0, -1)); + return number !== null && number >= 0 && number <= 100 ? number / 100 : null; +} + +function parseHslColor(body: string): string | null { + const parsed = splitFunctionalColor(body); + if (!parsed || parsed.channels.length !== 3) return null; + const hue = parseAngle(parsed.channels[0]); + const saturation = parsePercentage(parsed.channels[1]); + const lightness = parsePercentage(parsed.channels[2]); + const alpha = parseAlpha(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: unknown): string | null { + if (typeof value !== "string") return null; + const input = value.trim().toLowerCase(); + const named = Object.prototype.hasOwnProperty.call(NAMED_COLORS, input) + ? NAMED_COLORS[input] + : undefined; + 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]+)\((.*)\)$/); + 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 parseThemeColorOverrides(value: unknown): PortableThemeColorOverrides { + if (!isRecord(value)) throw new Error("Theme colors must be objects."); + + const overrides: Partial> = {}; + for (const [role, color] of Object.entries(value)) { + if (!PORTABLE_THEME_COLOR_ROLE_SET.has(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 as PortableThemeColorRole] = 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: unknown): ImportedMobileTheme { + if (!isRecord(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 (!isRecord(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 (!isRecord(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."); + } + + return { + version: MOBILE_THEME_FILE_VERSION, + id, + name: name.trim(), + appearance, + colors, + ...(Object.keys(variants).length > 0 ? { variants } : {}), + ...(collection ? { collection } : {}), + ...(value.managed === true ? { managed: true } : {}), + }; +} + +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: unknown; + try { + parsed = 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: unknown): 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 72d78974d6f7..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(); @@ -236,6 +238,106 @@ describe("mobile connection storage", () => { }); }); + 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({}); + }); + it("falls back to secure storage when SQLite cannot save preferences", async () => { mocks.setDatabaseFailures(true, true); await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 2f3aeb1da3d1..1e6efba0d608 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -12,6 +12,12 @@ import { type SyncedClientPreferencesUpdatedAtByField as SyncedClientPreferencesUpdatedAtByFieldValue, } from "@t3tools/contracts"; +import { + isMobileAppearanceMode, + isMobileBuiltInThemeId, + 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"; @@ -24,6 +30,9 @@ const isSyncedClientPreferencesUpdatedAtByField = Schema.is( ); export interface Preferences { + readonly appearanceMode?: MobileAppearanceMode; + readonly themeId?: string; + readonly importedThemes?: ReadonlyArray; readonly liveActivitiesEnabled?: boolean; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; @@ -89,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; @@ -106,6 +118,18 @@ function sanitizePreferences(parsed: Preferences): Preferences { legacyThreadListEnabled?: boolean; } = {}; + if (isMobileAppearanceMode(parsed.appearanceMode)) { + preferences.appearanceMode = parsed.appearanceMode; + } + const importedThemes = sanitizeImportedMobileThemes(parsed.importedThemes); + if (importedThemes.length > 0) preferences.importedThemes = importedThemes; + if ( + isMobileBuiltInThemeId(parsed.themeId) || + (typeof parsed.themeId === "string" && + importedThemes.some((theme) => theme.id === parsed.themeId)) + ) { + preferences.themeId = parsed.themeId; + } if (typeof parsed.liveActivitiesEnabled === "boolean") { preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; } diff --git a/docs/README.md b/docs/README.md index 82c684cb6231..8ea1e7b2e7f1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - [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. From 85a118542e0e102ffa36eef2c32e010be3f37faf Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 10:56:40 -0400 Subject: [PATCH 26/42] feat(mobile): sync appearance mode and theme across clients Extends synced client preferences to reconcile and write through appearance mode and selected theme IDs on mobile and web. Keeps custom theme definitions and theme halves local, with a deterministic T3 Code fallback for unavailable IDs. --- apps/mobile/src/Stack.tsx | 6 +- .../AppearancePreferencesProvider.tsx | 25 +- .../state/synced-client-preferences-model.ts | 101 +- .../state/synced-client-preferences.test.ts | 86 +- .../src/state/synced-client-preferences.ts | 142 ++- .../components/settings/SettingsPanels.tsx | 11 +- .../components/settings/ThemeEditorHost.tsx | 4 +- .../hooks/synced-client-preferences.test.ts | 946 +++++++++++++++++ ...n-mode.ts => synced-client-preferences.ts} | 244 +++-- apps/web/src/hooks/useSettings.test.ts | 950 +----------------- apps/web/src/hooks/useSettings.ts | 205 +++- 11 files changed, 1565 insertions(+), 1155 deletions(-) create mode 100644 apps/web/src/hooks/synced-client-preferences.test.ts rename apps/web/src/hooks/{synced-plan-mode.ts => synced-client-preferences.ts} (64%) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 97497e450f37..4db8e167744a 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -71,11 +71,7 @@ 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"; diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 0ec7a18622b3..2a14089df75e 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -43,6 +43,10 @@ import { } from "../../../lib/mobileThemeFile"; import type { Preferences } from "../../../persistence/mobile-preferences"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; +import { + useUpdateAppearanceModePreference, + useUpdateThemePreference, +} from "../../../state/synced-client-preferences"; import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { @@ -105,6 +109,8 @@ function applyTextScaleVariables(baseFontSize: number) { export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const updateAppearanceModePreference = useUpdateAppearanceModePreference(); + const updateThemePreference = useUpdateThemePreference(); const systemColorScheme = useColorScheme(); const [hasAppliedInitialPreferences, setHasAppliedInitialPreferences] = useState(false); const preferences = useMemo( @@ -203,26 +209,27 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN const setAppearanceMode = useCallback( (value: MobileAppearanceMode) => { - updatePreferences({ appearanceMode: value }); + updateAppearanceModePreference(value); }, - [updatePreferences], + [updateAppearanceModePreference], ); const setThemeId = useCallback( (value: MobileThemeId) => { if (!isMobileThemeId(value, importedThemes)) return; - updatePreferences({ themeId: value }); + updateThemePreference(value); }, - [importedThemes, updatePreferences], + [importedThemes, updateThemePreference], ); const importThemeJson = useCallback( (source: string) => { const importedTheme = parseMobileThemeFileJson(source); const next = addImportedMobileTheme(importedThemes, importedTheme); - updatePreferences({ importedThemes: next, themeId: importedTheme.id }); + updatePreferences({ importedThemes: next }); + updateThemePreference(importedTheme.id); }, - [importedThemes, updatePreferences], + [importedThemes, updatePreferences, updateThemePreference], ); const removeImportedTheme = useCallback( @@ -232,9 +239,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN removedThemeId, themePreferences.themeId, ); - if (patch) updatePreferences(patch); + if (!patch) return; + updatePreferences({ importedThemes: patch.importedThemes }); + if (patch.themeId !== undefined) updateThemePreference(patch.themeId); }, - [importedThemes, themePreferences.themeId, updatePreferences], + [importedThemes, themePreferences.themeId, updatePreferences, updateThemePreference], ); const value = useMemo( diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index c9b9bee8c09f..dbaad84f4f7b 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -17,11 +17,14 @@ 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 PLAN_MODE_PREFERENCE_RECONCILIATION_MAX_ATTEMPTS = 3; +const SYNCED_CLIENT_PREFERENCE_RECONCILIATION_MAX_ATTEMPTS = 3; -type PlanModePreferenceRetryScheduler = (retry: () => void, delayMs: number) => () => void; +type SyncedClientPreferenceRetryScheduler = (retry: () => void, delayMs: number) => () => void; -const schedulePlanModePreferenceRetry: PlanModePreferenceRetryScheduler = (retry, delayMs) => { +const scheduleSyncedClientPreferenceRetry: SyncedClientPreferenceRetryScheduler = ( + retry, + delayMs, +) => { const timer = setTimeout(retry, delayMs); return () => clearTimeout(timer); }; @@ -32,7 +35,7 @@ export interface EnvironmentPreferenceState { readonly canPatch?: boolean; } -export interface PlanModePreferencePatchTarget { +export interface SyncedClientPreferencePatchTarget { readonly environmentId: EnvironmentId; readonly input: PatchSyncedClientPreferencesRequest; } @@ -196,18 +199,20 @@ function localPreferenceUpdatedAt( return local.updatedAtByField?.[field] ?? local.legacyUpdatedAt; } -export function createPlanModePreferenceReconciliationController( - scheduleRetry: PlanModePreferenceRetryScheduler = schedulePlanModePreferenceRetry, +export function createSyncedClientPreferenceReconciliationController( + field: SyncedClientPreferenceField, + scheduleRetry: SyncedClientPreferenceRetryScheduler = scheduleSyncedClientPreferenceRetry, ) { - const reconciliationKey = (value: boolean | undefined, updatedAt: string | undefined) => - value === undefined || updatedAt === undefined - ? undefined - : `${updatedAt}:${value ? "1" : "0"}`; - const targetReconciliationKey = (target: PlanModePreferencePatchTarget) => - reconciliationKey(target.input.patch.planModeEnabled, target.input.updatedAt); + 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: PlanModePreferencePatchTarget; + readonly target: SyncedClientPreferencePatchTarget; readonly key: string | undefined; attempt: number; patch: () => Promise; @@ -230,7 +235,7 @@ export function createPlanModePreferenceReconciliationController( const { environmentId } = reconciliation.target; const state = environmentReconciliations.get(environmentId); if (state?.reconciliation !== reconciliation) return; - if (reconciliation.attempt >= PLAN_MODE_PREFERENCE_RECONCILIATION_MAX_ATTEMPTS) { + if (reconciliation.attempt >= SYNCED_CLIENT_PREFERENCE_RECONCILIATION_MAX_ATTEMPTS) { state.reconciliation = undefined; return; } @@ -255,7 +260,7 @@ export function createPlanModePreferenceReconciliationController( } state.reconciliation = undefined; state.settledKey = reconciliation.key; - const localPatch = canonicalPlanModePreferencePatch(preferences); + const localPatch = canonicalSyncedClientPreferencesPatch(preferences, [field]); if (localPatch !== null) reconciliation.persist(localPatch); }, () => settleFailure(reconciliation), @@ -278,7 +283,7 @@ export function createPlanModePreferenceReconciliationController( }, observe( environmentId: EnvironmentId, - value: boolean | undefined, + value: SyncedClientPreferencesPatch[SyncedClientPreferenceField] | undefined, updatedAt: string | undefined, ) { const reconciliation = environmentReconciliations.get(environmentId)?.reconciliation; @@ -286,11 +291,12 @@ export function createPlanModePreferenceReconciliationController( if (observedKey !== undefined && reconciliation?.key === observedKey) cancel(environmentId); }, reconcile(input: { - readonly target: PlanModePreferencePatchTarget; + readonly target: SyncedClientPreferencePatchTarget; readonly patch: ( - target: PlanModePreferencePatchTarget, + target: SyncedClientPreferencePatchTarget, ) => Promise>; readonly persist: (patch: Partial) => void; + readonly normalizeThemeId?: (themeId: string) => string; }) { const { environmentId } = input.target; const state = environmentReconciliations.get(environmentId); @@ -304,7 +310,12 @@ export function createPlanModePreferenceReconciliationController( const result = await input.patch(input.target); return result._tag === "Success" ? result.value : null; }; - current.persist = input.persist; + current.persist = (patch) => + input.persist( + patch.themeId === undefined + ? patch + : { ...patch, themeId: input.normalizeThemeId?.(patch.themeId) ?? patch.themeId }, + ); return; } if (current !== undefined) cancel(environmentId); @@ -317,7 +328,12 @@ export function createPlanModePreferenceReconciliationController( const result = await input.patch(input.target); return result._tag === "Success" ? result.value : null; }, - persist: input.persist, + persist: (patch) => + input.persist( + patch.themeId === undefined + ? patch + : { ...patch, themeId: input.normalizeThemeId?.(patch.themeId) ?? patch.themeId }, + ), }; state.reconciliation = reconciliation; dispatch(reconciliation); @@ -329,18 +345,22 @@ export function createPlanModePreferenceReconciliationController( }; } -export function canonicalPlanModePreferencePatch( +export function canonicalSyncedClientPreferencesPatch( preferences: SyncedClientPreferences, + fields: ReadonlyArray = SYNCED_CLIENT_PREFERENCE_FIELDS, ): Partial | null { - const updatedAt = getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"); - return preferences.planModeEnabled === undefined || updatedAt === undefined + 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 - : { - planModeEnabled: preferences.planModeEnabled, - syncedClientPreferencesUpdatedAtByField: { - planModeEnabled: updatedAt, - }, - }; + : { ...values, syncedClientPreferencesUpdatedAtByField: updatedAtByField }; } export function nextMobileSyncedPreferencesUpdatedAt( @@ -417,13 +437,13 @@ export function createPlanModePreferenceWrite(input: { }; } -export function createPlanModePreferenceWriteController() { +export function createSyncedClientPreferenceWriteController(field: SyncedClientPreferenceField) { let latestRequestedUpdatedAt: string | undefined; let settledRequestedUpdatedAt: string | undefined; return { - create(input: Parameters[0]) { - const persistedUpdatedAt = input.currentUpdatedAtByField?.planModeEnabled; + create(input: Parameters[0]) { + const persistedUpdatedAt = input.currentUpdatedAtByField?.[field]; const currentUpdatedAt = latestRequestedUpdatedAt === undefined || (persistedUpdatedAt !== undefined && persistedUpdatedAt > latestRequestedUpdatedAt) @@ -433,20 +453,20 @@ export function createPlanModePreferenceWriteController() { ...input.currentUpdatedAtByField, }; if (currentUpdatedAt !== undefined) { - currentUpdatedAtByField.planModeEnabled = currentUpdatedAt; + setPreferenceUpdatedAt(currentUpdatedAtByField, field, currentUpdatedAt); } - const write = createPlanModePreferenceWrite({ + const write = createSyncedClientPreferencesWrite({ ...input, currentUpdatedAtByField, }); - latestRequestedUpdatedAt = - write.localPatch.syncedClientPreferencesUpdatedAtByField.planModeEnabled; + latestRequestedUpdatedAt = write.localPatch.updatedAtByField[field]; settledRequestedUpdatedAt = undefined; return write; }, settle(input: { - readonly target: PlanModePreferencePatchTarget; + readonly target: SyncedClientPreferencePatchTarget; readonly result: AtomCommandResult; + readonly normalizeThemeId?: (themeId: string) => string; }): Partial | null { if ( input.target.input.updatedAt !== latestRequestedUpdatedAt || @@ -456,7 +476,10 @@ export function createPlanModePreferenceWriteController() { return null; } settledRequestedUpdatedAt = input.target.input.updatedAt; - return canonicalPlanModePreferencePatch(input.result.value); + const patch = canonicalSyncedClientPreferencesPatch(input.result.value, [field]); + return patch?.themeId === undefined + ? patch + : { ...patch, themeId: input.normalizeThemeId?.(patch.themeId) ?? patch.themeId }; }, }; } @@ -475,7 +498,7 @@ export function reconcileSyncedClientPreferences(input: { const localUpdatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField = { ...input.local.updatedAtByField, }; - const environmentPatches: PlanModePreferencePatchTarget[] = []; + const environmentPatches: SyncedClientPreferencePatchTarget[] = []; let localChanged = false; const hasPatchableEnvironment = input.environments.some( (environment) => environment.canPatch !== false, diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 7b76e41e7026..6e50100ceb7c 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -6,9 +6,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { advancePlanModePreferenceReconciliationKey, createPlanModePreferenceReconciliationKey, - createPlanModePreferenceReconciliationController, createPlanModePreferenceWrite, - createPlanModePreferenceWriteController, + createSyncedClientPreferenceReconciliationController, + createSyncedClientPreferenceWriteController, createSyncedClientPreferencesWrite, hasPlanModePreferenceReconciliationAttempted, isPlanModePreferenceReconciliationReady, @@ -440,22 +440,20 @@ describe("synced client preferences", () => { }); it("ignores a stale toggle ack after a newer same-millisecond write", () => { - const controller = createPlanModePreferenceWriteController(); + const controller = createSyncedClientPreferenceWriteController("planModeEnabled"); const environment = environmentId("environment-1"); const first = controller.create({ - value: true, + patch: { planModeEnabled: true }, connectedEnvironmentIds: [environment], now: "2026-08-14T12:00:00.000Z", }); const second = controller.create({ - value: false, + patch: { planModeEnabled: false }, connectedEnvironmentIds: [environment], now: "2026-08-14T12:00:00.000Z", }); - expect(second.localPatch.syncedClientPreferencesUpdatedAtByField.planModeEnabled).toBe( - "2026-08-14T12:00:00.001Z", - ); + expect(second.localPatch.updatedAtByField.planModeEnabled).toBe("2026-08-14T12:00:00.001Z"); expect( controller.settle({ target: second.environmentPatches[0]!, @@ -464,7 +462,10 @@ describe("synced client preferences", () => { updatedAt: second.environmentPatches[0]!.input.updatedAt, }), }), - ).toEqual(second.localPatch); + ).toEqual({ + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: second.localPatch.updatedAtByField, + }); expect( controller.settle({ target: first.environmentPatches[0]!, @@ -477,30 +478,28 @@ describe("synced client preferences", () => { }); it("advances past a local clock reconciled after the previous write", () => { - const controller = createPlanModePreferenceWriteController(); + const controller = createSyncedClientPreferenceWriteController("planModeEnabled"); const environment = environmentId("environment-1"); controller.create({ - value: true, + patch: { planModeEnabled: true }, connectedEnvironmentIds: [environment], now: "2026-08-14T12:00:00.000Z", }); const write = controller.create({ - value: false, + patch: { planModeEnabled: false }, connectedEnvironmentIds: [environment], currentUpdatedAtByField: { planModeEnabled: "2026-08-14T12:05:00.000Z" }, now: "2026-08-14T12:01:00.000Z", }); - expect(write.localPatch.syncedClientPreferencesUpdatedAtByField.planModeEnabled).toBe( - "2026-08-14T12:05:00.001Z", - ); + 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 = createPlanModePreferenceWriteController(); + const controller = createSyncedClientPreferenceWriteController("planModeEnabled"); const write = controller.create({ - value: true, + patch: { planModeEnabled: true }, connectedEnvironmentIds: [environmentId("environment-1"), environmentId("environment-2")], now: "2026-08-14T12:00:00.000Z", }); @@ -530,6 +529,35 @@ describe("synced client preferences", () => { ).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({ @@ -620,7 +648,7 @@ describe("synced client preferences", () => { environments: [{ environmentId: lowerEnvironmentId, preferences: undefined }], now: updatedAt, }); - const controller = createPlanModePreferenceReconciliationController(); + const controller = createSyncedClientPreferenceReconciliationController("planModeEnabled"); const patch = vi.fn(async (target: (typeof initial.environmentPatches)[number]) => AsyncResult.success({ planModeEnabled: target.input.patch.planModeEnabled, @@ -981,7 +1009,7 @@ describe("synced client preferences", () => { updatedAt: "2026-08-14T12:00:30.000Z", } as const; - const controller = createPlanModePreferenceReconciliationController(); + const controller = createSyncedClientPreferenceReconciliationController("planModeEnabled"); const persist = vi.fn(); controller.setActiveEnvironmentIds([target.environmentId]); controller.reconcile({ @@ -1058,7 +1086,10 @@ describe("synced client preferences", () => { }, } as const; const { schedule, scheduled } = makeRetryScheduler(); - const controller = createPlanModePreferenceReconciliationController(schedule); + const controller = createSyncedClientPreferenceReconciliationController( + "planModeEnabled", + schedule, + ); const patch = vi .fn() .mockResolvedValueOnce(AsyncResult.failure(Cause.fail("offline"))) @@ -1100,7 +1131,10 @@ describe("synced client preferences", () => { }, } as const; const { schedule, scheduled } = makeRetryScheduler(); - const controller = createPlanModePreferenceReconciliationController(schedule); + const controller = createSyncedClientPreferenceReconciliationController( + "planModeEnabled", + schedule, + ); const patch = vi.fn().mockResolvedValue(AsyncResult.failure(Cause.fail("offline"))); controller.setActiveEnvironmentIds([environment]); @@ -1125,7 +1159,10 @@ describe("synced client preferences", () => { }, } as const; const { schedule, scheduled } = makeRetryScheduler(); - const controller = createPlanModePreferenceReconciliationController(schedule); + const controller = createSyncedClientPreferenceReconciliationController( + "planModeEnabled", + schedule, + ); const patch = vi .fn() .mockResolvedValueOnce(AsyncResult.failure(Cause.fail("offline"))) @@ -1173,7 +1210,10 @@ describe("synced client preferences", () => { }, } as const; const { schedule, scheduled } = makeRetryScheduler(); - const controller = createPlanModePreferenceReconciliationController(schedule); + const controller = createSyncedClientPreferenceReconciliationController( + "planModeEnabled", + schedule, + ); const patch = vi.fn().mockResolvedValue(AsyncResult.failure(Cause.fail("offline"))); controller.setActiveEnvironmentIds([environment]); diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index 01a5724a3682..d7ac3b172d8c 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -2,11 +2,20 @@ 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 { environmentShell } from "./shell"; import { environmentPresentations } from "./presentation"; import { @@ -20,11 +29,11 @@ import { useAtomCommand } from "./use-atom-command"; import { advancePlanModePreferenceReconciliationKey, createPlanModePreferenceReconciliationKey, - createPlanModePreferenceReconciliationController, - createPlanModePreferenceWriteController, + createSyncedClientPreferenceReconciliationController, + createSyncedClientPreferenceWriteController, hasPlanModePreferenceReconciliationAttempted, isPlanModePreferenceReconciliationReady, - reconcilePlanModePreferences, + reconcileSyncedClientPreferences, } from "./synced-client-preferences-model"; const connectedEnvironmentPreferenceStatesAtom = Atom.make((get) => { @@ -94,28 +103,47 @@ export function useSyncedClientPreferences(): void { label: "synced client preferences reconciliation", reportFailure: false, }); - const reconciliationController = useMemo( - () => createPlanModePreferenceReconciliationController(), + const reconciliationControllers = useMemo( + () => + Object.fromEntries( + SYNCED_CLIENT_PREFERENCE_FIELDS.map((field) => [ + field, + createSyncedClientPreferenceReconciliationController(field), + ]), + ) as Record< + SyncedClientPreferenceField, + ReturnType + >, [], ); - useEffect(() => () => reconciliationController.reset(), [reconciliationController]); + useEffect( + () => () => { + for (const controller of Object.values(reconciliationControllers)) controller.reset(); + }, + [reconciliationControllers], + ); useEffect(() => { const liveStates = states.filter( ({ connectionState, shell }) => connectionState === "connected" && shell.status === "live", ); - reconciliationController.setActiveEnvironmentIds( - liveStates.filter(({ canPatch }) => canPatch).map(({ environmentId }) => environmentId), - ); + 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; - reconciliationController.observe( - environmentId, - preferences?.planModeEnabled, - getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"), - ); + for (const field of SYNCED_CLIENT_PREFERENCE_FIELDS) { + reconciliationControllers[field].observe( + environmentId, + preferences?.[field], + getSyncedClientPreferenceUpdatedAt(preferences, field), + ); + } } if (!connectionsLoaded) { setReconciledKey(null); @@ -143,11 +171,19 @@ export function useSyncedClientPreferences(): void { setReconciledKey(nextReconciledKey); return; } - const reconciliation = reconcilePlanModePreferences({ - localPlanModeEnabled: preferencesResult.value.planModeEnabled, - localUpdatedAt: - preferencesResult.value.syncedClientPreferencesUpdatedAtByField?.planModeEnabled ?? - preferencesResult.value.syncedClientPreferencesUpdatedAt, + const importedThemes = preferencesResult.value.importedThemes ?? []; + const normalizeThemeId = (themeId: string) => + isMobileThemeId(themeId, importedThemes) ? themeId : DEFAULT_MOBILE_THEME_ID; + 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, @@ -156,16 +192,29 @@ export function useSyncedClientPreferences(): void { })), now: new Date().toISOString(), }); - if (reconciliation.localPatch !== null) savePreferences(reconciliation.localPatch); + if (reconciliation.localPatch !== null) { + savePreferences({ + ...reconciliation.localPatch.values, + ...(reconciliation.localPatch.values.themeId === undefined + ? {} + : { themeId: normalizeThemeId(reconciliation.localPatch.values.themeId) }), + syncedClientPreferencesUpdatedAtByField: reconciliation.localPatch.updatedAtByField, + }); + } for (const target of reconciliation.environmentPatches) { - reconciliationController.reconcile({ + 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: { planModeEnabled: target.input.updatedAt }, + expectedUpdatedAtByField: { [field]: target.input.updatedAt }, patch, }), + normalizeThemeId, }); } setReconciledKey(nextReconciledKey); @@ -176,14 +225,14 @@ export function useSyncedClientPreferences(): void { preferencesResult, reconciledKey, reconciliationKey, - reconciliationController, + reconciliationControllers, savePreferences, setReconciledKey, states, ]); } -export function useUpdatePlanModePreference() { +function useUpdateSyncedClientPreference(field: SyncedClientPreferenceField) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const { connectedEnvironmentIds, states } = useConnectedEnvironmentPreferenceStates(); @@ -191,13 +240,16 @@ export function useUpdatePlanModePreference() { label: "synced client preferences update", reportFailure: false, }); - const writeController = useMemo(() => createPlanModePreferenceWriteController(), []); + const writeController = useMemo( + () => createSyncedClientPreferenceWriteController(field), + [field], + ); return useCallback( - (value: boolean) => { + (patch: SyncedClientPreferencesPatch) => { const current = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {}; const write = writeController.create({ - value, + patch, connectedEnvironmentIds, currentUpdatedAtByField: current.syncedClientPreferencesUpdatedAtByField, legacyCurrentUpdatedAt: current.syncedClientPreferencesUpdatedAt, @@ -206,11 +258,22 @@ export function useUpdatePlanModePreference() { ), now: new Date().toISOString(), }); - savePreferences(write.localPatch); + savePreferences({ + ...write.localPatch.values, + syncedClientPreferencesUpdatedAtByField: write.localPatch.updatedAtByField, + }); void Promise.allSettled( write.environmentPatches.map(async (target) => { const result = await patchPreferences(target); - const localPatch = writeController.settle({ target, result }); + const importedThemes = current.importedThemes ?? []; + const localPatch = writeController.settle({ + target, + result, + normalizeThemeId: (themeId) => + themeId === patch.themeId || isMobileThemeId(themeId, importedThemes) + ? themeId + : DEFAULT_MOBILE_THEME_ID, + }); if (localPatch !== null) savePreferences(localPatch); }), ); @@ -225,3 +288,24 @@ export function useUpdatePlanModePreference() { ], ); } + +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/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..3190003ade4c --- /dev/null +++ b/apps/web/src/hooks/synced-client-preferences.test.ts @@ -0,0 +1,946 @@ +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 { describe, expect, it, vi } from "vite-plus/test"; + +import { + createSyncedClientPreferenceHydrationController, + createSyncedClientPreferenceWrite, + createSyncedClientPreferencesSliceAtom, + createSyncedPlanModeHydrationController, + createSyncedPlanModeWrite, + resolveSyncedClientPreferenceHydrationAction, + resolveSyncedPlanModeHydrationAction, + type SyncedClientPreferenceHydrationInput, + type SyncedPlanModeHydrationInput, +} from "./synced-client-preferences"; + +describe("synced client preferences", () => { + it("adopts an environment value over the local cache", () => { + expect( + resolveSyncedPlanModeHydrationAction({ + 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( + resolveSyncedPlanModeHydrationAction({ + 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( + resolveSyncedPlanModeHydrationAction({ + 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( + createSyncedPlanModeWrite({ + value: false, + serverPreferences: { + planModeEnabled: true, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ + clientPatch: { planModeEnabled: false }, + request: { + patch: { planModeEnabled: false }, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }); + }); + + it("advances writes from the plan clock instead of a newer appearance clock", () => { + expect( + createSyncedPlanModeWrite({ + 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", + }), + ).toMatchObject({ + request: { 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 re-adopt stale server state while a local write is pending", () => { + expect( + resolveSyncedPlanModeHydrationAction({ + 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 = createSyncedPlanModeHydrationController(); + 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 = createSyncedPlanModeHydrationController(); + 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 = createSyncedPlanModeHydrationController(); + 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 = createSyncedPlanModeHydrationController(); + 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 = createSyncedPlanModeHydrationController(); + 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 = createSyncedPlanModeHydrationController((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 SyncedPlanModeHydrationInput; + + 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 = createSyncedPlanModeHydrationController((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 SyncedPlanModeHydrationInput; + 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 = createSyncedPlanModeHydrationController(); + let resolvePatch!: ( + result: Awaited["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 SyncedPlanModeHydrationInput; + 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 () => { + const environmentId = EnvironmentId.make("primary"); + const controller = createSyncedPlanModeHydrationController(); + 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["patch"]>>, + ) => void; + const patch = vi.fn["patch"]>( + () => + new Promise((resolve) => { + resolvePatch = resolve; + }), + ); + let localValue = false; + const persisted: boolean[] = []; + const persist = (value: boolean) => { + localValue = value; + persisted.push(value); + }; + const deactivate = controller.synchronize({ + environmentId, + primaryEnvironmentId: environmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: previous, + canPatch: true, + now: previous.updatedAt, + patch, + persist, + }); + + localValue = true; + controller.write({ + environmentId, + value: localValue, + serverPreferences: previous, + canPatch: true, + now: "2026-08-14T12:02:00.000Z", + patch, + persist, + }); + deactivate?.(); + resolvePatch( + AsyncResult.success({ + planModeEnabled: true, + updatedAt: "2026-08-14T12:02:00.000Z", + }), + ); + await Promise.resolve(); + + expect(persisted).toEqual([]); + + controller.synchronize({ + environmentId, + primaryEnvironmentId: environmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: stale, + canPatch: true, + now: "2026-08-14T12:03:00.000Z", + patch, + persist, + }); + + expect(localValue).toBe(true); + expect(persisted).toEqual([]); + expect(patch).toHaveBeenCalledOnce(); + }); + + it("keeps the latest rapid toggle when responses settle out of order", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const controller = createSyncedPlanModeHydrationController(); + const previous = { + planModeEnabled: false, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const targets: Array["patch"]>[0]> = []; + const resolvePatches: Array< + (result: Awaited["patch"]>>) => void + > = []; + const patch: SyncedPlanModeHydrationInput["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 = createSyncedPlanModeHydrationController(); + const targets: Array["patch"]>[0]> = []; + const resolvePatches: Array< + (result: Awaited["patch"]>>) => void + > = []; + const patch: SyncedPlanModeHydrationInput["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-plan-mode.ts b/apps/web/src/hooks/synced-client-preferences.ts similarity index 64% rename from apps/web/src/hooks/synced-plan-mode.ts rename to apps/web/src/hooks/synced-client-preferences.ts index b9bbc6c6bcdb..4befba49f02d 100644 --- a/apps/web/src/hooks/synced-plan-mode.ts +++ b/apps/web/src/hooks/synced-client-preferences.ts @@ -5,7 +5,9 @@ import { 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"; @@ -23,86 +25,124 @@ export function createSyncedClientPreferencesSliceAtom( }); } -export function createSyncedPlanModeWrite(input: { - readonly value: boolean; +type SyncedClientPreferenceValue = Exclude< + SyncedClientPreferencesPatch[Field], + undefined +>; + +function syncedClientPreferencePatch( + field: Field, + value: SyncedClientPreferenceValue, +): SyncedClientPreferencesPatch { + return { [field]: value } as SyncedClientPreferencesPatch; +} + +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, - "planModeEnabled", - ); + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt(input.serverPreferences, input.field); const currentUpdatedAt = input.pendingUpdatedAt !== undefined && (serverUpdatedAt === undefined || input.pendingUpdatedAt > serverUpdatedAt) ? input.pendingUpdatedAt : serverUpdatedAt; return { - clientPatch: { planModeEnabled: input.value }, request: { - patch: { planModeEnabled: input.value }, + patch: syncedClientPreferencePatch(input.field, input.value), updatedAt: nextSyncedClientPreferencesUpdatedAt([currentUpdatedAt], input.now), }, } as const; } -type SyncedPlanModePatchTarget = { +export function createSyncedPlanModeWrite( + input: Omit>[0], "field">, +) { + return { + clientPatch: { planModeEnabled: input.value }, + ...createSyncedClientPreferenceWrite({ ...input, field: "planModeEnabled" }), + } as const; +} + +type SyncedClientPreferencePatchTarget = { readonly environmentId: EnvironmentId; readonly input: PatchSyncedClientPreferencesRequest; }; -type SyncedPlanModePatch = ( - target: SyncedPlanModePatchTarget, +type SyncedClientPreferencePatch = ( + target: SyncedClientPreferencePatchTarget, ) => Promise>; -export interface SyncedPlanModeHydrationInput { +export interface SyncedClientPreferenceHydrationInput< + Field extends SyncedClientPreferenceField, + E, +> { readonly environmentId: EnvironmentId | null; readonly primaryEnvironmentId: EnvironmentId | null; readonly clientHydrated: boolean; - readonly clientValue: boolean; + readonly clientValue: SyncedClientPreferenceValue; readonly live: boolean; readonly serverPreferences: SyncedClientPreferences | undefined; readonly canPatch: boolean; readonly now: string; - readonly patch: SyncedPlanModePatch; - readonly persist: (value: boolean) => void; + readonly patch: SyncedClientPreferencePatch; + readonly persist: (value: SyncedClientPreferenceValue) => void; } -export type SyncedPlanModeHydrationAction = +export type SyncedClientPreferenceHydrationAction = | { readonly type: "none" } - | { readonly type: "adopt"; readonly value: boolean; readonly updatedAt: string } - | { readonly type: "seed"; readonly value: boolean; readonly updatedAt: string }; + | { + readonly type: "adopt"; + readonly value: SyncedClientPreferenceValue; + readonly updatedAt: string; + } + | { + readonly type: "seed"; + readonly value: SyncedClientPreferenceValue; + readonly updatedAt: string; + }; -export function resolveSyncedPlanModeHydrationAction(input: { +export function resolveSyncedClientPreferenceHydrationAction< + Field extends SyncedClientPreferenceField, +>(input: { + readonly field: Field; readonly clientHydrated: boolean; - readonly clientValue: boolean; + readonly clientValue: SyncedClientPreferenceValue; readonly serverPreferences: SyncedClientPreferences | undefined; readonly seedPending: boolean; - readonly writePending?: { readonly value: boolean; readonly updatedAt: string }; + readonly writePending?: { + readonly value: SyncedClientPreferenceValue; + readonly updatedAt: string; + }; readonly adoptedUpdatedAt?: string; readonly now: string; -}): SyncedPlanModeHydrationAction { +}): SyncedClientPreferenceHydrationAction { if (!input.clientHydrated) return { type: "none" }; - const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( - input.serverPreferences, - "planModeEnabled", - ); + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt(input.serverPreferences, input.field); if ( input.writePending !== undefined && (serverUpdatedAt === undefined || serverUpdatedAt < input.writePending.updatedAt) ) { return { type: "none" }; } - if (input.serverPreferences?.planModeEnabled !== undefined) { + const serverPreferences = input.serverPreferences; + const serverValue = serverPreferences?.[input.field] as + | SyncedClientPreferenceValue + | undefined; + if (serverValue !== undefined) { return input.adoptedUpdatedAt !== undefined && serverUpdatedAt !== undefined && serverUpdatedAt <= input.adoptedUpdatedAt ? { type: "none" } : { type: "adopt", - value: input.serverPreferences.planModeEnabled, - updatedAt: serverUpdatedAt ?? input.serverPreferences.updatedAt, + value: serverValue, + updatedAt: serverUpdatedAt ?? serverPreferences!.updatedAt, }; } if (input.seedPending) return { type: "none" }; @@ -113,43 +153,74 @@ export function resolveSyncedPlanModeHydrationAction(input: { }; } -const SYNCED_PLAN_MODE_RETRY_DELAY_MS = 1_000; -const SYNCED_PLAN_MODE_MAX_ATTEMPTS = 3; +export type SyncedPlanModeHydrationInput = SyncedClientPreferenceHydrationInput< + "planModeEnabled", + E +>; +export type SyncedPlanModeHydrationAction = + SyncedClientPreferenceHydrationAction<"planModeEnabled">; -type SyncedPlanModeRetryScheduler = (retry: () => void, delayMs: number) => () => void; +export function resolveSyncedPlanModeHydrationAction( + input: Omit< + Parameters>[0], + "field" + >, +): SyncedPlanModeHydrationAction { + return resolveSyncedClientPreferenceHydrationAction({ + ...input, + field: "planModeEnabled", + }); +} + +const SYNCED_CLIENT_PREFERENCE_RETRY_DELAY_MS = 1_000; +const SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS = 3; -const scheduleSyncedPlanModeRetry: SyncedPlanModeRetryScheduler = (retry, delayMs) => { +type SyncedClientPreferenceRetryScheduler = (retry: () => void, delayMs: number) => () => void; + +const scheduleSyncedClientPreferenceRetry: SyncedClientPreferenceRetryScheduler = ( + retry, + delayMs, +) => { const timer = setTimeout(retry, delayMs); return () => clearTimeout(timer); }; -export function createSyncedPlanModeHydrationController( - scheduleRetry: SyncedPlanModeRetryScheduler = scheduleSyncedPlanModeRetry, +export function createSyncedClientPreferenceHydrationController< + Field extends SyncedClientPreferenceField, +>( + field: Field, + scheduleRetry: SyncedClientPreferenceRetryScheduler = scheduleSyncedClientPreferenceRetry, ) { - interface SyncedPlanModeEnvironmentState { + interface SyncedClientPreferenceEnvironmentState { adoptedUpdatedAt?: string; seedPendingUpdatedAt?: string; - writePending?: { readonly value: boolean; readonly updatedAt: string }; + writePending?: { + readonly value: SyncedClientPreferenceValue; + readonly updatedAt: string; + }; writeInFlightUpdatedAt?: string; - pendingAdoption?: { readonly value: boolean; readonly updatedAt: string }; + pendingAdoption?: { + readonly value: SyncedClientPreferenceValue; + readonly updatedAt: string; + }; readonly synchronizeAgainByOwner: Map void>; cancelRetry?: () => void; patchAttempt: number; } const imperativeSynchronizationOwner = Symbol(); - const stateByEnvironment = new Map(); + const stateByEnvironment = new Map(); const stateFor = (environmentId: EnvironmentId) => { const current = stateByEnvironment.get(environmentId); if (current !== undefined) return current; - const state: SyncedPlanModeEnvironmentState = { + const state: SyncedClientPreferenceEnvironmentState = { synchronizeAgainByOwner: new Map(), patchAttempt: 0, }; stateByEnvironment.set(environmentId, state); return state; }; - const cancelRetry = (state: SyncedPlanModeEnvironmentState) => { + const cancelRetry = (state: SyncedClientPreferenceEnvironmentState) => { state.cancelRetry?.(); delete state.cancelRetry; }; @@ -160,28 +231,28 @@ export function createSyncedPlanModeHydrationController( if (state.synchronizeAgainByOwner.size > 0) return; cancelRetry(state); }; - const getSynchronizeAgain = (state: SyncedPlanModeEnvironmentState) => { + const getSynchronizeAgain = (state: SyncedClientPreferenceEnvironmentState) => { let latest: (() => void) | undefined; for (const synchronizeAgain of state.synchronizeAgainByOwner.values()) { latest = synchronizeAgain; } return latest; }; - const requestRetry = (state: SyncedPlanModeEnvironmentState) => { + const requestRetry = (state: SyncedClientPreferenceEnvironmentState) => { if ( - state.patchAttempt >= SYNCED_PLAN_MODE_MAX_ATTEMPTS || + state.patchAttempt >= SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS || state.cancelRetry !== undefined || getSynchronizeAgain(state) === undefined ) { return; } - const delayMs = SYNCED_PLAN_MODE_RETRY_DELAY_MS * 2 ** (state.patchAttempt - 1); + const delayMs = SYNCED_CLIENT_PREFERENCE_RETRY_DELAY_MS * 2 ** (state.patchAttempt - 1); state.cancelRetry = scheduleRetry(() => { delete state.cancelRetry; getSynchronizeAgain(state)?.(); }, delayMs); }; - const markAdopted = (state: SyncedPlanModeEnvironmentState, updatedAt: string) => { + const markAdopted = (state: SyncedClientPreferenceEnvironmentState, updatedAt: string) => { if (state.adoptedUpdatedAt === undefined || updatedAt > state.adoptedUpdatedAt) { state.adoptedUpdatedAt = updatedAt; } @@ -191,7 +262,7 @@ export function createSyncedPlanModeHydrationController( readonly environmentId: EnvironmentId; readonly requestedUpdatedAt: string; readonly result: AtomCommandResult; - readonly persist: (value: boolean) => void; + readonly persist: (value: SyncedClientPreferenceValue) => void; }) => { const state = stateByEnvironment.get(input.environmentId); if (state === undefined) return; @@ -219,11 +290,8 @@ export function createSyncedPlanModeHydrationController( cancelRetry(state); state.patchAttempt = 0; if (matchingWrite) delete state.writePending; - const resultUpdatedAt = getSyncedClientPreferenceUpdatedAt( - input.result.value, - "planModeEnabled", - ); - const resultValue = input.result.value.planModeEnabled; + const resultUpdatedAt = getSyncedClientPreferenceUpdatedAt(input.result.value, field); + const resultValue = input.result.value[field] as SyncedClientPreferenceValue | undefined; if (resultUpdatedAt !== undefined && resultValue !== undefined) { state.pendingAdoption = { value: resultValue, updatedAt: resultUpdatedAt }; } @@ -233,9 +301,9 @@ export function createSyncedPlanModeHydrationController( }; const dispatchPatch = (input: { - readonly target: SyncedPlanModePatchTarget; - readonly patch: SyncedPlanModePatch; - readonly persist: (value: boolean) => void; + readonly target: SyncedClientPreferencePatchTarget; + readonly patch: SyncedClientPreferencePatch; + readonly persist: (value: SyncedClientPreferenceValue) => void; }) => { const { environmentId } = input.target; const requestedUpdatedAt = input.target.input.updatedAt; @@ -248,7 +316,7 @@ export function createSyncedPlanModeHydrationController( }; const synchronize = ( - input: SyncedPlanModeHydrationInput, + input: SyncedClientPreferenceHydrationInput, owner = imperativeSynchronizationOwner, ) => { const environmentId = input.environmentId; @@ -260,14 +328,11 @@ export function createSyncedPlanModeHydrationController( const state = stateFor(environmentId); state.synchronizeAgainByOwner.set(owner, () => synchronize(input, owner)); const deactivateSynchronization = () => deactivate(environmentId, owner); - if (input.serverPreferences?.planModeEnabled !== undefined) { + if (input.serverPreferences?.[field] !== undefined) { delete state.seedPendingUpdatedAt; } const pendingWrite = state.writePending; - const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( - input.serverPreferences, - "planModeEnabled", - ); + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt(input.serverPreferences, field); if ( state.pendingAdoption !== undefined && serverUpdatedAt !== undefined && @@ -301,7 +366,7 @@ export function createSyncedPlanModeHydrationController( if ( input.canPatch && activePendingWrite !== undefined && - state.patchAttempt < SYNCED_PLAN_MODE_MAX_ATTEMPTS && + state.patchAttempt < SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS && (serverUpdatedAt === undefined || serverUpdatedAt < activePendingWrite.updatedAt) && state.writeInFlightUpdatedAt !== activePendingWrite.updatedAt && state.cancelRetry === undefined @@ -310,7 +375,7 @@ export function createSyncedPlanModeHydrationController( target: { environmentId, input: { - patch: { planModeEnabled: activePendingWrite.value }, + patch: syncedClientPreferencePatch(field, activePendingWrite.value), updatedAt: activePendingWrite.updatedAt, }, }, @@ -320,20 +385,22 @@ export function createSyncedPlanModeHydrationController( } if (environmentId !== input.primaryEnvironmentId) return deactivateSynchronization; - let hydrationInput: Parameters[0] = { - clientHydrated: input.clientHydrated, - clientValue: input.clientValue, - serverPreferences: input.serverPreferences, - seedPending: state.seedPendingUpdatedAt !== undefined, - now: input.now, - }; + 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 = resolveSyncedPlanModeHydrationAction(hydrationInput); + const action = resolveSyncedClientPreferenceHydrationAction(hydrationInput); if (action.type === "adopt") { if (!input.canPatch) return deactivateSynchronization; markAdopted(state, action.updatedAt); @@ -347,7 +414,7 @@ export function createSyncedPlanModeHydrationController( target: { environmentId, input: { - patch: { planModeEnabled: action.value }, + patch: syncedClientPreferencePatch(field, action.value), updatedAt: action.updatedAt, }, }, @@ -360,12 +427,12 @@ export function createSyncedPlanModeHydrationController( const write = (input: { readonly environmentId: EnvironmentId | null; - readonly value: boolean; + readonly value: SyncedClientPreferenceValue; readonly serverPreferences: SyncedClientPreferences | undefined; readonly canPatch: boolean; readonly now: string; - readonly patch: SyncedPlanModePatch; - readonly persist: (value: boolean) => void; + readonly patch: SyncedClientPreferencePatch; + readonly persist: (value: SyncedClientPreferenceValue) => void; }) => { if (input.environmentId === null) return; const environmentId = input.environmentId; @@ -385,7 +452,8 @@ export function createSyncedPlanModeHydrationController( : latest, undefined, ); - let writeInput: Parameters[0] = { + let writeInput: Parameters>[0] = { + field, value: input.value, serverPreferences: input.serverPreferences, now: input.now, @@ -393,7 +461,7 @@ export function createSyncedPlanModeHydrationController( if (controllerUpdatedAt !== undefined) { writeInput = { ...writeInput, pendingUpdatedAt: controllerUpdatedAt }; } - const next = createSyncedPlanModeWrite(writeInput); + const next = createSyncedClientPreferenceWrite(writeInput); state.writePending = { value: input.value, updatedAt: next.request.updatedAt, @@ -425,9 +493,18 @@ export function createSyncedPlanModeHydrationController( }; } -export function useSyncedPlanModeHydrationEffect( - controller: ReturnType, - input: SyncedPlanModeHydrationInput, +export function createSyncedPlanModeHydrationController( + scheduleRetry?: SyncedClientPreferenceRetryScheduler, +) { + return createSyncedClientPreferenceHydrationController("planModeEnabled", scheduleRetry); +} + +export function useSyncedClientPreferenceHydrationEffect< + Field extends SyncedClientPreferenceField, + E, +>( + controller: ReturnType>, + input: SyncedClientPreferenceHydrationInput, ): void { const synchronizationOwner = useMemo(() => Symbol(), [controller]); useEffect( @@ -447,3 +524,10 @@ export function useSyncedPlanModeHydrationEffect( ], ); } + +export function useSyncedPlanModeHydrationEffect( + controller: ReturnType, + input: SyncedPlanModeHydrationInput, +): void { + useSyncedClientPreferenceHydrationEffect(controller, input); +} diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index d5f8e8cc8423..0ac7fa98c458 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -1,961 +1,13 @@ import { DEFAULT_SERVER_SETTINGS, - EnvironmentId, ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; -import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -import * as Option from "effect/Option"; -import * as Cause from "effect/Cause"; -import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; -import { - createSyncedClientPreferencesSliceAtom, - createSyncedPlanModeHydrationController, - createSyncedPlanModeWrite, - resolveSyncedPlanModeHydrationAction, - type SyncedPlanModeHydrationInput, -} from "./synced-plan-mode"; import { mergeEnvironmentSettings, resolveEnvironmentIdentificationMode } from "./useSettings"; -describe("synced plan mode", () => { - it("adopts an environment value over the local cache", () => { - expect( - resolveSyncedPlanModeHydrationAction({ - 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( - resolveSyncedPlanModeHydrationAction({ - 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( - resolveSyncedPlanModeHydrationAction({ - 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( - createSyncedPlanModeWrite({ - value: false, - serverPreferences: { - planModeEnabled: true, - updatedAt: "2026-08-14T12:00:00.000Z", - }, - now: "2026-08-14T12:01:00.000Z", - }), - ).toEqual({ - clientPatch: { planModeEnabled: false }, - request: { - patch: { planModeEnabled: false }, - updatedAt: "2026-08-14T12:01:00.000Z", - }, - }); - }); - - it("advances writes from the plan clock instead of a newer appearance clock", () => { - expect( - createSyncedPlanModeWrite({ - 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", - }), - ).toMatchObject({ - request: { updatedAt: "2026-08-14T12:30:00.000Z" }, - }); - }); - - it("does not re-adopt stale server state while a local write is pending", () => { - expect( - resolveSyncedPlanModeHydrationAction({ - 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 = createSyncedPlanModeHydrationController(); - 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 = createSyncedPlanModeHydrationController(); - 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 = createSyncedPlanModeHydrationController(); - 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 = createSyncedPlanModeHydrationController(); - 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 = createSyncedPlanModeHydrationController(); - 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 = createSyncedPlanModeHydrationController((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 SyncedPlanModeHydrationInput; - - 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 = createSyncedPlanModeHydrationController((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 SyncedPlanModeHydrationInput; - - 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 write", async () => { - const primaryEnvironmentId = EnvironmentId.make("primary"); - const scheduledRetries: Array<() => void> = []; - const controller = createSyncedPlanModeHydrationController((retry) => { - scheduledRetries.push(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: true, - live: true, - serverPreferences: previous, - canPatch: true, - now: "2026-08-14T12:01:00.000Z", - patch, - persist: vi.fn(), - } satisfies SyncedPlanModeHydrationInput; - - controller.synchronize(hydrationInput); - controller.write({ - environmentId: primaryEnvironmentId, - value: true, - 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: false, - 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 = createSyncedPlanModeHydrationController((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 SyncedPlanModeHydrationInput; - 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 = createSyncedPlanModeHydrationController(); - let resolvePatch!: ( - result: Awaited["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 SyncedPlanModeHydrationInput; - 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 () => { - const environmentId = EnvironmentId.make("primary"); - const controller = createSyncedPlanModeHydrationController(); - 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["patch"]>>, - ) => void; - const patch = vi.fn["patch"]>( - () => - new Promise((resolve) => { - resolvePatch = resolve; - }), - ); - let localValue = false; - const persisted: boolean[] = []; - const persist = (value: boolean) => { - localValue = value; - persisted.push(value); - }; - const deactivate = controller.synchronize({ - environmentId, - primaryEnvironmentId: environmentId, - clientHydrated: true, - clientValue: localValue, - live: true, - serverPreferences: previous, - canPatch: true, - now: previous.updatedAt, - patch, - persist, - }); - - localValue = true; - controller.write({ - environmentId, - value: localValue, - serverPreferences: previous, - canPatch: true, - now: "2026-08-14T12:02:00.000Z", - patch, - persist, - }); - deactivate?.(); - resolvePatch( - AsyncResult.success({ - planModeEnabled: true, - updatedAt: "2026-08-14T12:02:00.000Z", - }), - ); - await Promise.resolve(); - - expect(persisted).toEqual([]); - - controller.synchronize({ - environmentId, - primaryEnvironmentId: environmentId, - clientHydrated: true, - clientValue: localValue, - live: true, - serverPreferences: stale, - canPatch: true, - now: "2026-08-14T12:03:00.000Z", - patch, - persist, - }); - - expect(localValue).toBe(true); - expect(persisted).toEqual([]); - expect(patch).toHaveBeenCalledOnce(); - }); - - it("adopts a canonical patch response after synchronization resumes", async () => { - const environmentId = EnvironmentId.make("primary"); - const controller = createSyncedPlanModeHydrationController(); - const previous = { - planModeEnabled: false, - updatedAt: "2026-08-14T12:00:00.000Z", - } as const; - let resolvePatch!: ( - result: Awaited["patch"]>>, - ) => void; - const patch = vi.fn["patch"]>( - () => - new Promise((resolve) => { - resolvePatch = resolve; - }), - ); - let localValue = false; - const persisted: boolean[] = []; - const persist = (value: boolean) => { - localValue = value; - persisted.push(value); - }; - const input = { - environmentId, - primaryEnvironmentId: environmentId, - clientHydrated: true, - clientValue: localValue, - live: true, - serverPreferences: previous, - canPatch: true, - now: previous.updatedAt, - patch, - persist, - } satisfies SyncedPlanModeHydrationInput; - const deactivate = controller.synchronize(input); - - localValue = true; - controller.write({ - ...input, - value: localValue, - now: "2026-08-14T12:01:00.000Z", - }); - deactivate?.(); - resolvePatch( - AsyncResult.success({ - planModeEnabled: false, - updatedAt: "2026-08-14T12:02:00.000Z", - }), - ); - await Promise.resolve(); - - expect(persisted).toEqual([]); - - controller.synchronize({ ...input, clientValue: localValue, now: "2026-08-14T12:03:00.000Z" }); - - expect(localValue).toBe(false); - expect(persisted).toEqual([false]); - expect(patch).toHaveBeenCalledOnce(); - }); - - it("keeps the latest rapid toggle when responses settle out of order", async () => { - const primaryEnvironmentId = EnvironmentId.make("primary"); - const controller = createSyncedPlanModeHydrationController(); - const previous = { - planModeEnabled: false, - updatedAt: "2026-08-14T12:00:00.000Z", - } as const; - const targets: Array["patch"]>[0]> = []; - const resolvePatches: Array< - (result: Awaited["patch"]>>) => void - > = []; - const patch: SyncedPlanModeHydrationInput["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 = createSyncedPlanModeHydrationController(); - const targets: Array["patch"]>[0]> = []; - const resolvePatches: Array< - (result: Awaited["patch"]>>) => void - > = []; - const patch: SyncedPlanModeHydrationInput["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(); - }); -}); - describe("resolveEnvironmentIdentificationMode", () => { it("keeps identification hidden until client settings hydrate", () => { expect(resolveEnvironmentIdentificationMode({ mode: "artwork", settingsHydrated: false })).toBe( diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index b9c221db912f..6fad66576168 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -19,6 +19,7 @@ import { ServerSettings, type ServerSettingsPatch, type SyncedClientPreferences, + type SyncedClientPreferencesPatch, } from "@t3tools/contracts"; import { type ClientSettingsPatch, @@ -30,11 +31,16 @@ import { import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { ensureLocalApi } from "~/localApi"; import { + canonicalThemePreference, + getThemePreferenceMode, getThemeDefinition, getThemePreviewSidebarArtwork, + isKnownThemePreference, resolveThemeHalf, subscribeToThemePreview, themeAllowsSidebarArtwork, + type ThemePreference, + type ThemePreferenceMode, } from "~/themePalette"; import * as Struct from "effect/Struct"; import { Atom } from "effect/unstable/reactivity"; @@ -45,10 +51,10 @@ import { environmentShell } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; import { SHELL_NOT_LIVE, + createSyncedClientPreferenceHydrationController, createSyncedClientPreferencesSliceAtom, - createSyncedPlanModeHydrationController, - useSyncedPlanModeHydrationEffect, -} from "./synced-plan-mode"; + useSyncedClientPreferenceHydrationEffect, +} from "./synced-client-preferences"; import { useTheme } from "./useTheme"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -280,7 +286,33 @@ function useMergedSettings( return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); } -const syncedPlanModeHydrationController = createSyncedPlanModeHydrationController(); +export const DEFAULT_SYNCED_THEME_ID = "t3-code"; + +export function syncedThemeIdFromPreference(theme: ThemePreference): string { + return theme === "system" || theme === "light" || theme === "dark" + ? DEFAULT_SYNCED_THEME_ID + : canonicalThemePreference(theme); +} + +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( @@ -306,8 +338,43 @@ function useCanPatchSyncedClientPreferences(environmentId: EnvironmentId | null) ); } -function useSyncedPlanModeHydration(environmentId: EnvironmentId | null) { +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); @@ -316,23 +383,31 @@ function useSyncedPlanModeHydration(environmentId: EnvironmentId | null) { label: "synced client preferences seed", reportFailure: false, }); - const persistSyncedPlanMode = useCallback((value: boolean) => { - if (getClientSettingsSnapshot().planModeEnabled !== value) { - persistClientSettings({ ...getClientSettingsSnapshot(), planModeEnabled: value }); - } - }, []); - - useSyncedPlanModeHydrationEffect(syncedPlanModeHydrationController, { + const common = { environmentId, primaryEnvironmentId, clientHydrated, - clientValue: clientSettings.planModeEnabled, live: synced.live, serverPreferences: synced.preferences, canPatch, now: new Date().toISOString(), patch: patchPreferences, - persist: persistSyncedPlanMode, + } 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 { @@ -342,6 +417,102 @@ function useSyncedPlanModeHydration(environmentId: EnvironmentId | null) { } 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; + write({ themeId: syncedThemeIdFromPreference(theme) }); + 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 { @@ -404,7 +575,7 @@ export function useEnvironmentSettings( selector?: (settings: UnifiedSettings) => T, ): T { const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); - const synced = useSyncedPlanModeHydration(environmentId); + const synced = useSyncedClientPreferencesHydration(environmentId); return useMergedSettings( serverSettings ?? DEFAULT_SERVER_SETTINGS, synced.preferences, @@ -419,7 +590,7 @@ export function usePrimarySettings( selector?: (settings: UnifiedSettings) => T, ): T { const environmentId = usePrimaryEnvironment()?.environmentId ?? null; - const synced = useSyncedPlanModeHydration(environmentId); + const synced = useSyncedClientPreferencesHydration(environmentId); return useMergedSettings( useAtomValue(primaryServerSettingsAtom), synced.preferences, @@ -520,6 +691,8 @@ export function __resetClientSettingsPersistenceForTests(): void { clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); syncedPlanModeHydrationController.reset(); + syncedAppearanceModeHydrationController.reset(); + syncedThemeIdHydrationController.reset(); } export function __setClientSettingsForTests(settings: ClientSettings): void { From e464ea409f32475eadab8885f676c4b3724f1426 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 19:58:25 -0400 Subject: [PATCH 27/42] fix(mobile): address review-bot findings - Compose sequential synced-theme writes from the latest pending snapshot - Document every supported mobile theme color syntax - Remove a stale Hermes-test lint suppression --- .../appearance/sections/ColorSchemeAppearanceSection.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx index a8550114de6e..4d1bcb1f5a1f 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx @@ -108,7 +108,8 @@ function ImportThemeSheet(props: { Paste a T3 Code ThemeFile v1 JSON object. Mobile colors support hex, named colors, - rgb()/rgba(), hsl()/hsla(), and oklch(). + rgb()/rgba(), hsl()/hsla(), hwb(), lab(), lch(), oklab(), oklch(), and color(display-p3 + ...)/color(srgb ...). Date: Fri, 14 Aug 2026 20:26:48 -0400 Subject: [PATCH 28/42] fix(mobile): use effective appearance in new-task composer --- apps/mobile/src/features/threads/NewTaskDraftScreen.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index c58d932be57c..7cb8bc569fd5 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -47,6 +47,7 @@ import { import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, @@ -127,6 +128,7 @@ export function NewTaskDraftScreen(props: { reserveShare, } = useIncomingShare(); const insets = useSafeAreaInsets(); + const { effectiveColorScheme } = useAppearancePreferences(); const colorScheme = useColorScheme(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = Math.max(insets.bottom, 10); @@ -935,7 +937,7 @@ 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) && From e932c564176d281d771a92ac6b14f180a84c6ed6 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 20:52:17 -0400 Subject: [PATCH 29/42] fix(mobile): address follow-up review findings --- .../features/threads/NewTaskDraftScreen.tsx | 9 +++++---- apps/mobile/src/lib/mobileThemeFile.test.ts | 9 +++++++++ apps/mobile/src/lib/mobileThemeFile.ts | 18 ++++-------------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 7cb8bc569fd5..84f2720f9d78 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -6,7 +6,7 @@ import { usePreventRemove, } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Platform, Pressable, ScrollView, View, useColorScheme } from "react-native"; +import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { KeyboardController, KeyboardStickyView, @@ -129,7 +129,6 @@ export function NewTaskDraftScreen(props: { } = useIncomingShare(); const insets = useSafeAreaInsets(); const { effectiveColorScheme } = useAppearancePreferences(); - const colorScheme = useColorScheme(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = Math.max(insets.bottom, 10); const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); @@ -357,8 +356,10 @@ 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 = + effectiveColorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; + const sheetFadeTransparent = + 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. diff --git a/apps/mobile/src/lib/mobileThemeFile.test.ts b/apps/mobile/src/lib/mobileThemeFile.test.ts index b04133023bfb..691a54b8477e 100644 --- a/apps/mobile/src/lib/mobileThemeFile.test.ts +++ b/apps/mobile/src/lib/mobileThemeFile.test.ts @@ -79,6 +79,15 @@ describe("parseMobileThemeFile", () => { }); }); + it.each([ + { input: "rgb(none 20 30)", expected: "#00141e" }, + { input: "rgb(255 0 0 / 200%)", expected: "#ff0000" }, + ])("normalizes CSS RGB edge case $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" }, diff --git a/apps/mobile/src/lib/mobileThemeFile.ts b/apps/mobile/src/lib/mobileThemeFile.ts index ba19e9b13671..0940c4579c08 100644 --- a/apps/mobile/src/lib/mobileThemeFile.ts +++ b/apps/mobile/src/lib/mobileThemeFile.ts @@ -316,17 +316,6 @@ function parseCssNumber(value: string): number | null { return Number.isFinite(number) ? number : null; } -function parseAlpha(value: string | undefined): number | null { - if (value === undefined || value.trim() === "") return 1; - const token = value.trim().toLowerCase(); - if (token === "none") return 0; - const percent = token.endsWith("%"); - const number = parseCssNumber(percent ? token.slice(0, -1) : token); - if (number === null) return null; - const alpha = percent ? number / 100 : number; - return alpha >= 0 && alpha <= 1 ? alpha : null; -} - function parseClampedAlpha(value: string | undefined): number | null { if (value === undefined) return 1; const token = value.trim().toLowerCase(); @@ -348,7 +337,8 @@ function parseScaledComponent(value: string, percentageScale: number): number | } function parseRgbChannel(value: string): number | null { - const token = value.trim(); + const token = value.trim().toLowerCase(); + if (token === "none") return 0; const percent = token.endsWith("%"); const number = parseCssNumber(percent ? token.slice(0, -1) : token); if (number === null) return null; @@ -398,7 +388,7 @@ 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 = parseAlpha(parsed.alpha); + const alpha = parseClampedAlpha(parsed.alpha); return channels.every((channel): channel is number => channel !== null) && alpha !== null ? rgbaToHex(channels[0], channels[1], channels[2], alpha) : null; @@ -438,7 +428,7 @@ function parseHslColor(body: string): string | null { const hue = parseAngle(parsed.channels[0]); const saturation = parsePercentage(parsed.channels[1]); const lightness = parsePercentage(parsed.channels[2]); - const alpha = parseAlpha(parsed.alpha); + 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); From 1a9c9bd384b9c8a1d1236bd3c3d13c584803d985 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 21:32:37 -0400 Subject: [PATCH 30/42] fix(mobile): hsl none components and popover appearance override --- .../src/features/threads/ComposerCommandPopover.tsx | 6 +++--- apps/mobile/src/features/threads/NewTaskDraftScreen.tsx | 1 + apps/mobile/src/features/threads/ThreadComposer.tsx | 1 + apps/mobile/src/lib/mobileThemeFile.test.ts | 9 +++++++++ apps/mobile/src/lib/mobileThemeFile.ts | 6 +++--- 5 files changed, 17 insertions(+), 6 deletions(-) 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 84f2720f9d78..da58972f0c77 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -957,6 +957,7 @@ export function NewTaskDraftScreen(props: { items={composerMenuItems} triggerKind={composerTrigger.kind} isLoading={false} + isDarkMode={isDarkMode} onSelect={handleCommandSelect} /> ) : null; diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 4f058d31587c..0ca96ad252db 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -741,6 +741,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer items={composerMenuItems} triggerKind={composerTrigger.kind} isLoading={pathSearch.isPending} + isDarkMode={isDarkMode} onSelect={handleCommandSelect} /> diff --git a/apps/mobile/src/lib/mobileThemeFile.test.ts b/apps/mobile/src/lib/mobileThemeFile.test.ts index 691a54b8477e..5f8d33dd64c6 100644 --- a/apps/mobile/src/lib/mobileThemeFile.test.ts +++ b/apps/mobile/src/lib/mobileThemeFile.test.ts @@ -88,6 +88,15 @@ describe("parseMobileThemeFile", () => { 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" }, diff --git a/apps/mobile/src/lib/mobileThemeFile.ts b/apps/mobile/src/lib/mobileThemeFile.ts index 0940c4579c08..729b124ca38c 100644 --- a/apps/mobile/src/lib/mobileThemeFile.ts +++ b/apps/mobile/src/lib/mobileThemeFile.ts @@ -425,9 +425,9 @@ function parsePercentage(value: string): number | null { function parseHslColor(body: string): string | null { const parsed = splitFunctionalColor(body); if (!parsed || parsed.channels.length !== 3) return null; - const hue = parseAngle(parsed.channels[0]); - const saturation = parsePercentage(parsed.channels[1]); - const lightness = parsePercentage(parsed.channels[2]); + 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); From d6a1092c7d52ef1fb4a09142bc593993cef605bf Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 21:50:52 -0400 Subject: [PATCH 31/42] fix(mobile): theme form sheet surfaces with appearance override --- apps/mobile/src/App.tsx | 14 +++++++- apps/mobile/src/Stack.tsx | 44 +++++++++++++++++++++---- apps/mobile/src/lib/mobileTheme.test.ts | 5 +++ apps/mobile/src/lib/mobileTheme.ts | 10 ++++++ apps/mobile/src/native/sheet-surface.ts | 8 ----- 5 files changed, 66 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 789fca73334a..77801a85aed1 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -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"; @@ -58,7 +60,8 @@ function SplashScreenCoordinator() { } function ThemedApp() { - const { effectiveColorScheme } = useAppearancePreferences(); + const { appearanceMode, effectiveColorScheme, importedThemes, themeId } = + useAppearancePreferences(); const statusBarBg = useThemeColor("--color-status-bar"); const navigationBackground = String(useThemeColor("--color-screen")); const navigationCard = String(useThemeColor("--color-sheet")); @@ -69,6 +72,12 @@ function ThemedApp() { 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, @@ -79,12 +88,15 @@ function ThemedApp() { }, }; }, [ + appearanceMode, effectiveColorScheme, + importedThemes, navigationBackground, navigationBorder, navigationCard, navigationPrimary, navigationText, + themeId, ]); return ( diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4db8e167744a..f84df38c1581 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"; @@ -81,6 +81,16 @@ type AppScreenOptions = NativeStackNavigationOptions & { readonly unstable_navigationItemStyle?: "editor"; }; +type AppNavigationTheme = ReactNavigation.Theme & { + readonly formSheetBackground: ColorValue; +}; + +function themedFormSheetContentStyle(theme: ReactNavigation.Theme) { + return { + backgroundColor: (theme as AppNavigationTheme).formSheetBackground, + }; +} + // Shared header presets. Screens only override genuinely dynamic values (titles, // subtitles, toolbar items, search callbacks) via NativeStackScreenOptions. // @@ -239,18 +249,23 @@ 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, // 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, @@ -450,12 +465,29 @@ 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, - }, + ...(Platform.OS === "ios" && ROOT_FORM_SHEET_ROUTES.has(route.name) + ? { contentStyle: themedFormSheetContentStyle(theme) } + : null), + }), screens: { Home: createNativeStackScreen({ screen: HomeRouteScreen, diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index b347df8d3bb4..23f199699e30 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vite-plus/test"; import { removeImportedMobileTheme, resolveColorSchemeOverride, + resolveMobileFormSheetBackground, resolveMobileThemePickerOptions, resolveMobileThemeColors, resolveMobileNativeSurfaceColors, @@ -38,6 +39,8 @@ describe("mobile theme preferences", () => { }); 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", () => { @@ -137,6 +140,8 @@ describe("mobile theme palette resolution", () => { 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", () => { diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index fe4e8b29fe3b..33271a951125 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -588,6 +588,16 @@ export function resolveMobileNativeSurfaceColors( }; } +/** 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}`); 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 }), }; From dba88bb9578aa8b81ef6b775ab803de4cb3bac86 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 22:15:01 -0400 Subject: [PATCH 32/42] test(web): port inactive-settle adoption guard test to generalized API --- .../hooks/synced-client-preferences.test.ts | 173 ++++++++++++------ 1 file changed, 122 insertions(+), 51 deletions(-) diff --git a/apps/web/src/hooks/synced-client-preferences.test.ts b/apps/web/src/hooks/synced-client-preferences.test.ts index 3190003ade4c..6d8bf63c9f4b 100644 --- a/apps/web/src/hooks/synced-client-preferences.test.ts +++ b/apps/web/src/hooks/synced-client-preferences.test.ts @@ -3,6 +3,8 @@ 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 { @@ -15,8 +17,47 @@ import { resolveSyncedPlanModeHydrationAction, type SyncedClientPreferenceHydrationInput, type SyncedPlanModeHydrationInput, + 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 as unknown, + documentElement: null as unknown, + }; + 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: "", + }; + document.body = container; + document.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); + return createRoot(container as unknown as Element); +} + describe("synced client preferences", () => { it("adopts an environment value over the local cache", () => { expect( @@ -714,8 +755,11 @@ describe("synced client preferences", () => { ); it("does not adopt stale shell preferences after an inactive patch settles", async () => { + type HydrationInput = SyncedClientPreferenceHydrationInput<"planModeEnabled", never>; + const environmentId = EnvironmentId.make("primary"); - const controller = createSyncedPlanModeHydrationController(); + const nextPrimaryEnvironmentId = EnvironmentId.make("next-primary"); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); const previous = { planModeEnabled: false, updatedAt: "2026-08-14T12:00:00.000Z", @@ -724,10 +768,8 @@ describe("synced client preferences", () => { planModeEnabled: false, updatedAt: "2026-08-14T12:01:00.000Z", } as const; - let resolvePatch!: ( - result: Awaited["patch"]>>, - ) => void; - const patch = vi.fn["patch"]>( + let resolvePatch!: (result: Awaited>) => void; + const patch = vi.fn( () => new Promise((resolve) => { resolvePatch = resolve; @@ -739,56 +781,85 @@ describe("synced client preferences", () => { localValue = value; persisted.push(value); }; - const deactivate = controller.synchronize({ - environmentId, - primaryEnvironmentId: environmentId, - clientHydrated: true, - clientValue: localValue, - live: true, - serverPreferences: previous, - canPatch: true, - now: previous.updatedAt, - patch, - persist, - }); + const HydrationHook = ({ input }: { input: HydrationInput }) => { + useSyncedClientPreferenceHydrationEffect(controller, input); + return null; + }; + const root = createHookTestRoot(); - localValue = true; - controller.write({ - environmentId, - value: localValue, - serverPreferences: previous, - canPatch: true, - now: "2026-08-14T12:02:00.000Z", - patch, - persist, - }); - deactivate?.(); - resolvePatch( - AsyncResult.success({ - planModeEnabled: true, - updatedAt: "2026-08-14T12:02:00.000Z", - }), - ); - await Promise.resolve(); + try { + const input = { + environmentId, + primaryEnvironmentId: environmentId, + clientHydrated: true, + clientValue: localValue, + live: true, + serverPreferences: previous, + canPatch: true, + now: previous.updatedAt, + patch, + persist, + } satisfies HydrationInput; - expect(persisted).toEqual([]); + await act(async () => { + root.render(createElement(HydrationHook, { input })); + }); - controller.synchronize({ - environmentId, - primaryEnvironmentId: environmentId, - clientHydrated: true, - clientValue: localValue, - live: true, - serverPreferences: stale, - canPatch: true, - now: "2026-08-14T12:03:00.000Z", - patch, - persist, - }); + 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(localValue).toBe(true); - expect(persisted).toEqual([]); - expect(patch).toHaveBeenCalledOnce(); + 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 () => { From 1dfeff090f6d4976feab86ce65a19f1089aebfed Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 22:30:11 -0400 Subject: [PATCH 33/42] fix(mobile): opaque custom theme ids and forced-scheme propagation --- .../AppearancePreferencesProvider.test.ts | 53 +++++++++++++++++++ .../AppearancePreferencesProvider.tsx | 10 +++- .../hooks/synced-client-preferences.test.ts | 21 ++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.test.ts 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..afd75e498884 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.test.ts @@ -0,0 +1,53 @@ +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 { applyAppearanceModeToRuntimes } 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); + }); +}); diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 2a14089df75e..a241d4d83f0d 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -10,7 +10,7 @@ import { import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useColorScheme } from "react-native"; +import { Appearance, useColorScheme } from "react-native"; import { Uniwind } from "uniwind"; @@ -106,6 +106,12 @@ function applyTextScaleVariables(baseFontSize: number) { Uniwind.updateCSSVariables(currentTheme, variables); } +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); @@ -155,7 +161,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN }, [importedThemes, themePreferences.themeId]); useEffect(() => { - Uniwind.setTheme(resolveColorSchemeOverride(themePreferences.appearanceMode) ?? "system"); + applyAppearanceModeToRuntimes(themePreferences.appearanceMode); }, [themePreferences.appearanceMode]); useEffect(() => { diff --git a/apps/web/src/hooks/synced-client-preferences.test.ts b/apps/web/src/hooks/synced-client-preferences.test.ts index 6d8bf63c9f4b..329cb8be3910 100644 --- a/apps/web/src/hooks/synced-client-preferences.test.ts +++ b/apps/web/src/hooks/synced-client-preferences.test.ts @@ -145,6 +145,27 @@ describe("synced client preferences", () => { }); }); + it("preserves an opaque server theme across an unrelated plan write", () => { + expect( + createSyncedPlanModeWrite({ + 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({ From f9f7f46557d1df77b959e294183ab3273a4ad181 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 22:43:00 -0400 Subject: [PATCH 34/42] fix(mobile): splash plugin mod shape, unsent-draft live activity, opaque headers --- .../withIosSplashScreenInlineBackground.cjs | 43 +++---- ...ithIosSplashScreenInlineBackground.test.ts | 105 ++++++++++++++++++ apps/mobile/src/Stack.tsx | 22 +++- 3 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 apps/mobile/plugins/withIosSplashScreenInlineBackground.test.ts diff --git a/apps/mobile/plugins/withIosSplashScreenInlineBackground.cjs b/apps/mobile/plugins/withIosSplashScreenInlineBackground.cjs index 51cf2ada4156..213e779e5371 100644 --- a/apps/mobile/plugins/withIosSplashScreenInlineBackground.cjs +++ b/apps/mobile/plugins/withIosSplashScreenInlineBackground.cjs @@ -1,30 +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) => { - const document = nextConfig.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 } }]; - + 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..fa226b071366 --- /dev/null +++ b/apps/mobile/plugins/withIosSplashScreenInlineBackground.test.ts @@ -0,0 +1,105 @@ +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 } = require("./withIosSplashScreenInlineBackground.cjs") as { + inlineSplashScreenBackground: (modResults: StoryboardModResults) => StoryboardModResults; +}; + +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/Stack.tsx b/apps/mobile/src/Stack.tsx index f84df38c1581..070d84fc0c42 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -91,6 +91,14 @@ function themedFormSheetContentStyle(theme: ReactNavigation.Theme) { }; } +function themedOpaqueHeaderStyle(theme: ReactNavigation.Theme) { + return { + // native-stack types this as `string`, but the native side accepts any + // ColorValue including DynamicColorIOS. + backgroundColor: (theme as AppNavigationTheme).formSheetBackground as unknown as string, + }; +} + // Shared header presets. Screens only override genuinely dynamic values (titles, // subtitles, toolbar items, search callbacks) via NativeStackScreenOptions. // @@ -103,7 +111,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { headerLargeTitle: false, headerShadowVisible: false, headerShown: true, - headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : 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, @@ -147,11 +155,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, @@ -251,6 +260,7 @@ const NewTaskSheetStack = createNativeStackNavigator({ initialRouteName: "NewTask", 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. @@ -484,6 +494,7 @@ export const RootStack = createNativeStackNavigator({ layout: RootStackLayout, screenOptions: ({ route, theme }) => ({ headerShown: false, + headerStyle: themedOpaqueHeaderStyle(theme), ...(Platform.OS === "ios" && ROOT_FORM_SHEET_ROUTES.has(route.name) ? { contentStyle: themedFormSheetContentStyle(theme) } : null), @@ -530,10 +541,11 @@ export const RootStack = createNativeStackNavigator({ ThreadFiles: createNativeStackScreen({ screen: ThreadFilesTreeScreen, linking: `${THREAD_LINKING_PREFIX}/files`, - options: { + options: ({ theme }) => ({ ...GLASS_HEADER_OPTIONS, + contentStyle: themedFormSheetContentStyle(theme), title: "Files", - }, + }), }), ThreadFile: createNativeStackScreen({ screen: ThreadFileScreen, From f6b69cf058de48b6afeb42be48bee87beddcf8ff Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Fri, 14 Aug 2026 23:18:29 -0400 Subject: [PATCH 35/42] refactor(mobile): simplify theme internals --- apps/mobile/src/Stack.tsx | 8 +- .../src/features/files/SourceFileSurface.tsx | 11 +- .../features/files/nativeSourceFileAdapter.ts | 13 +-- .../review/nativeReviewDiffAdapter.ts | 104 ++++++++---------- .../review/useNativeReviewDiffBridge.ts | 11 +- .../AppearancePreferencesProvider.tsx | 98 ++++++----------- .../sections/ColorSchemeAppearanceSection.tsx | 35 ++---- .../terminal/ThreadTerminalRouteScreen.tsx | 11 +- .../src/features/terminal/terminalTheme.ts | 2 +- .../src/features/threads/ThreadFeed.tsx | 11 +- apps/mobile/src/lib/mobileTheme.ts | 39 ++----- apps/mobile/src/lib/mobileThemeFile.test.ts | 8 -- apps/mobile/src/lib/mobileThemeFile.ts | 12 +- .../src/state/synced-client-preferences.ts | 18 ++- 14 files changed, 128 insertions(+), 253 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 070d84fc0c42..ac9bf72a038c 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -92,11 +92,9 @@ function themedFormSheetContentStyle(theme: ReactNavigation.Theme) { } function themedOpaqueHeaderStyle(theme: ReactNavigation.Theme) { - return { - // native-stack types this as `string`, but the native side accepts any - // ColorValue including DynamicColorIOS. - backgroundColor: (theme as AppNavigationTheme).formSheetBackground as unknown as string, - }; + // native-stack types this as `string`, but the native side accepts any + // ColorValue including DynamicColorIOS. + return themedFormSheetContentStyle(theme) as { readonly backgroundColor: string }; } // Shared header presets. Screens only override genuinely dynamic values (titles, diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index e275267621ab..580673e0bfd8 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -173,15 +173,8 @@ function NativeSourceFileSurface( [targetIndex], ); const themeJson = useMemo( - () => JSON.stringify(createNativeSourceFileTheme(theme, nativeSurfaceColors ?? undefined)), - [ - nativeSurfaceColors?.accent, - nativeSurfaceColors?.border, - nativeSurfaceColors?.foreground, - nativeSurfaceColors?.mutedForeground, - nativeSurfaceColors?.sheetBackground, - theme, - ], + () => JSON.stringify(createNativeSourceFileTheme(theme, nativeSurfaceColors)), + [nativeSurfaceColors, theme], ); const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]); const contentWidth = codeWordBreak diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts index 90a0213d3cb3..3d80abc1bb22 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts @@ -7,22 +7,11 @@ 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"; -import { - createNativeReviewDiffTheme, - type NativeReviewDiffThemeOverrides, -} from "../review/nativeReviewDiffAdapter"; -import type { TerminalAppearanceScheme } from "../terminal/terminalTheme"; +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; -export function createNativeSourceFileTheme( - scheme: TerminalAppearanceScheme, - overrides?: NativeReviewDiffThemeOverrides, -) { - return createNativeReviewDiffTheme(scheme, overrides); -} - export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle( resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), ); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index b9146d74fb63..ccf72ce769d5 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -118,65 +118,57 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray createNativeReviewDiffTheme(scheme, nativeSurfaceColors ?? undefined), - [ - nativeSurfaceColors?.accent, - nativeSurfaceColors?.border, - nativeSurfaceColors?.foreground, - nativeSurfaceColors?.mutedForeground, - nativeSurfaceColors?.sheetBackground, - scheme, - ], + () => createNativeReviewDiffTheme(scheme, nativeSurfaceColors), + [nativeSurfaceColors, scheme], ); const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]); const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]); diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index a241d4d83f0d..f8b0f86e4e4f 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -18,7 +18,6 @@ import { resolveAppearance, resolveAppearancePreferences, resolveTextScaleVariables, - type AppearancePreferences, type ResolvedAppearance, } from "../../../lib/appearancePreferences"; import { @@ -32,16 +31,12 @@ import { type MobileNativeSurfaceColors, type MobileThemeAppearance, type MobileThemeId, - type MobileThemePreferences, } from "../../../lib/mobileTheme"; import { addImportedMobileTheme, - importedMobileThemesKey, parseMobileThemeFileJson, - sanitizeImportedMobileThemes, type ImportedMobileTheme, } from "../../../lib/mobileThemeFile"; -import type { Preferences } from "../../../persistence/mobile-preferences"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import { useUpdateAppearanceModePreference, @@ -72,38 +67,21 @@ interface AppearancePreferencesContextValue { const AppearancePreferencesContext = createContext(null); -/** - * Updates the current theme last so the active stylesheet settles correctly. - */ -function applyThemeVariables( - themeId: MobileThemeId, - importedThemes: ReadonlyArray, +/** Updates the active stylesheet last so it settles correctly. */ +function updateCSSVariables( + resolveVariables: (theme: MobileThemeAppearance) => Readonly>, ) { const currentTheme = Uniwind.currentTheme; - - for (const theme of ["light", "dark"] as const) { - if (theme !== currentTheme) { - Uniwind.updateCSSVariables( - theme, - resolveMobileThemeVariables(themeId, theme, importedThemes), - ); - } - } - Uniwind.updateCSSVariables( - currentTheme, - resolveMobileThemeVariables(themeId, currentTheme, importedThemes), - ); + const inactiveTheme = currentTheme === "light" ? "dark" : "light"; + Uniwind.updateCSSVariables(inactiveTheme, resolveVariables(inactiveTheme)); + Uniwind.updateCSSVariables(currentTheme, resolveVariables(currentTheme)); } -/** Injects scaled `--text-*` variables so className text sizes re-resolve live. */ -function applyTextScaleVariables(baseFontSize: number) { - const variables = resolveTextScaleVariables(baseFontSize); - const currentTheme = Uniwind.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) { @@ -115,7 +93,7 @@ export function applyAppearanceModeToRuntimes(appearanceMode: MobileAppearanceMo export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const updateAppearanceModePreference = useUpdateAppearanceModePreference(); + const setAppearanceMode = useUpdateAppearanceModePreference(); const updateThemePreference = useUpdateThemePreference(); const systemColorScheme = useColorScheme(); const [hasAppliedInitialPreferences, setHasAppliedInitialPreferences] = useState(false); @@ -126,13 +104,12 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN ), [preferencesResult], ); - const importedThemesSource = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.importedThemes - : null; - const importedThemesCacheKey = importedMobileThemesKey(importedThemesSource); + const importedThemesJson = JSON.stringify( + AsyncResult.isSuccess(preferencesResult) ? (preferencesResult.value.importedThemes ?? []) : [], + ); const importedThemes = useMemo( - () => sanitizeImportedMobileThemes(JSON.parse(importedThemesCacheKey) as unknown), - [importedThemesCacheKey], + () => JSON.parse(importedThemesJson) as ReadonlyArray, + [importedThemesJson], ); const themePreferences = useMemo( () => @@ -165,7 +142,8 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN }, [themePreferences.appearanceMode]); useEffect(() => { - applyTextScaleVariables(preferences.baseFontSize); + const variables = resolveTextScaleVariables(preferences.baseFontSize); + updateCSSVariables(() => variables); }, [preferences.baseFontSize]); useEffect(() => { @@ -178,46 +156,32 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN const isReady = preferencesLoaded && hasAppliedInitialPreferences; - const updatePreferences = useCallback( - (patch: Partial) => { - savePreferences(patch); - }, - [savePreferences], - ); - 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 }); - }, - [updatePreferences], - ); - - const setAppearanceMode = useCallback( - (value: MobileAppearanceMode) => { - updateAppearanceModePreference(value); + savePreferences({ codeWordBreak: value }); }, - [updateAppearanceModePreference], + [savePreferences], ); const setThemeId = useCallback( @@ -232,10 +196,10 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN (source: string) => { const importedTheme = parseMobileThemeFileJson(source); const next = addImportedMobileTheme(importedThemes, importedTheme); - updatePreferences({ importedThemes: next }); + savePreferences({ importedThemes: next }); updateThemePreference(importedTheme.id); }, - [importedThemes, updatePreferences, updateThemePreference], + [importedThemes, savePreferences, updateThemePreference], ); const removeImportedTheme = useCallback( @@ -246,10 +210,10 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themePreferences.themeId, ); if (!patch) return; - updatePreferences({ importedThemes: patch.importedThemes }); + savePreferences({ importedThemes: patch.importedThemes }); if (patch.themeId !== undefined) updateThemePreference(patch.themeId); }, - [importedThemes, themePreferences.themeId, updatePreferences, updateThemePreference], + [importedThemes, savePreferences, themePreferences.themeId, updateThemePreference], ); const value = useMemo( diff --git a/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx index 4d1bcb1f5a1f..d241416ec942 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx @@ -4,27 +4,14 @@ import { Alert, KeyboardAvoidingView, Modal, Platform, Pressable, View } from "r import { AppText as Text, AppTextInput as TextInput } from "../../../../components/AppText"; import { SymbolView } from "../../../../components/AppSymbol"; import { - MOBILE_APPEARANCE_MODES, + MOBILE_APPEARANCE_OPTIONS, resolveMobileThemePickerOptions, - type MobileAppearanceMode, type MobileThemePickerOption, } from "../../../../lib/mobileTheme"; import { useThemeColor } from "../../../../lib/useThemeColor"; import { SettingsSection } from "../../components/SettingsSection"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; -const APPEARANCE_MODE_LABELS: Readonly> = { - system: "System", - light: "Light", - dark: "Dark", -}; - -function chunk(items: readonly T[], size: number): ReadonlyArray> { - return Array.from({ length: Math.ceil(items.length / size) }, (_, index) => - items.slice(index * size, (index + 1) * size), - ); -} - function ThemePreview(props: { readonly theme: MobileThemePickerOption }) { return ( @@ -153,10 +140,12 @@ export function ColorSchemeAppearanceSection() { setThemeId, themeId, } = useAppearancePreferences(); - const themeRows = useMemo( - () => chunk(resolveMobileThemePickerOptions(importedThemes), 2), - [importedThemes], - ); + 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?`, [ @@ -174,15 +163,15 @@ export function ColorSchemeAppearanceSection() { Appearance - {MOBILE_APPEARANCE_MODES.map((mode) => { - const selected = appearanceMode === mode; + {MOBILE_APPEARANCE_OPTIONS.map((option) => { + const selected = appearanceMode === option.id; return ( setAppearanceMode(mode)} + onPress={() => setAppearanceMode(option.id)} className={ selected ? "min-h-11 flex-1 items-center justify-center rounded-xl bg-primary px-2" @@ -196,7 +185,7 @@ export function ColorSchemeAppearanceSection() { : "text-sm font-t3-medium text-foreground" } > - {APPEARANCE_MODE_LABELS[mode]} + {option.label} ); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index a6635f7500d4..e64460775985 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -469,15 +469,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) ); const terminalTheme = useMemo( - () => getPierreTerminalTheme(appearanceScheme, nativeSurfaceColors ?? undefined), - [ - appearanceScheme, - nativeSurfaceColors?.border, - nativeSurfaceColors?.mutedForeground, - nativeSurfaceColors?.terminalBackground, - nativeSurfaceColors?.terminalCursor, - nativeSurfaceColors?.terminalForeground, - ], + () => getPierreTerminalTheme(appearanceScheme, nativeSurfaceColors), + [appearanceScheme, nativeSurfaceColors], ); const usesNativeHeaderGlass = Platform.OS === "ios"; const pendingModifier = diff --git a/apps/mobile/src/features/terminal/terminalTheme.ts b/apps/mobile/src/features/terminal/terminalTheme.ts index 1f572b7c3176..b9d040cc75e3 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.ts @@ -75,7 +75,7 @@ const PIERRE_DARK_THEME: TerminalTheme = { export function getPierreTerminalTheme( scheme: TerminalAppearanceScheme, - overrides?: TerminalThemeOverrides, + overrides: TerminalThemeOverrides | null = null, ): TerminalTheme { const base = scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; if (!overrides) return base; diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 62b4445eb320..0f9924999e81 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1128,15 +1128,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { [nativeReviewDiffData.rows], ); const nativeReviewDiffTheme = useMemo( - () => createNativeReviewDiffTheme(appearanceScheme, nativeSurfaceColors ?? undefined), - [ - appearanceScheme, - nativeSurfaceColors?.accent, - nativeSurfaceColors?.border, - nativeSurfaceColors?.foreground, - nativeSurfaceColors?.mutedForeground, - nativeSurfaceColors?.sheetBackground, - ], + () => createNativeReviewDiffTheme(appearanceScheme, nativeSurfaceColors), + [appearanceScheme, nativeSurfaceColors], ); const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); const nativeThemeJson = useMemo( diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 33271a951125..32d1bd32a4fb 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -4,8 +4,12 @@ import { type PortableThemeColorOverrides, } from "./mobileThemeFile"; -export const MOBILE_APPEARANCE_MODES = ["system", "light", "dark"] as const; -export type MobileAppearanceMode = (typeof MOBILE_APPEARANCE_MODES)[number]; +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 = [ @@ -420,7 +424,7 @@ const THEME_VARIANTS: Readonly< }; export function isMobileAppearanceMode(value: unknown): value is MobileAppearanceMode { - return MOBILE_APPEARANCE_MODES.some((mode) => mode === value); + return MOBILE_APPEARANCE_OPTIONS.some((option) => option.id === value); } export function isMobileBuiltInThemeId(value: unknown): value is MobileBuiltInThemeId { @@ -526,34 +530,7 @@ function pickMobileCoreThemeColors( colors: PortableThemeColorOverrides, ): Readonly> { const overrides: { -readonly [Role in keyof MobileCoreThemeColors]?: string } = {}; - for (const role of [ - "canvas", - "surface", - "surfaceRaised", - "surfaceOverlay", - "text", - "textMuted", - "border", - "input", - "accent", - "accentForeground", - "secondary", - "secondaryForeground", - "muted", - "mutedForeground", - "placeholder", - "error", - "errorForeground", - "errorSurface", - "messageSurface", - "messageForeground", - "codeBackground", - "codeForeground", - "sidebar", - "sidebarForeground", - "sidebarMutedForeground", - "sidebarControlSurface", - ] as const) { + for (const role of Object.keys(T3_CODE_COLORS.light) as Array) { const color = colors[role]; if (color) overrides[role] = color; } diff --git a/apps/mobile/src/lib/mobileThemeFile.test.ts b/apps/mobile/src/lib/mobileThemeFile.test.ts index 5f8d33dd64c6..092d8fd0068f 100644 --- a/apps/mobile/src/lib/mobileThemeFile.test.ts +++ b/apps/mobile/src/lib/mobileThemeFile.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { addImportedMobileTheme, - importedMobileThemesKey, MAX_IMPORTED_MOBILE_THEMES, MAX_IMPORTED_MOBILE_THEMES_BYTES, MAX_MOBILE_THEME_FILE_BYTES, @@ -258,13 +257,6 @@ describe("addImportedMobileTheme", () => { }); describe("sanitizeImportedMobileThemes", () => { - it("keeps the theme-variable key stable when unrelated preferences reload themes", () => { - const storedThemes = [themeFile()]; - const reloadedThemes = JSON.parse(JSON.stringify(storedThemes)) as unknown; - - expect(importedMobileThemesKey(reloadedThemes)).toBe(importedMobileThemesKey(storedThemes)); - }); - 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}`), diff --git a/apps/mobile/src/lib/mobileThemeFile.ts b/apps/mobile/src/lib/mobileThemeFile.ts index 729b124ca38c..af7c11208069 100644 --- a/apps/mobile/src/lib/mobileThemeFile.ts +++ b/apps/mobile/src/lib/mobileThemeFile.ts @@ -292,16 +292,10 @@ function utf8ByteLength(value: string): number { return bytes; } -export function importedMobileThemesKey(value: unknown): string { - return JSON.stringify(value) ?? "null"; -} - -function clampByte(value: number): number { - return Math.round(Math.min(255, Math.max(0, value))); -} - function byteToHex(value: number): string { - return clampByte(value).toString(16).padStart(2, "0"); + 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 { diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index d7ac3b172d8c..07cee83b61df 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -16,6 +16,7 @@ import { isMobileThemeId, type MobileAppearanceMode, } from "../lib/mobileTheme"; +import type { ImportedMobileTheme } from "../lib/mobileThemeFile"; import { environmentShell } from "./shell"; import { environmentPresentations } from "./presentation"; import { @@ -91,6 +92,16 @@ export function usePlanModePreferenceReconciliationReady(): boolean { }); } +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); @@ -172,8 +183,7 @@ export function useSyncedClientPreferences(): void { return; } const importedThemes = preferencesResult.value.importedThemes ?? []; - const normalizeThemeId = (themeId: string) => - isMobileThemeId(themeId, importedThemes) ? themeId : DEFAULT_MOBILE_THEME_ID; + const normalizeThemeId = (themeId: string) => resolveLocalThemeId(themeId, importedThemes); const reconciliation = reconcileSyncedClientPreferences({ local: { values: { @@ -270,9 +280,7 @@ function useUpdateSyncedClientPreference(field: SyncedClientPreferenceField) { target, result, normalizeThemeId: (themeId) => - themeId === patch.themeId || isMobileThemeId(themeId, importedThemes) - ? themeId - : DEFAULT_MOBILE_THEME_ID, + resolveLocalThemeId(themeId, importedThemes, patch.themeId), }); if (localPatch !== null) savePreferences(localPatch); }), From 1c8ed6dbef49f1bf8e1b6623cdf0f4a9d045e8a2 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 01:21:25 -0400 Subject: [PATCH 36/42] fix(mobile): rgb clamping parity and themed new-task fade --- .../features/threads/NewTaskDraftScreen.tsx | 13 +++++++----- apps/mobile/src/lib/mobileThemeFile.test.ts | 3 +++ apps/mobile/src/lib/mobileThemeFile.ts | 20 ++++++++++++++----- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index da58972f0c77..e438b8c7564c 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -47,6 +47,7 @@ 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 { @@ -128,7 +129,7 @@ export function NewTaskDraftScreen(props: { reserveShare, } = useIncomingShare(); const insets = useSafeAreaInsets(); - const { effectiveColorScheme } = useAppearancePreferences(); + const { effectiveColorScheme, nativeSurfaceColors } = useAppearancePreferences(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = Math.max(insets.bottom, 10); const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); @@ -356,10 +357,12 @@ export function NewTaskDraftScreen(props: { const projectUnderlineColor = useThemeColor("--color-foreground-muted"); const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); - const sheetFadeOpaque = - effectiveColorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; - const sheetFadeTransparent = - effectiveColorScheme === "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. diff --git a/apps/mobile/src/lib/mobileThemeFile.test.ts b/apps/mobile/src/lib/mobileThemeFile.test.ts index 092d8fd0068f..314ffa77af00 100644 --- a/apps/mobile/src/lib/mobileThemeFile.test.ts +++ b/apps/mobile/src/lib/mobileThemeFile.test.ts @@ -81,6 +81,9 @@ describe("parseMobileThemeFile", () => { 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 } }); diff --git a/apps/mobile/src/lib/mobileThemeFile.ts b/apps/mobile/src/lib/mobileThemeFile.ts index af7c11208069..de2bfc221a93 100644 --- a/apps/mobile/src/lib/mobileThemeFile.ts +++ b/apps/mobile/src/lib/mobileThemeFile.ts @@ -336,8 +336,7 @@ function parseRgbChannel(value: string): number | null { const percent = token.endsWith("%"); const number = parseCssNumber(percent ? token.slice(0, -1) : token); if (number === null) return null; - const channel = percent ? (number / 100) * 255 : number; - return channel >= 0 && channel <= 255 ? channel : null; + return percent ? (number / 100) * 255 : number; } function parseAngle(value: string): number | null { @@ -383,9 +382,20 @@ function parseRgbColor(body: string): string | null { if (!parsed || parsed.channels.length !== 3) return null; const channels = parsed.channels.map(parseRgbChannel); const alpha = parseClampedAlpha(parsed.alpha); - return channels.every((channel): channel is number => channel !== null) && alpha !== null - ? rgbaToHex(channels[0], channels[1], channels[2], alpha) - : null; + 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] { From a915dff1aff148357f284c90cd8aec7511fbe1cf Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 01:24:31 -0400 Subject: [PATCH 37/42] refactor(mobile): remove review-round slop --- .../review/nativeReviewDiffAdapter.ts | 34 ++--- .../sections/ColorSchemeAppearanceSection.tsx | 28 ++-- apps/mobile/src/lib/mobileTheme.ts | 14 +- apps/mobile/src/lib/mobileThemeFile.ts | 44 +++--- .../hooks/synced-client-preferences.test.ts | 130 ++++++++++-------- .../src/hooks/synced-client-preferences.ts | 41 ------ apps/web/src/hooks/useSettings.ts | 4 +- 7 files changed, 130 insertions(+), 165 deletions(-) diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index ccf72ce769d5..7ebc1af9f453 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -122,42 +122,34 @@ export function createNativeReviewDiffTheme( ): NativeReviewDiffTheme { const terminalTheme = getPierreTerminalTheme(scheme); const [, terminalRed, , , terminalBlue] = terminalTheme.palette; - const theme: NativeReviewDiffTheme = + const background = scheme === "dark" ? "#0e0e0e" : "#f2f2f7"; + const schemeColors = scheme === "dark" ? { - // Match the app surface (--color-sheet) so code views blend with the rest of - // the app instead of using a distinct code-editor background. - background: "#0e0e0e", - text: terminalTheme.foreground, - mutedText: terminalTheme.mutedForeground, - headerBackground: "#0e0e0e", - border: terminalTheme.border, hunkBackground: "#071f28", - hunkText: terminalBlue ?? "#009fff", addBackground: "#0d2f28", deleteBackground: "#391415", - addBar: "#00cab1", - deleteBar: terminalRed ?? "#ff2e3f", addText: "#5ECC71", deleteText: "#FF6762", } : { - // Match the app surface (--color-sheet) so code views blend with the rest of the - // app instead of using a distinct code-editor background. - background: "#f2f2f7", - text: "#070707", - mutedText: terminalTheme.mutedForeground, - headerBackground: "#f2f2f7", - border: terminalTheme.border, hunkBackground: "#e0f2ff", - hunkText: terminalBlue ?? "#009fff", addBackground: "#e5f8f5", deleteBackground: "#ffe6e7", - addBar: "#00cab1", - deleteBar: terminalRed ?? "#ff2e3f", addText: "#199F43", deleteText: "#D52C36", }; + const theme: NativeReviewDiffTheme = { + background, + text: scheme === "dark" ? terminalTheme.foreground : "#070707", + mutedText: terminalTheme.mutedForeground, + headerBackground: background, + border: terminalTheme.border, + hunkText: terminalBlue ?? "#009fff", + addBar: "#00cab1", + deleteBar: terminalRed ?? "#ff2e3f", + ...schemeColors, + }; return overrides ? { ...theme, diff --git a/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx index d241416ec942..9c553343576e 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ColorSchemeAppearanceSection.tsx @@ -15,24 +15,18 @@ import { useAppearancePreferences } from "../AppearancePreferencesProvider"; function ThemePreview(props: { readonly theme: MobileThemePickerOption }) { return ( - - - - + {(["light", "dark"] as const).map((appearance) => ( - + key={appearance} + className="flex-1 items-center justify-center" + style={{ backgroundColor: props.theme[appearance].canvas }} + > + + + ))} ); } diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 32d1bd32a4fb..6a1bfc2be56d 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -144,13 +144,13 @@ const T3_CODE_COLORS: Readonly { - const normalized = normalizeMobileThemeColorLiteral(color); - if (!normalized) throw new Error(`Invalid T3 Code fallback color for "${role}": ${color}`); - return [role, normalized]; - }), - ) as unknown as MobileCoreThemeColors; + const normalizedColors = { ...colors }; + 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: Readonly> = { diff --git a/apps/mobile/src/lib/mobileThemeFile.ts b/apps/mobile/src/lib/mobileThemeFile.ts index de2bfc221a93..e4214afa4027 100644 --- a/apps/mobile/src/lib/mobileThemeFile.ts +++ b/apps/mobile/src/lib/mobileThemeFile.ts @@ -310,33 +310,33 @@ function parseCssNumber(value: string): number | null { return Number.isFinite(number) ? number : null; } -function parseClampedAlpha(value: string | undefined): number | null { - if (value === undefined) return 1; +function parseCssComponent( + value: string, +): { readonly number: number; readonly percent: boolean } | null { const token = value.trim().toLowerCase(); - if (token === "") return null; - if (token === "none") return 0; + if (token === "none") return { number: 0, percent: false }; const percent = token.endsWith("%"); const number = parseCssNumber(percent ? token.slice(0, -1) : token); - if (number === null) return null; - return Math.min(1, Math.max(0, percent ? number / 100 : number)); + 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 token = value.trim().toLowerCase(); - if (token === "none") return 0; - const percent = token.endsWith("%"); - const number = parseCssNumber(percent ? token.slice(0, -1) : token); - if (number === null) return null; - return percent ? (number * percentageScale) / 100 : number; + const component = parseCssComponent(value); + if (!component) return null; + return component.percent ? (component.number * percentageScale) / 100 : component.number; } function parseRgbChannel(value: string): number | null { - const token = value.trim().toLowerCase(); - if (token === "none") return 0; - const percent = token.endsWith("%"); - const number = parseCssNumber(percent ? token.slice(0, -1) : token); - if (number === null) return null; - return percent ? (number / 100) * 255 : number; + const component = parseCssComponent(value); + if (!component) return null; + return component.percent ? (component.number / 100) * 255 : component.number; } function parseAngle(value: string): number | null { @@ -420,10 +420,10 @@ function hslToRgb(hue: number, saturation: number, lightness: number): [number, } function parsePercentage(value: string): number | null { - const token = value.trim(); - if (!token.endsWith("%")) return null; - const number = parseCssNumber(token.slice(0, -1)); - return number !== null && number >= 0 && number <= 100 ? number / 100 : null; + const component = parseCssComponent(value); + return component?.percent && component.number >= 0 && component.number <= 100 + ? component.number / 100 + : null; } function parseHslColor(body: string): string | null { diff --git a/apps/web/src/hooks/synced-client-preferences.test.ts b/apps/web/src/hooks/synced-client-preferences.test.ts index 329cb8be3910..99e29b143e4c 100644 --- a/apps/web/src/hooks/synced-client-preferences.test.ts +++ b/apps/web/src/hooks/synced-client-preferences.test.ts @@ -11,12 +11,8 @@ import { createSyncedClientPreferenceHydrationController, createSyncedClientPreferenceWrite, createSyncedClientPreferencesSliceAtom, - createSyncedPlanModeHydrationController, - createSyncedPlanModeWrite, resolveSyncedClientPreferenceHydrationAction, - resolveSyncedPlanModeHydrationAction, type SyncedClientPreferenceHydrationInput, - type SyncedPlanModeHydrationInput, useSyncedClientPreferenceHydrationEffect, } from "./synced-client-preferences"; @@ -61,7 +57,8 @@ function createHookTestRoot() { describe("synced client preferences", () => { it("adopts an environment value over the local cache", () => { expect( - resolveSyncedPlanModeHydrationAction({ + resolveSyncedClientPreferenceHydrationAction({ + field: "planModeEnabled", clientHydrated: true, clientValue: false, serverPreferences: { @@ -80,7 +77,8 @@ describe("synced client preferences", () => { it("seeds a missing environment value once from the local cache", () => { expect( - resolveSyncedPlanModeHydrationAction({ + resolveSyncedClientPreferenceHydrationAction({ + field: "planModeEnabled", clientHydrated: true, clientValue: true, serverPreferences: { @@ -96,7 +94,8 @@ describe("synced client preferences", () => { updatedAt: "2026-08-14T11:00:00.000Z", }); expect( - resolveSyncedPlanModeHydrationAction({ + resolveSyncedClientPreferenceHydrationAction({ + field: "planModeEnabled", clientHydrated: true, clientValue: true, serverPreferences: undefined, @@ -108,26 +107,25 @@ describe("synced client preferences", () => { it("writes one stamped value to the local and environment stores", () => { expect( - createSyncedPlanModeWrite({ + createSyncedClientPreferenceWrite({ + field: "planModeEnabled", value: false, serverPreferences: { planModeEnabled: true, updatedAt: "2026-08-14T12:00:00.000Z", }, now: "2026-08-14T12:01:00.000Z", - }), + }).request, ).toEqual({ - clientPatch: { planModeEnabled: false }, - request: { - patch: { planModeEnabled: false }, - updatedAt: "2026-08-14T12:01:00.000Z", - }, + patch: { planModeEnabled: false }, + updatedAt: "2026-08-14T12:01:00.000Z", }); }); it("advances writes from the plan clock instead of a newer appearance clock", () => { expect( - createSyncedPlanModeWrite({ + createSyncedClientPreferenceWrite({ + field: "planModeEnabled", value: false, serverPreferences: { planModeEnabled: true, @@ -139,15 +137,14 @@ describe("synced client preferences", () => { updatedAt: "2026-08-14T13:00:00.000Z", }, now: "2026-08-14T12:30:00.000Z", - }), - ).toMatchObject({ - request: { updatedAt: "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( - createSyncedPlanModeWrite({ + createSyncedClientPreferenceWrite({ + field: "planModeEnabled", value: true, serverPreferences: { planModeEnabled: false, @@ -217,7 +214,8 @@ describe("synced client preferences", () => { it("does not re-adopt stale server state while a local write is pending", () => { expect( - resolveSyncedPlanModeHydrationAction({ + resolveSyncedClientPreferenceHydrationAction({ + field: "planModeEnabled", clientHydrated: true, clientValue: false, serverPreferences: { @@ -237,7 +235,7 @@ describe("synced client preferences", () => { it("keeps divergent values stable across primary and secondary synchronization", async () => { const primaryEnvironmentId = EnvironmentId.make("primary"); const secondaryEnvironmentId = EnvironmentId.make("secondary"); - const controller = createSyncedPlanModeHydrationController(); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); const persisted: boolean[] = []; let localValue = false; const persist = (value: boolean) => { @@ -302,7 +300,7 @@ describe("synced client preferences", () => { it("settles a pending write from an older canonical ack without re-patching", async () => { const primaryEnvironmentId = EnvironmentId.make("primary"); - const controller = createSyncedPlanModeHydrationController(); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); let localValue = false; const persist = (value: boolean) => { localValue = value; @@ -375,7 +373,7 @@ describe("synced client preferences", () => { it("does not seed preferences without orchestration operate scope", () => { const primaryEnvironmentId = EnvironmentId.make("read-only"); - const controller = createSyncedPlanModeHydrationController(); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); const patch = vi.fn(async () => AsyncResult.success({ planModeEnabled: false, @@ -412,7 +410,7 @@ describe("synced client preferences", () => { it("keeps the local fallback when server preferences are read-only", () => { const primaryEnvironmentId = EnvironmentId.make("read-only"); - const controller = createSyncedPlanModeHydrationController(); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); const persist = vi.fn(); const patch = vi.fn(); @@ -438,7 +436,7 @@ describe("synced client preferences", () => { it("uploads an offline write when patch access becomes available", async () => { const primaryEnvironmentId = EnvironmentId.make("primary"); - const controller = createSyncedPlanModeHydrationController(); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); const previous = { planModeEnabled: false, updatedAt: "2026-08-14T12:00:00.000Z", @@ -485,10 +483,13 @@ describe("synced client preferences", () => { it.each(["write", "seed"] as const)("retries a failed %s patch", async (kind) => { const primaryEnvironmentId = EnvironmentId.make("primary"); const scheduledRetries: Array<() => void> = []; - const controller = createSyncedPlanModeHydrationController((retry) => { - scheduledRetries.push(retry); - return vi.fn(); - }); + const controller = createSyncedClientPreferenceHydrationController( + "planModeEnabled", + (retry) => { + scheduledRetries.push(retry); + return vi.fn(); + }, + ); const previous = { planModeEnabled: false, updatedAt: "2026-08-14T12:00:00.000Z", @@ -513,7 +514,7 @@ describe("synced client preferences", () => { now: "2026-08-14T12:01:00.000Z", patch, persist: vi.fn(), - } satisfies SyncedPlanModeHydrationInput; + } satisfies SyncedClientPreferenceHydrationInput<"planModeEnabled", string>; controller.synchronize(hydrationInput); if (kind === "write") { @@ -653,17 +654,20 @@ describe("synced client preferences", () => { const previousPrimaryEnvironmentId = EnvironmentId.make("previous-primary"); const nextPrimaryEnvironmentId = EnvironmentId.make("next-primary"); const scheduledRetries: Array<() => void> = []; - const controller = createSyncedPlanModeHydrationController((retry) => { - let cancelled = false; - scheduledRetries.push(() => { - if (!cancelled) retry(); - }); - return () => { - cancelled = true; - }; - }); + const controller = createSyncedClientPreferenceHydrationController( + "planModeEnabled", + (retry) => { + let cancelled = false; + scheduledRetries.push(() => { + if (!cancelled) retry(); + }); + return () => { + cancelled = true; + }; + }, + ); const patch = vi - .fn["patch"]>() + .fn["patch"]>() .mockResolvedValueOnce(AsyncResult.failure(Cause.fail("offline"))) .mockResolvedValue( AsyncResult.success({ @@ -683,7 +687,7 @@ describe("synced client preferences", () => { now: "2026-08-14T12:00:00.000Z", patch, persist, - } satisfies SyncedPlanModeHydrationInput; + } satisfies SyncedClientPreferenceHydrationInput<"planModeEnabled", string>; const owner = Symbol(); const deactivate = controller.synchronize(input, owner); await Promise.resolve(); @@ -717,11 +721,13 @@ describe("synced client preferences", () => { async ({ switchPrimary, expectedPersisted }) => { const previousPrimaryEnvironmentId = EnvironmentId.make("previous-primary"); const nextPrimaryEnvironmentId = EnvironmentId.make("next-primary"); - const controller = createSyncedPlanModeHydrationController(); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); let resolvePatch!: ( - result: Awaited["patch"]>>, + result: Awaited< + ReturnType["patch"]> + >, ) => void; - const patch = vi.fn["patch"]>( + const patch = vi.fn["patch"]>( () => new Promise((resolve) => { resolvePatch = resolve; @@ -740,7 +746,7 @@ describe("synced client preferences", () => { now: "2026-08-14T12:00:00.000Z", patch, persist, - } satisfies SyncedPlanModeHydrationInput; + } satisfies SyncedClientPreferenceHydrationInput<"planModeEnabled", never>; const owner = Symbol(); const deactivate = controller.synchronize(input, owner); expect(patch).toHaveBeenCalledOnce(); @@ -885,16 +891,24 @@ describe("synced client preferences", () => { it("keeps the latest rapid toggle when responses settle out of order", async () => { const primaryEnvironmentId = EnvironmentId.make("primary"); - const controller = createSyncedPlanModeHydrationController(); + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); const previous = { planModeEnabled: false, updatedAt: "2026-08-14T12:00:00.000Z", } as const; - const targets: Array["patch"]>[0]> = []; + const targets: Array< + Parameters["patch"]>[0] + > = []; const resolvePatches: Array< - (result: Awaited["patch"]>>) => void + ( + result: Awaited< + ReturnType["patch"]> + >, + ) => void > = []; - const patch: SyncedPlanModeHydrationInput["patch"] = (target) => + const patch: SyncedClientPreferenceHydrationInput<"planModeEnabled", never>["patch"] = ( + target, + ) => new Promise((resolve) => { targets.push(target); resolvePatches.push(resolve); @@ -951,12 +965,20 @@ describe("synced client preferences", () => { it("ignores a seed acknowledgement after a newer write is queued", async () => { const primaryEnvironmentId = EnvironmentId.make("primary"); - const controller = createSyncedPlanModeHydrationController(); - const targets: Array["patch"]>[0]> = []; + const controller = createSyncedClientPreferenceHydrationController("planModeEnabled"); + const targets: Array< + Parameters["patch"]>[0] + > = []; const resolvePatches: Array< - (result: Awaited["patch"]>>) => void + ( + result: Awaited< + ReturnType["patch"]> + >, + ) => void > = []; - const patch: SyncedPlanModeHydrationInput["patch"] = (target) => + const patch: SyncedClientPreferenceHydrationInput<"planModeEnabled", never>["patch"] = ( + target, + ) => new Promise((resolve) => { targets.push(target); resolvePatches.push(resolve); diff --git a/apps/web/src/hooks/synced-client-preferences.ts b/apps/web/src/hooks/synced-client-preferences.ts index 4befba49f02d..c60be6272c29 100644 --- a/apps/web/src/hooks/synced-client-preferences.ts +++ b/apps/web/src/hooks/synced-client-preferences.ts @@ -60,15 +60,6 @@ export function createSyncedClientPreferenceWrite< } as const; } -export function createSyncedPlanModeWrite( - input: Omit>[0], "field">, -) { - return { - clientPatch: { planModeEnabled: input.value }, - ...createSyncedClientPreferenceWrite({ ...input, field: "planModeEnabled" }), - } as const; -} - type SyncedClientPreferencePatchTarget = { readonly environmentId: EnvironmentId; readonly input: PatchSyncedClientPreferencesRequest; @@ -153,25 +144,6 @@ export function resolveSyncedClientPreferenceHydrationAction< }; } -export type SyncedPlanModeHydrationInput = SyncedClientPreferenceHydrationInput< - "planModeEnabled", - E ->; -export type SyncedPlanModeHydrationAction = - SyncedClientPreferenceHydrationAction<"planModeEnabled">; - -export function resolveSyncedPlanModeHydrationAction( - input: Omit< - Parameters>[0], - "field" - >, -): SyncedPlanModeHydrationAction { - return resolveSyncedClientPreferenceHydrationAction({ - ...input, - field: "planModeEnabled", - }); -} - const SYNCED_CLIENT_PREFERENCE_RETRY_DELAY_MS = 1_000; const SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS = 3; @@ -493,12 +465,6 @@ export function createSyncedClientPreferenceHydrationController< }; } -export function createSyncedPlanModeHydrationController( - scheduleRetry?: SyncedClientPreferenceRetryScheduler, -) { - return createSyncedClientPreferenceHydrationController("planModeEnabled", scheduleRetry); -} - export function useSyncedClientPreferenceHydrationEffect< Field extends SyncedClientPreferenceField, E, @@ -524,10 +490,3 @@ export function useSyncedClientPreferenceHydrationEffect< ], ); } - -export function useSyncedPlanModeHydrationEffect( - controller: ReturnType, - input: SyncedPlanModeHydrationInput, -): void { - useSyncedClientPreferenceHydrationEffect(controller, input); -} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 6fad66576168..89289632efba 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -310,9 +310,7 @@ const syncedPlanModeHydrationController = createSyncedClientPreferenceHydrationController("planModeEnabled"); const syncedAppearanceModeHydrationController = createSyncedClientPreferenceHydrationController("appearanceMode"); -const syncedThemeIdHydrationController = - createSyncedClientPreferenceHydrationController("themeId"); - +const syncedThemeIdHydrationController = createSyncedClientPreferenceHydrationController("themeId"); function useEnvironmentSyncedClientPreferences(environmentId: EnvironmentId | null) { const preferences = useAtomValue( From e474c66b04e6215eb9544c281e5515a3f542bedc Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 01:54:04 -0400 Subject: [PATCH 38/42] fix(mobile): address review findings --- apps/mobile/src/lib/mobileTheme.test.ts | 4 +- apps/mobile/src/lib/mobileTheme.ts | 5 ++- .../state/synced-client-preferences-model.ts | 14 +++++-- .../state/synced-client-preferences.test.ts | 37 +++++++++++++++++++ .../src/state/synced-client-preferences.ts | 1 + 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index 23f199699e30..22c8c8658154 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -305,8 +305,8 @@ describe("mobile theme color helpers", () => { expect(() => withAlpha("rgb(0, 0, 0)", "1f")).toThrow(/6- or 8-digit hex/i); }); - it("replaces an existing hex alpha for imported colors", () => { - expect(withAlpha("#11223380", "1f")).toBe("#1122331f"); + it("combines an existing hex alpha for imported colors", () => { + expect(withAlpha("#00000014", "cc")).toBe("#00000010"); }); }); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 6a1bfc2be56d..977192d00aae 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -579,7 +579,10 @@ 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}`); } - return `${color.slice(0, 7)}${alpha}`; + 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 = { diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index dbaad84f4f7b..c9bdddaa1336 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -489,6 +489,7 @@ export function reconcileSyncedClientPreferences(input: { readonly environments: ReadonlyArray; readonly now: string; readonly fields?: ReadonlyArray; + readonly normalizeThemeId?: (themeId: string) => string; }) { if (input.environments.length === 0) { return { localPatch: null, environmentPatches: [] }; @@ -503,12 +504,19 @@ export function reconcileSyncedClientPreferences(input: { const hasPatchableEnvironment = input.environments.some( (environment) => environment.canPatch !== false, ); + const normalizePreferenceValue = ( + field: SyncedClientPreferenceField, + value: SyncedClientPreferencesPatch[SyncedClientPreferenceField] | undefined, + ) => + field === "themeId" && typeof value === "string" + ? (input.normalizeThemeId?.(value) ?? value) + : value; for (const field of input.fields ?? SYNCED_CLIENT_PREFERENCE_FIELDS) { - const localValue = input.local.values[field]; + const localValue = normalizePreferenceValue(field, input.local.values[field]); const localUpdatedAt = localPreferenceUpdatedAt(input.local, field); const environmentCandidates = input.environments.flatMap((environment) => { - const value = environment.preferences?.[field]; + const value = normalizePreferenceValue(field, environment.preferences?.[field]); const updatedAt = getSyncedClientPreferenceUpdatedAt(environment.preferences, field); return value === undefined || updatedAt === undefined || @@ -549,7 +557,7 @@ export function reconcileSyncedClientPreferences(input: { for (const environment of input.environments) { if (environment.canPatch === false) continue; if ( - environment.preferences?.[field] === value && + normalizePreferenceValue(field, environment.preferences?.[field]) === value && getSyncedClientPreferenceUpdatedAt(environment.preferences, field) === updatedAt ) { continue; diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index 6e50100ceb7c..b715713b9d4a 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -873,6 +873,43 @@ describe("synced client preferences", () => { ]); }); + 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, diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index 07cee83b61df..2740828fa0b4 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -201,6 +201,7 @@ export function useSyncedClientPreferences(): void { shell.snapshot._tag === "Some" ? shell.snapshot.value.syncedClientPreferences : undefined, })), now: new Date().toISOString(), + normalizeThemeId, }); if (reconciliation.localPatch !== null) { savePreferences({ From 061c307bc8d873df11ed3355f3f26fb7a56ea095 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 11:02:00 -0400 Subject: [PATCH 39/42] fix(mobile): theme removal respects newer selection --- .../AppearancePreferencesProvider.test.ts | 37 +++++++++++++- .../AppearancePreferencesProvider.tsx | 50 ++++++++++++++----- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.test.ts b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.test.ts index afd75e498884..a4624980ee72 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.test.ts +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.test.ts @@ -32,7 +32,11 @@ vi.mock("../../terminal/terminalUiState", () => ({ cacheTerminalFontSize: vi.fn(), })); -import { applyAppearanceModeToRuntimes } from "./AppearancePreferencesProvider"; +import { parseMobileThemeFile } from "../../../lib/mobileThemeFile"; +import { + applyAppearanceModeToRuntimes, + applyImportedThemeRemovalAtConfirmation, +} from "./AppearancePreferencesProvider"; describe("applyAppearanceModeToRuntimes", () => { beforeEach(() => { @@ -51,3 +55,34 @@ describe("applyAppearanceModeToRuntimes", () => { 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 f8b0f86e4e4f..fad58e4cfcf3 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; @@ -67,6 +68,27 @@ interface AppearancePreferencesContextValue { const AppearancePreferencesContext = createContext(null); +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>, @@ -132,6 +154,18 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN ), [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); @@ -202,19 +236,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [importedThemes, savePreferences, updateThemePreference], ); - const removeImportedTheme = useCallback( - (removedThemeId: string) => { - const patch = removeImportedMobileTheme( - importedThemes, - removedThemeId, - themePreferences.themeId, - ); - if (!patch) return; - savePreferences({ importedThemes: patch.importedThemes }); - if (patch.themeId !== undefined) updateThemePreference(patch.themeId); - }, - [importedThemes, savePreferences, themePreferences.themeId, updateThemePreference], - ); + const removeImportedTheme = useCallback((removedThemeId: string) => { + applyImportedThemeRemovalAtConfirmation(removedThemeId, importedThemeRemovalState.current); + }, []); const value = useMemo( (): AppearancePreferencesContextValue => ({ From a8cc43fae8284bc7979c3c6439c1ac08257a9a08 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 12:19:20 -0400 Subject: [PATCH 40/42] fix(mobile): parse newline-separated color components --- apps/mobile/src/lib/mobileThemeFile.test.ts | 9 +++++++++ apps/mobile/src/lib/mobileThemeFile.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/mobileThemeFile.test.ts b/apps/mobile/src/lib/mobileThemeFile.test.ts index 314ffa77af00..0a90774faac4 100644 --- a/apps/mobile/src/lib/mobileThemeFile.test.ts +++ b/apps/mobile/src/lib/mobileThemeFile.test.ts @@ -78,6 +78,15 @@ describe("parseMobileThemeFile", () => { }); }); + 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" }, diff --git a/apps/mobile/src/lib/mobileThemeFile.ts b/apps/mobile/src/lib/mobileThemeFile.ts index e4214afa4027..c32aad3f08e5 100644 --- a/apps/mobile/src/lib/mobileThemeFile.ts +++ b/apps/mobile/src/lib/mobileThemeFile.ts @@ -718,7 +718,7 @@ export function normalizeMobileThemeColorLiteral(value: unknown): string | null : `#${hex}`; } - const functional = input.match(/^([a-z]+)\((.*)\)$/); + const functional = input.match(/^([a-z]+)\(([\s\S]*)\)$/); if (!functional) return null; const [, name, body] = functional; if (name === "rgb" || name === "rgba") return parseRgbColor(body); From ae02d72ffd9aef3c8974b48885217254510b89c9 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 19:47:58 -0400 Subject: [PATCH 41/42] refactor(mobile): apply anti-slop lint pass --- ...ithIosSplashScreenInlineBackground.test.ts | 6 +- apps/mobile/src/Stack.tsx | 5 +- .../AppearancePreferencesProvider.tsx | 3 +- apps/mobile/src/lib/mobileTheme.ts | 63 +++++----- apps/mobile/src/lib/mobileThemeFile.ts | 115 ++++++++++++------ .../src/persistence/mobile-preferences.ts | 8 +- .../state/synced-client-preferences-model.ts | 5 +- .../src/state/synced-client-preferences.ts | 24 ++-- .../hooks/synced-client-preferences.test.ts | 10 +- .../src/hooks/synced-client-preferences.ts | 25 +++- 10 files changed, 163 insertions(+), 101 deletions(-) diff --git a/apps/mobile/plugins/withIosSplashScreenInlineBackground.test.ts b/apps/mobile/plugins/withIosSplashScreenInlineBackground.test.ts index fa226b071366..1d5e645d132e 100644 --- a/apps/mobile/plugins/withIosSplashScreenInlineBackground.test.ts +++ b/apps/mobile/plugins/withIosSplashScreenInlineBackground.test.ts @@ -29,9 +29,11 @@ type StoryboardModResults = { }; const require = NodeModule.createRequire(import.meta.url); -const { inlineSplashScreenBackground } = require("./withIosSplashScreenInlineBackground.cjs") as { +const { + inlineSplashScreenBackground, +}: { inlineSplashScreenBackground: (modResults: StoryboardModResults) => StoryboardModResults; -}; +} = require("./withIosSplashScreenInlineBackground.cjs"); describe("iOS splash screen inline background", () => { it("transforms Expo's real storyboard modResults shape", () => { diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index ac9bf72a038c..bf98abc508ff 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -86,14 +86,15 @@ type AppNavigationTheme = ReactNavigation.Theme & { }; 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) { - // native-stack types this as `string`, but the native side accepts any - // ColorValue including DynamicColorIOS. + // SAFETY: native-stack types this as `string`, but the native side accepts + // every ColorValue, including DynamicColorIOS. return themedFormSheetContentStyle(theme) as { readonly backgroundColor: string }; } diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index fad58e4cfcf3..f2b1b0be11c0 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -36,6 +36,7 @@ import { import { addImportedMobileTheme, parseMobileThemeFileJson, + sanitizeImportedMobileThemes, type ImportedMobileTheme, } from "../../../lib/mobileThemeFile"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; @@ -130,7 +131,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN AsyncResult.isSuccess(preferencesResult) ? (preferencesResult.value.importedThemes ?? []) : [], ); const importedThemes = useMemo( - () => JSON.parse(importedThemesJson) as ReadonlyArray, + () => sanitizeImportedMobileThemes(JSON.parse(importedThemesJson)), [importedThemesJson], ); const themePreferences = useMemo( diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 977192d00aae..510e18bd8409 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -83,8 +83,9 @@ export interface MobileThemePickerOption { type MobileThemeVariants = Readonly< Record>> >; +type StoredMobileThemeValue = string | null | undefined; -const T3_CODE_COLORS: Readonly> = { +const T3_CODE_COLORS = { light: { canvas: "#f2f2f7", surface: "#ffffff", @@ -141,10 +142,11 @@ const T3_CODE_COLORS: 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]}`); @@ -153,12 +155,12 @@ function normalizeMobileThemeColors(colors: MobileCoreThemeColors): MobileCoreTh return normalizedColors; } -const NORMALIZED_T3_CODE_COLORS: Readonly> = { +const NORMALIZED_T3_CODE_COLORS = { light: normalizeMobileThemeColors(T3_CODE_COLORS.light), dark: normalizeMobileThemeColors(T3_CODE_COLORS.dark), -}; +} satisfies Readonly>; -const T3_CHAT_COLORS: MobileThemeVariants = { +const T3_CHAT_COLORS = { light: { canvas: "#fdf7fd", surface: "#faf3fb", @@ -215,7 +217,7 @@ const T3_CHAT_COLORS: MobileThemeVariants = { sidebarMutedForeground: "#e7d0dd", sidebarControlSurface: "#261922", }, -}; +} satisfies MobileThemeVariants; const MANAGED_LIGHT_COLORS = { text: "#241523", @@ -242,16 +244,14 @@ const MANAGED_DARK_COLORS = { function managedTheme( light: Readonly>, dark: Readonly>, -): MobileThemeVariants { +) { return { light: { ...MANAGED_LIGHT_COLORS, ...light }, dark: { ...MANAGED_DARK_COLORS, ...dark }, - }; + } satisfies MobileThemeVariants; } -const THEME_VARIANTS: Readonly< - Record, MobileThemeVariants> -> = { +const THEME_VARIANTS = { "t3-chat": T3_CHAT_COLORS, grove: managedTheme( { @@ -421,28 +421,35 @@ const THEME_VARIANTS: Readonly< sidebarControlSurface: "#494459", }, ), -}; +} satisfies Readonly, MobileThemeVariants>>; -export function isMobileAppearanceMode(value: unknown): value is MobileAppearanceMode { +export function isMobileAppearanceMode( + value: StoredMobileThemeValue, +): value is MobileAppearanceMode { return MOBILE_APPEARANCE_OPTIONS.some((option) => option.id === value); } -export function isMobileBuiltInThemeId(value: unknown): value is MobileBuiltInThemeId { +export function isMobileBuiltInThemeId( + value: StoredMobileThemeValue, +): value is MobileBuiltInThemeId { return MOBILE_THEME_OPTIONS.some((theme) => theme.id === value); } export function isMobileThemeId( - value: unknown, + value: StoredMobileThemeValue, importedThemes: ReadonlyArray = [], ): value is MobileThemeId { - return ( - isMobileBuiltInThemeId(value) || - (typeof value === "string" && importedThemes.some((theme) => theme.id === value)) - ); + return isMobileBuiltInThemeId(value) || importedThemes.some((theme) => theme.id === value); } export function resolveMobileThemePreferences( - stored: { readonly appearanceMode?: unknown; readonly themeId?: unknown } | null | undefined, + stored: + | { + readonly appearanceMode?: StoredMobileThemeValue; + readonly themeId?: StoredMobileThemeValue; + } + | null + | undefined, importedThemes: ReadonlyArray = [], ): MobileThemePreferences { return { @@ -465,10 +472,10 @@ export function removeImportedMobileTheme( }> | null { const next = importedThemes.filter((theme) => theme.id !== removedThemeId); if (next.length === importedThemes.length) return null; - return { - importedThemes: next, - ...(selectedThemeId === removedThemeId ? { themeId: DEFAULT_MOBILE_THEME_ID } : {}), - }; + if (selectedThemeId === removedThemeId) { + return { importedThemes: next, themeId: DEFAULT_MOBILE_THEME_ID }; + } + return { importedThemes: next }; } export function resolveColorSchemeOverride( @@ -483,10 +490,9 @@ export function resolveMobileThemeColors( importedThemes: ReadonlyArray = [], ): MobileCoreThemeColors { const fallback = T3_CODE_COLORS[appearance]; - if (themeId === "t3-code") return fallback; if (isMobileBuiltInThemeId(themeId)) { - const alternateThemeId = themeId as Exclude; - return { ...fallback, ...THEME_VARIANTS[alternateThemeId][appearance] }; + if (themeId === "t3-code") return fallback; + return { ...fallback, ...THEME_VARIANTS[themeId][appearance] }; } const theme = importedThemes.find((candidate) => candidate.id === themeId); @@ -530,6 +536,7 @@ 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; @@ -730,7 +737,7 @@ export function resolveMobileThemeVariables( themeId: string, appearance: MobileThemeAppearance, importedThemes: ReadonlyArray = [], -): Readonly> { +) { if (!isMobileThemeId(themeId, importedThemes) || themeId === "t3-code") { return T3_CODE_VARIABLES[appearance]; } diff --git a/apps/mobile/src/lib/mobileThemeFile.ts b/apps/mobile/src/lib/mobileThemeFile.ts index c32aad3f08e5..09d8549587a4 100644 --- a/apps/mobile/src/lib/mobileThemeFile.ts +++ b/apps/mobile/src/lib/mobileThemeFile.ts @@ -1,3 +1,5 @@ +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; @@ -78,6 +80,39 @@ export interface ImportedMobileTheme { 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", @@ -96,7 +131,7 @@ const RESERVED_THEME_IDS = new Set([ "t3-iris", ]); -const NAMED_COLORS: Readonly> = { +const NAMED_COLORS = { aliceblue: "#f0f8ff", antiquewhite: "#faebd7", aqua: "#00ffff", @@ -246,22 +281,19 @@ const NAMED_COLORS: Readonly> = { whitesmoke: "#f5f5f5", yellow: "#ffff00", yellowgreen: "#9acd32", -}; +} as const; +const NAMED_COLOR_BY_NAME = new Map(Object.entries(NAMED_COLORS)); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isThemeAppearance(value: unknown): value is MobileThemeAppearance { +function isThemeAppearance(value: UnparsedMobileThemeValue): value is MobileThemeAppearance { return value === "light" || value === "dark"; } -function isThemeId(value: unknown): value is string { - return typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value); +function isThemeId(value: UnparsedMobileThemeValue): value is string { + return isString(value) && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value); } -function isThemeLabel(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48; +function isThemeLabel(value: UnparsedMobileThemeValue): value is string { + return isString(value) && value.trim().length > 0 && value.trim().length <= 48; } function themeIdFromName(name: string): string { @@ -274,9 +306,11 @@ function themeIdFromName(name: string): string { return normalized || "custom-theme"; } -function parseThemeCollection(value: unknown): Readonly<{ id: string; label: string }> | undefined { - return isRecord(value) && - typeof value.id === "string" && +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() } @@ -703,12 +737,10 @@ function parseOklchColor(body: string): string | null { ); } -export function normalizeMobileThemeColorLiteral(value: unknown): string | null { - if (typeof value !== "string") return null; +export function normalizeMobileThemeColorLiteral(value: UnparsedMobileThemeValue): string | null { + if (!isString(value)) return null; const input = value.trim().toLowerCase(); - const named = Object.prototype.hasOwnProperty.call(NAMED_COLORS, input) - ? NAMED_COLORS[input] - : undefined; + 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]; @@ -732,12 +764,16 @@ export function normalizeMobileThemeColorLiteral(value: unknown): string | null return null; } -function parseThemeColorOverrides(value: unknown): PortableThemeColorOverrides { - if (!isRecord(value)) throw new Error("Theme colors must be objects."); +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 (!PORTABLE_THEME_COLOR_ROLE_SET.has(role)) { + if (!isPortableThemeColorRole(role)) { throw new Error(`"${role}" is not a supported theme color role.`); } const normalized = normalizeMobileThemeColorLiteral(color); @@ -746,7 +782,7 @@ function parseThemeColorOverrides(value: unknown): PortableThemeColorOverrides { `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 as PortableThemeColorRole] = normalized; + overrides[role] = normalized; } if (Object.keys(overrides).length === 0) { throw new Error("Add at least one color role to the theme file."); @@ -754,8 +790,12 @@ function parseThemeColorOverrides(value: unknown): PortableThemeColorOverrides { return overrides; } -export function parseMobileThemeFile(value: unknown): ImportedMobileTheme { - if (!isRecord(value)) throw new Error("Theme files must contain a JSON object."); +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}.`, @@ -768,7 +808,9 @@ export function parseMobileThemeFile(value: unknown): ImportedMobileTheme { if (!isThemeAppearance(appearance)) { throw new Error('Theme files need an appearance of "light" or "dark".'); } - if (!isRecord(value.colors)) throw new Error("Theme files need a colors object."); + if (!isUnparsedMobileThemeRecord(value.colors)) { + throw new Error("Theme files need a colors object."); + } const derivedId = value.id === undefined ? themeIdFromName(name) : null; const id = @@ -783,7 +825,9 @@ export function parseMobileThemeFile(value: unknown): ImportedMobileTheme { const colors = parseThemeColorOverrides(value.colors); const variants: Partial> = {}; if (value.variants !== undefined) { - if (!isRecord(value.variants)) throw new Error("Theme variants must be an object."); + 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".'); @@ -800,25 +844,26 @@ export function parseMobileThemeFile(value: unknown): ImportedMobileTheme { throw new Error("Theme collections need a valid id and label."); } - return { + const theme = { version: MOBILE_THEME_FILE_VERSION, id, name: name.trim(), appearance, colors, - ...(Object.keys(variants).length > 0 ? { variants } : {}), - ...(collection ? { collection } : {}), - ...(value.managed === true ? { managed: true } : {}), }; + 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: unknown; + let parsed: UnparsedMobileThemeValue; try { - parsed = JSON.parse(value); + parsed = decodeUnparsedMobileThemeValue(JSON.parse(value)); } catch { throw new Error("Theme files must contain valid JSON."); } @@ -842,7 +887,9 @@ export function addImportedMobileTheme( return next; } -export function sanitizeImportedMobileThemes(value: unknown): ReadonlyArray { +export function sanitizeImportedMobileThemes( + value: UnparsedMobileThemeValue | ReadonlyArray, +): ReadonlyArray { if (!Array.isArray(value)) return []; const themes: ImportedMobileTheme[] = []; let libraryBytes = 2; diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 1e6efba0d608..afc518cbd1f2 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -14,7 +14,7 @@ import { import { isMobileAppearanceMode, - isMobileBuiltInThemeId, + isMobileThemeId, type MobileAppearanceMode, } from "../lib/mobileTheme"; import { sanitizeImportedMobileThemes, type ImportedMobileTheme } from "../lib/mobileThemeFile"; @@ -123,11 +123,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { } const importedThemes = sanitizeImportedMobileThemes(parsed.importedThemes); if (importedThemes.length > 0) preferences.importedThemes = importedThemes; - if ( - isMobileBuiltInThemeId(parsed.themeId) || - (typeof parsed.themeId === "string" && - importedThemes.some((theme) => theme.id === parsed.themeId)) - ) { + if (isMobileThemeId(parsed.themeId, importedThemes)) { preferences.themeId = parsed.themeId; } if (typeof parsed.liveActivitiesEnabled === "boolean") { diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index c9bdddaa1336..6ebce3c9ce0e 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -18,6 +18,7 @@ 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; @@ -508,9 +509,7 @@ export function reconcileSyncedClientPreferences(input: { field: SyncedClientPreferenceField, value: SyncedClientPreferencesPatch[SyncedClientPreferenceField] | undefined, ) => - field === "themeId" && typeof value === "string" - ? (input.normalizeThemeId?.(value) ?? value) - : value; + 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]); diff --git a/apps/mobile/src/state/synced-client-preferences.ts b/apps/mobile/src/state/synced-client-preferences.ts index 2740828fa0b4..95cc1d643b49 100644 --- a/apps/mobile/src/state/synced-client-preferences.ts +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -115,16 +115,11 @@ export function useSyncedClientPreferences(): void { reportFailure: false, }); const reconciliationControllers = useMemo( - () => - Object.fromEntries( - SYNCED_CLIENT_PREFERENCE_FIELDS.map((field) => [ - field, - createSyncedClientPreferenceReconciliationController(field), - ]), - ) as Record< - SyncedClientPreferenceField, - ReturnType - >, + () => ({ + planModeEnabled: createSyncedClientPreferenceReconciliationController("planModeEnabled"), + appearanceMode: createSyncedClientPreferenceReconciliationController("appearanceMode"), + themeId: createSyncedClientPreferenceReconciliationController("themeId"), + }), [], ); @@ -204,11 +199,12 @@ export function useSyncedClientPreferences(): void { normalizeThemeId, }); if (reconciliation.localPatch !== null) { + const localValues = { ...reconciliation.localPatch.values }; + if (localValues.themeId !== undefined) { + localValues.themeId = normalizeThemeId(localValues.themeId); + } savePreferences({ - ...reconciliation.localPatch.values, - ...(reconciliation.localPatch.values.themeId === undefined - ? {} - : { themeId: normalizeThemeId(reconciliation.localPatch.values.themeId) }), + ...localValues, syncedClientPreferencesUpdatedAtByField: reconciliation.localPatch.updatedAtByField, }); } diff --git a/apps/web/src/hooks/synced-client-preferences.test.ts b/apps/web/src/hooks/synced-client-preferences.test.ts index 99e29b143e4c..8ba074f9829d 100644 --- a/apps/web/src/hooks/synced-client-preferences.test.ts +++ b/apps/web/src/hooks/synced-client-preferences.test.ts @@ -28,8 +28,8 @@ function createHookTestRoot() { removeEventListener: noop, defaultView: globalThis, activeElement: null, - body: null as unknown, - documentElement: null as unknown, + body: null, + documentElement: null, }; const container = { nodeType: 1, @@ -44,14 +44,14 @@ function createHookTestRoot() { parentNode: null, textContent: "", }; - document.body = container; - document.documentElement = container; + 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); - return createRoot(container as unknown as Element); + // 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", () => { diff --git a/apps/web/src/hooks/synced-client-preferences.ts b/apps/web/src/hooks/synced-client-preferences.ts index c60be6272c29..5b49978d481f 100644 --- a/apps/web/src/hooks/synced-client-preferences.ts +++ b/apps/web/src/hooks/synced-client-preferences.ts @@ -26,15 +26,30 @@ export function createSyncedClientPreferencesSliceAtom( } type SyncedClientPreferenceValue = Exclude< - SyncedClientPreferencesPatch[Field], + SyncedClientPreferences[Field], undefined >; +type MutableSyncedClientPreferencesPatch = { + -readonly [Field in SyncedClientPreferenceField]?: SyncedClientPreferencesPatch[Field]; +}; + function syncedClientPreferencePatch( field: Field, value: SyncedClientPreferenceValue, ): SyncedClientPreferencesPatch { - return { [field]: value } as 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< @@ -122,9 +137,7 @@ export function resolveSyncedClientPreferenceHydrationAction< return { type: "none" }; } const serverPreferences = input.serverPreferences; - const serverValue = serverPreferences?.[input.field] as - | SyncedClientPreferenceValue - | undefined; + const serverValue = syncedClientPreferenceValue(serverPreferences, input.field); if (serverValue !== undefined) { return input.adoptedUpdatedAt !== undefined && serverUpdatedAt !== undefined && @@ -263,7 +276,7 @@ export function createSyncedClientPreferenceHydrationController< state.patchAttempt = 0; if (matchingWrite) delete state.writePending; const resultUpdatedAt = getSyncedClientPreferenceUpdatedAt(input.result.value, field); - const resultValue = input.result.value[field] as SyncedClientPreferenceValue | undefined; + const resultValue = syncedClientPreferenceValue(input.result.value, field); if (resultUpdatedAt !== undefined && resultValue !== undefined) { state.pendingAdoption = { value: resultValue, updatedAt: resultUpdatedAt }; } From d84cff676fa6ed34deea0bdd6f780c4edbc9115d Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 15 Aug 2026 20:35:13 -0400 Subject: [PATCH 42/42] fix(mobile): outbound stamp clamp and custom-theme sync scope --- .../state/synced-client-preferences-model.ts | 8 ++--- .../state/synced-client-preferences.test.ts | 33 +++++++++++++++++++ .../hooks/synced-client-preferences.test.ts | 16 +++++++++ .../src/hooks/synced-client-preferences.ts | 10 ++++-- apps/web/src/hooks/useSettings.test.ts | 14 +++++++- apps/web/src/hooks/useSettings.ts | 13 +++++--- apps/web/src/themePalette.ts | 5 +++ 7 files changed, 85 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts index 6ebce3c9ce0e..3a06630b2666 100644 --- a/apps/mobile/src/state/synced-client-preferences-model.ts +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -526,16 +526,14 @@ export function reconcileSyncedClientPreferences(input: { environmentCandidates.sort(compareEnvironmentPreferenceCandidates); const latestEnvironment = environmentCandidates.at(-1); const latestObservedEnvironmentUpdatedAt = latestEnvironment?.updatedAt; + const localUpdatedAtRebase = latestObservedEnvironmentUpdatedAt ?? input.now; const boundedLocalUpdatedAt = hasPatchableEnvironment && localUpdatedAt !== undefined && - latestObservedEnvironmentUpdatedAt !== undefined && - localUpdatedAt > latestObservedEnvironmentUpdatedAt && + localUpdatedAt > localUpdatedAtRebase && Date.parse(localUpdatedAt) > Date.parse(input.now) + SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS - ? nextMobileSyncedPreferencesUpdatedAt([], latestObservedEnvironmentUpdatedAt, [ - latestObservedEnvironmentUpdatedAt, - ]) + ? nextMobileSyncedPreferencesUpdatedAt([], localUpdatedAtRebase, [localUpdatedAtRebase]) : localUpdatedAt; // Exact remote ties use environment id for deterministic convergence. const localWins = diff --git a/apps/mobile/src/state/synced-client-preferences.test.ts b/apps/mobile/src/state/synced-client-preferences.test.ts index b715713b9d4a..94686eb75527 100644 --- a/apps/mobile/src/state/synced-client-preferences.test.ts +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -1033,6 +1033,39 @@ describe("synced client preferences", () => { ]); }); + 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"), diff --git a/apps/web/src/hooks/synced-client-preferences.test.ts b/apps/web/src/hooks/synced-client-preferences.test.ts index 8ba074f9829d..913d84465f36 100644 --- a/apps/web/src/hooks/synced-client-preferences.test.ts +++ b/apps/web/src/hooks/synced-client-preferences.test.ts @@ -212,6 +212,22 @@ describe("synced client preferences", () => { }); }); + 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({ diff --git a/apps/web/src/hooks/synced-client-preferences.ts b/apps/web/src/hooks/synced-client-preferences.ts index 5b49978d481f..52ca290b3978 100644 --- a/apps/web/src/hooks/synced-client-preferences.ts +++ b/apps/web/src/hooks/synced-client-preferences.ts @@ -91,7 +91,7 @@ export interface SyncedClientPreferenceHydrationInput< readonly environmentId: EnvironmentId | null; readonly primaryEnvironmentId: EnvironmentId | null; readonly clientHydrated: boolean; - readonly clientValue: SyncedClientPreferenceValue; + readonly clientValue: SyncedClientPreferenceValue | undefined; readonly live: boolean; readonly serverPreferences: SyncedClientPreferences | undefined; readonly canPatch: boolean; @@ -118,7 +118,7 @@ export function resolveSyncedClientPreferenceHydrationAction< >(input: { readonly field: Field; readonly clientHydrated: boolean; - readonly clientValue: SyncedClientPreferenceValue; + readonly clientValue: SyncedClientPreferenceValue | undefined; readonly serverPreferences: SyncedClientPreferences | undefined; readonly seedPending: boolean; readonly writePending?: { @@ -128,7 +128,7 @@ export function resolveSyncedClientPreferenceHydrationAction< readonly adoptedUpdatedAt?: string; readonly now: string; }): SyncedClientPreferenceHydrationAction { - if (!input.clientHydrated) return { type: "none" }; + if (!input.clientHydrated || input.clientValue === undefined) return { type: "none" }; const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt(input.serverPreferences, input.field); if ( input.writePending !== undefined && @@ -306,6 +306,10 @@ export function createSyncedClientPreferenceHydrationController< ) => { const environmentId = input.environmentId; if (environmentId === null) return; + if (input.clientValue === undefined) { + deactivate(environmentId, owner); + return; + } if (!input.live) { deactivate(environmentId, owner); return; diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 0ac7fa98c458..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", () => { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 89289632efba..1803378d33cd 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -35,6 +35,7 @@ import { getThemePreferenceMode, getThemeDefinition, getThemePreviewSidebarArtwork, + isBuiltInThemePreference, isKnownThemePreference, resolveThemeHalf, subscribeToThemePreview, @@ -288,10 +289,11 @@ function useMergedSettings( export const DEFAULT_SYNCED_THEME_ID = "t3-code"; -export function syncedThemeIdFromPreference(theme: ThemePreference): string { - return theme === "system" || theme === "light" || theme === "dark" - ? DEFAULT_SYNCED_THEME_ID - : canonicalThemePreference(theme); +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 { @@ -481,7 +483,8 @@ export function useSyncedTheme() { const setTheme = useCallback( (theme: ThemePreference): boolean => { if (!state.themeState.setTheme(theme)) return false; - write({ themeId: syncedThemeIdFromPreference(theme) }); + const themeId = syncedThemeIdFromPreference(theme); + if (themeId !== undefined) write({ themeId }); return true; }, [state.themeState, write], 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);