diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7cffbf62b0d7..e5eeb8634695 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -73,6 +73,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; +import { useSyncedClientPreferences } from "./state/synced-client-preferences"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -358,6 +359,11 @@ function ThreadOutboxDrainWorker() { return null; } +function SyncedClientPreferencesWorker() { + useSyncedClientPreferences(); + return null; +} + function RootStackLayout(props: { readonly children: React.ReactNode; readonly state: NavigationState; @@ -394,6 +400,7 @@ function RootStackLayout(props: { return ( + diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 32f915e7af5c..40fceff336fb 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -124,6 +124,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..6f2bfb2f3f21 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 { useLegacyPlanModeEnabled } from "../threads/use-legacy-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 = useLegacyPlanModeEnabled(); 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 8f5beb69c938..40df4ff63666 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 } from "react-native"; import { KeyboardController, @@ -17,12 +17,17 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { themeColorWithAlpha } from "../../lib/mobileTheme"; 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, @@ -35,6 +40,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, @@ -65,7 +71,20 @@ import { resolveNewTaskBranchLabel, resolveNewTaskWorkspaceLabel, } from "./new-task-context-presentation"; +import { + canSubmitNewTaskDraft, + resolveNewTaskComposerSelection, + shouldApplyNewTaskCommandSelection, + shouldInterpretNewTaskSubmit, +} from "./new-task-submit"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; +import { + getPlanModeComposerSlashCommands, + replaceCurrentComposerTrigger, + resolveComposerInteractionMode, + resolveComposerSubmitInteractionMode, + resolveSlashCommandInteractionMode, +} from "./legacy-plan-mode"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; @@ -100,6 +119,7 @@ export function NewTaskDraftScreen(props: { const projects = useProjects(); const createProjectThread = useCreateProjectThread(); const flow = useNewTaskFlow(); + const planModeEnabled = flow.planModeEnabled; const navigation = useNavigation(); const { consumeShare, @@ -124,6 +144,13 @@ export function NewTaskDraftScreen(props: { (environment) => environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; const promptInputRef = useRef(null); + const startInFlightRef = useRef(false); + const composerSelectionRef = useRef({ + start: flow.prompt.length, + end: flow.prompt.length, + }); + const composerSelectionDraftKeyRef = useRef(flow.draftKey); + const [composerSelection, setComposerSelection] = useState(composerSelectionRef.current); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ @@ -223,6 +250,41 @@ export function NewTaskDraftScreen(props: { !props.incomingShareId || (hasImportedIncomingShare && !incomingShare) || isIncomingShareUnavailable; + const composerTrigger = useMemo(() => { + if (composerSelection.start !== composerSelection.end) { + return null; + } + const trigger = detectComposerTrigger(flow.prompt, composerSelection.end); + return trigger?.kind === "slash-command" ? trigger : null; + }, [composerSelection, flow.prompt]); + const composerMenuItems = useMemo( + () => + composerTrigger + ? getPlanModeComposerSlashCommands({ + planModeEnabled, + query: composerTrigger.query, + }) + : [], + [composerTrigger, planModeEnabled], + ); + const updateComposerSelection = useCallback((selection: ComposerEditorSelection) => { + composerSelectionRef.current = selection; + setComposerSelection(selection); + }, []); + useEffect(() => { + const previousDraftKey = composerSelectionDraftKeyRef.current; + composerSelectionDraftKeyRef.current = flow.draftKey; + const next = resolveNewTaskComposerSelection({ + previousDraftKey, + draftKey: flow.draftKey, + promptLength: flow.prompt.length, + selection: composerSelectionRef.current, + }); + const current = composerSelectionRef.current; + if (next.start !== current.start || next.end !== current.end) { + updateComposerSelection(next); + } + }, [flow.draftKey, flow.prompt.length, updateComposerSelection]); const appliedInitialProjectKeyRef = useRef(null); useEffect(() => { if (cancelledIncomingShareId === props.incomingShareId) { @@ -636,6 +698,55 @@ export function NewTaskDraftScreen(props: { [flow], ); + const handleCommandSelect = useCallback( + (item: ComposerCommandItem): void => { + const draftKey = flow.draftKey; + if ( + !shouldApplyNewTaskCommandSelection({ + incomingShareTransferPending: isIncomingShareTransferPending, + }) || + !draftKey || + !composerTrigger || + item.type !== "slash-command" + ) { + return; + } + const interactionMode = resolveSlashCommandInteractionMode({ + command: item.command, + planModeEnabled, + }); + if (interactionMode === null) { + return; + } + const draft = getComposerDraftSnapshot(draftKey); + const result = replaceCurrentComposerTrigger({ + text: draft.text, + selection: composerSelectionRef.current, + expectedKind: composerTrigger.kind, + expectedText: flow.prompt.slice(composerTrigger.rangeStart, composerTrigger.rangeEnd), + replacement: "", + extendSlashCommandToken: true, + }); + if (!result) { + console.warn("[new-task] composer trigger changed before command selection"); + return; + } + updateComposerSelection({ start: result.cursor, end: result.cursor }); + flow.setPrompt(result.text); + flow.setInteractionMode(interactionMode); + }, + [ + composerTrigger, + flow.draftKey, + flow.prompt, + flow.setInteractionMode, + flow.setPrompt, + isIncomingShareTransferPending, + planModeEnabled, + updateComposerSelection, + ], + ); + async function handleStart(): Promise { const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; @@ -643,6 +754,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. @@ -657,16 +781,24 @@ export function NewTaskDraftScreen(props: { draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; - const interactionMode = flow.planModeEnabled - ? (draft.interactionMode ?? flow.interactionMode) - : "default"; + const interactionMode = resolveComposerInteractionMode({ + interactionMode: draft.interactionMode ?? flow.interactionMode, + planModeEnabled, + }); const initialMessageText = draft.text.trim(); if ( !modelSelection || - initialMessageText.length === 0 || - flow.submitting || - (workspaceMode === "worktree" && !selectedBranchName) + startInFlightRef.current || + !canSubmitNewTaskDraft({ + text: draft.text, + incomingShareReady: isIncomingShareReady, + importingShare: isImportingShare, + planModePreferenceLoaded: flow.planModePreferenceLoaded, + submitting: flow.submitting, + workspaceMode, + selectedBranchName, + }) ) { return; } @@ -689,6 +821,7 @@ export function NewTaskDraftScreen(props: { if (!message) { return; } + startInFlightRef.current = true; flow.setSubmitting(true); try { await enqueueThreadOutboxMessage(message); @@ -700,6 +833,7 @@ export function NewTaskDraftScreen(props: { return; } finally { flow.setSubmitting(false); + startInFlightRef.current = false; } if (editingPendingTask) { flow.finishEditingPendingTask(); @@ -713,7 +847,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 @@ -728,57 +861,65 @@ export function NewTaskDraftScreen(props: { selectedBranch: selectedBranchName, currentCheckoutBranch: flow.currentCheckoutBranchName, }); - const result = await createProjectThread({ - project: selectedProject, - modelSelection, - envMode: workspaceMode, - branch: creationBranch, - worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, - startFromOrigin, - runtimeMode, - interactionMode, - initialMessageText, - initialAttachments: draft.attachments, - ...(editingPendingTask - ? { - turnMetadata: { - threadId: editingPendingTask.threadId, - commandId: editingPendingTask.commandId, - messageId: editingPendingTask.messageId, - createdAt: editingPendingTask.createdAt, - }, - } - : {}), - }); - flow.setSubmitting(false); + startInFlightRef.current = true; + flow.setSubmitting(true); + try { + let createInput: Parameters[0] = { + project: selectedProject, + modelSelection, + envMode: workspaceMode, + branch: creationBranch, + worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, + startFromOrigin, + runtimeMode, + interactionMode, + initialMessageText, + initialAttachments: draft.attachments, + }; + if (editingPendingTask) { + createInput = { + ...createInput, + turnMetadata: { + threadId: editingPendingTask.threadId, + commandId: editingPendingTask.commandId, + messageId: editingPendingTask.messageId, + createdAt: editingPendingTask.createdAt, + }, + }; + } + const result = await createProjectThread(createInput); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Alert.alert( - "Could not start task", - error instanceof Error ? error.message : "The task could not be started.", - ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not start task", + error instanceof Error ? error.message : "The task could not be started.", + ); + } + return; } - return; - } - if (editingPendingTask) { - try { - await removeThreadOutboxMessage(editingPendingTask); - } catch (error) { - console.warn("[new-task] failed to remove delivered pending task", error); + if (editingPendingTask) { + try { + await removeThreadOutboxMessage(editingPendingTask); + } catch (error) { + console.warn("[new-task] failed to remove delivered pending task", error); + } + flow.finishEditingPendingTask(); + } else { + clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); } - flow.finishEditingPendingTask(); - } else { - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(result.value.environmentId), + threadId: String(result.value.threadId), + }), + ); + } finally { + flow.setSubmitting(false); + startInFlightRef.current = false; } - navigation.dispatch( - StackActions.replace("Thread", { - environmentId: String(result.value.environmentId), - threadId: String(result.value.threadId), - }), - ); } if (!selectedProject) { @@ -801,11 +942,24 @@ 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, + planModePreferenceLoaded: flow.planModePreferenceLoaded, + submitting: flow.submitting, + workspaceMode: flow.workspaceMode, + selectedBranchName: flow.selectedBranchName, + }); + const commandPopover = + composerTrigger && composerMenuItems.length > 0 ? ( + + ) : null; const promptEditor = ( setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} + onSubmit={() => void handleStart()} placeholder="Ask anything…" singleLineCentered={false} contentInsetVertical={0} @@ -955,7 +1112,10 @@ export function NewTaskDraftScreen(props: { ); const composerDock = ( - + + {commandPopover ? ( + {commandPopover} + ) : null} {workspaceControls} 0 ? "Queue" : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; + const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); + const canSend = canSubmitExistingThreadDraft({ hasContent, planModePreferenceLoaded }); const connectionStatus = composerConnectionStatus({ connectionError: props.connectionError, connectionState: props.connectionState, @@ -356,25 +365,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); // ── Trigger detection ──────────────────────────────────── - const [composerSelection, setComposerSelection] = useState(() => ({ + const composerSelectionRef = useRef({ start: props.draftMessage.length, end: props.draftMessage.length, - })); + }); + const [composerSelection, setComposerSelection] = useState(composerSelectionRef.current); - const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => { + const updateComposerSelection = useCallback((selection: ComposerEditorSelection) => { + composerSelectionRef.current = selection; setComposerSelection(selection); }, []); useEffect(() => { const end = props.draftMessage.length; - setComposerSelection((selection) => { - const start = Math.min(selection.start, end); - const selectionEnd = Math.min(selection.end, end); - if (start === selection.start && selectionEnd === selection.end) { - return selection; - } - return { start, end: selectionEnd }; - }); - }, [props.draftMessage.length]); + const selection = composerSelectionRef.current; + const next = { + start: Math.min(selection.start, end), + end: Math.min(selection.end, end), + }; + if (next.start !== selection.start || next.end !== selection.end) { + updateComposerSelection(next); + } + }, [props.draftMessage.length, updateComposerSelection]); const composerTrigger = useMemo(() => { if (composerSelection.start !== composerSelection.end) { @@ -393,30 +404,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 ?? []) { @@ -531,77 +519,108 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + }, [composerTrigger, pathSearch.entries, planModeEnabled, selectedProviderStatus]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; const handleSend = useCallback(async () => { + if (!canSend) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const draft = getComposerDraftSnapshot(threadKey); + const submitInteractionMode = resolveComposerSubmitInteractionMode({ + text: draft.text, + attachmentCount: draft.attachments.length, + planModeEnabled, + }); + if (submitInteractionMode !== null) { + updateComposerSelection({ start: 0, end: 0 }); + onChangeDraftMessage(""); + onUpdateInteractionMode(submitInteractionMode); + return; + } + if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); try { - await onSendMessage(); - // Sending a prompt starts agent work: arm the lock-screen card while the - // app is foregrounded and the activity token can be registered. Armed - // after the send so its preference read and native Activity start don't - // contend with the queued-message feedback on the tap frame. - armAgentAwarenessLiveActivityForLocalWork({ - environmentId: props.environmentId, - threadTitle: props.selectedThread.title, - projectTitle: props.environmentLabel ?? "T3 Code", + await sendThreadComposerMessage({ + onSendMessage, + onSent: () => { + // Sending a prompt starts agent work: arm the lock-screen card while the + // app is foregrounded and the activity token can be registered. Armed + // after the send so its preference read and native Activity start don't + // contend with the queued-message feedback on the tap frame. + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: props.environmentId, + threadTitle: props.selectedThread.title, + projectTitle: props.environmentLabel ?? "T3 Code", + }); + }, }); } finally { inFlightThreadIdsRef.current.delete(threadKey); } }, [ + canSend, + onChangeDraftMessage, onSendMessage, + onUpdateInteractionMode, + planModeEnabled, props.environmentId, props.environmentLabel, props.selectedThread.id, props.selectedThread.title, + updateComposerSelection, ]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { if (!composerTrigger) return; - if ( - item.type === "slash-command" && - (item.command === "plan" || item.command === "default") - ) { - const result = replaceTextRange( - draftMessage, - composerTrigger.rangeStart, - composerTrigger.rangeEnd, - "", - ); - setComposerSelection({ start: result.cursor, end: result.cursor }); - onChangeDraftMessage(result.text); - onUpdateInteractionMode(item.command); - return; - } - + const interactionMode = + item.type === "slash-command" + ? resolveSlashCommandInteractionMode({ + command: item.command, + planModeEnabled, + }) + : null; let replacement = ""; if (item.type === "path") { replacement = `${serializeComposerFileLink(item.path)} `; } else if (item.type === "skill") { replacement = `$${item.skill.name} `; } else if (item.type === "slash-command") { - replacement = `/${item.command} `; + replacement = interactionMode === null ? `/${item.command} ` : ""; } else if (item.type === "provider-slash-command") { replacement = `/${item.command.name} `; } - const result = replaceTextRange( - draftMessage, - composerTrigger.rangeStart, - composerTrigger.rangeEnd, + const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const draft = getComposerDraftSnapshot(threadKey); + const result = replaceCurrentComposerTrigger({ + text: draft.text, + selection: composerSelectionRef.current, + expectedKind: composerTrigger.kind, + expectedText: draftMessage.slice(composerTrigger.rangeStart, composerTrigger.rangeEnd), replacement, - ); - setComposerSelection({ start: result.cursor, end: result.cursor }); + extendSlashCommandToken: true, + }); + if (!result) return; + updateComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); + if (interactionMode !== null) { + onUpdateInteractionMode(interactionMode); + } }, - [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], + [ + composerTrigger, + draftMessage, + onChangeDraftMessage, + onUpdateInteractionMode, + planModeEnabled, + props.environmentId, + props.selectedThread.id, + updateComposerSelection, + ], ); // ── Model menu ─────────────────────────────────────────── @@ -786,7 +805,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer skills={selectedProviderStatus?.skills ?? []} selection={composerSelection} onChangeText={props.onChangeDraftMessage} - onSelectionChange={handleSelectionChange} + onSelectionChange={updateComposerSelection} onPasteImages={(uris) => void props.onNativePasteImages(uris)} placeholder={props.placeholder} onFocus={handleFocus} diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.composer.test.ts b/apps/mobile/src/features/threads/legacy-plan-mode.composer.test.ts new file mode 100644 index 000000000000..b290155b4438 --- /dev/null +++ b/apps/mobile/src/features/threads/legacy-plan-mode.composer.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + canSubmitExistingThreadDraft, + getBuiltInComposerSlashCommands, + getPlanModeComposerSlashCommands, + replaceCurrentComposerTrigger, + resolveComposerEnqueueInteractionMode, + resolveComposerInteractionMode, + resolveComposerSubmitInteractionMode, + resolveLegacyPlanModeEnabled, + resolveSlashCommandInteractionMode, +} from "./legacy-plan-mode"; + +describe("mobile plan mode", () => { + it("defaults off and hides the legacy slash commands", () => { + const planModeEnabled = resolveLegacyPlanModeEnabled({ loaded: true, preference: undefined }); + + expect(planModeEnabled).toBe(false); + expect(getBuiltInComposerSlashCommands({ planModeEnabled, query: "" })).toEqual([ + expect.objectContaining({ command: "model" }), + ]); + }); + + it("shows the legacy slash commands when enabled", () => { + expect( + getBuiltInComposerSlashCommands({ planModeEnabled: true, query: "" }).map( + (item) => item.command, + ), + ).toEqual(["model", "plan", "default"]); + }); + + it("matches slash commands case-insensitively in both composers", () => { + expect( + getBuiltInComposerSlashCommands({ planModeEnabled: true, query: "PL" }).map( + (item) => item.command, + ), + ).toEqual(["plan"]); + expect( + getPlanModeComposerSlashCommands({ planModeEnabled: true, query: "PL" }).map( + (item) => item.command, + ), + ).toEqual(["plan"]); + }); + + it("only applies plan mode slash commands when enabled", () => { + expect(resolveSlashCommandInteractionMode({ command: "plan", planModeEnabled: false })).toBe( + null, + ); + expect(resolveSlashCommandInteractionMode({ command: "default", planModeEnabled: false })).toBe( + null, + ); + expect(resolveSlashCommandInteractionMode({ command: "plan", planModeEnabled: true })).toBe( + "plan", + ); + expect(resolveSlashCommandInteractionMode({ command: "default", planModeEnabled: true })).toBe( + "default", + ); + }); + + it("forces outgoing turns to default mode while disabled", () => { + expect( + resolveComposerInteractionMode({ interactionMode: "plan", planModeEnabled: false }), + ).toBe("default"); + expect(resolveComposerInteractionMode({ interactionMode: "plan", planModeEnabled: true })).toBe( + "plan", + ); + }); + + it("waits for the Plan Mode preference before choosing the queued mode", () => { + expect( + resolveComposerEnqueueInteractionMode({ + interactionMode: "plan", + planModeEnabled: false, + preferenceLoaded: false, + }), + ).toBeNull(); + expect( + resolveComposerEnqueueInteractionMode({ + interactionMode: "plan", + planModeEnabled: true, + preferenceLoaded: true, + }), + ).toBe("plan"); + }); + + describe("existing-thread submit parsing", () => { + it("blocks submission until the plan mode preference is hydrated", () => { + expect( + canSubmitExistingThreadDraft({ + hasContent: true, + planModePreferenceLoaded: false, + }), + ).toBe(false); + expect( + canSubmitExistingThreadDraft({ + hasContent: true, + planModePreferenceLoaded: true, + }), + ).toBe(true); + }); + + it("switches modes for standalone commands while enabled", () => { + expect( + resolveComposerSubmitInteractionMode({ + text: " /PLAN ", + attachmentCount: 0, + planModeEnabled: true, + }), + ).toBe("plan"); + expect( + resolveComposerSubmitInteractionMode({ + text: "/default", + attachmentCount: 0, + planModeEnabled: true, + }), + ).toBe("default"); + }); + + it("keeps typed commands inert while disabled", () => { + expect( + resolveComposerSubmitInteractionMode({ + text: "/plan", + attachmentCount: 0, + planModeEnabled: false, + }), + ).toBeNull(); + }); + + it("sends standalone-looking commands when attachments are present", () => { + expect( + resolveComposerSubmitInteractionMode({ + text: "/plan", + attachmentCount: 1, + planModeEnabled: true, + }), + ).toBeNull(); + }); + + it("sends non-standalone text containing a plan command", () => { + expect( + resolveComposerSubmitInteractionMode({ + text: "please use /plan for this", + attachmentCount: 0, + planModeEnabled: true, + }), + ).toBeNull(); + expect( + resolveComposerSubmitInteractionMode({ + text: "/plan extra", + attachmentCount: 0, + planModeEnabled: true, + }), + ).toBeNull(); + }); + }); + + describe("new-task submit parsing", () => { + it("shows only plan-mode commands in the popover while enabled", () => { + expect( + getPlanModeComposerSlashCommands({ planModeEnabled: true, query: "" }).map( + (item) => item.command, + ), + ).toEqual(["plan", "default"]); + expect(getPlanModeComposerSlashCommands({ planModeEnabled: false, query: "" })).toEqual([]); + }); + + it("keeps the typed command sendable and forces created threads to default while disabled", () => { + expect( + resolveComposerSubmitInteractionMode({ + text: "/plan", + attachmentCount: 0, + planModeEnabled: false, + }), + ).toBeNull(); + expect( + resolveComposerInteractionMode({ interactionMode: "plan", planModeEnabled: false }), + ).toBe("default"); + }); + }); + + describe("composer command replacement", () => { + it("aborts when the current trigger no longer matches the rendered trigger", () => { + expect( + replaceCurrentComposerTrigger({ + text: "/default", + selection: { start: 8, end: 8 }, + expectedKind: "slash-command", + expectedText: "/plan", + replacement: "", + extendSlashCommandToken: true, + }), + ).toBeNull(); + }); + + it("replaces the full slash-command token when the caret is inside it", () => { + expect( + replaceCurrentComposerTrigger({ + text: "/plan do things", + selection: { start: 3, end: 3 }, + expectedKind: "slash-command", + expectedText: "/pl", + replacement: "", + extendSlashCommandToken: true, + }), + ).toEqual({ text: " do things", cursor: 0 }); + }); + + it("preserves the slash-command remainder when token extension is disabled", () => { + expect( + replaceCurrentComposerTrigger({ + text: "/plan do things", + selection: { start: 3, end: 3 }, + expectedKind: "slash-command", + expectedText: "/pl", + replacement: "", + extendSlashCommandToken: false, + }), + ).toEqual({ text: "an do things", cursor: 0 }); + }); + + it("replaces the full path token when the caret is inside it", () => { + expect( + replaceCurrentComposerTrigger({ + text: "Open @src/old-file.ts now", + selection: { start: 13, end: 13 }, + expectedKind: "path", + expectedText: "@src/old", + replacement: "[new-file.ts](src/new-file.ts)", + extendSlashCommandToken: true, + }), + ).toEqual({ + text: "Open [new-file.ts](src/new-file.ts) now", + cursor: 35, + }); + }); + }); +}); diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/features/threads/legacy-plan-mode.ts index e7122125fb58..6a9ec7c355e6 100644 --- a/apps/mobile/src/features/threads/legacy-plan-mode.ts +++ b/apps/mobile/src/features/threads/legacy-plan-mode.ts @@ -2,6 +2,36 @@ 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 resolveLegacyPlanModeEnabled(input: { readonly loaded: boolean; @@ -27,3 +57,104 @@ export function resolvePendingTaskInteractionMode(input: { } return DEFAULT_PROVIDER_INTERACTION_MODE; } + +function composerSlashCommandMatchesQuery(command: string, query: string): boolean { + return command.includes(query.toLowerCase()); +} + +export function getBuiltInComposerSlashCommands(input: { + readonly planModeEnabled: boolean; + readonly query: string; +}) { + return BUILT_IN_COMPOSER_SLASH_COMMANDS.filter( + (item) => + composerSlashCommandMatchesQuery(item.command, input.query) && + (input.planModeEnabled || (item.command !== "plan" && item.command !== "default")), + ); +} + +export function getPlanModeComposerSlashCommands(input: { + readonly planModeEnabled: boolean; + readonly query: string; +}) { + return BUILT_IN_COMPOSER_SLASH_COMMANDS.filter( + (item) => + item.command !== "model" && + input.planModeEnabled && + composerSlashCommandMatchesQuery(item.command, input.query), + ); +} + +export function resolveSlashCommandInteractionMode(input: { + readonly command: string; + readonly planModeEnabled: boolean; +}): ProviderInteractionMode | null { + if (!input.planModeEnabled) return null; + if (input.command === "plan" || input.command === DEFAULT_PROVIDER_INTERACTION_MODE) { + return input.command; + } + return null; +} + +export function resolveComposerSubmitInteractionMode(input: { + readonly text: string; + readonly attachmentCount: number; + readonly planModeEnabled: boolean; +}): ProviderInteractionMode | null { + if (input.attachmentCount > 0) return null; + const command = parseStandaloneComposerSlashCommand(input.text); + return command === null + ? null + : resolveSlashCommandInteractionMode({ command, planModeEnabled: input.planModeEnabled }); +} + +export function replaceCurrentComposerTrigger(input: { + readonly text: string; + readonly selection: { readonly start: number; readonly end: number }; + readonly expectedKind: ComposerTrigger["kind"]; + readonly expectedText: string; + readonly replacement: string; + readonly extendSlashCommandToken: boolean; +}): { readonly text: string; readonly cursor: number } | null { + if (input.selection.start !== input.selection.end) return null; + const trigger = detectComposerTrigger(input.text, input.selection.end); + if ( + trigger?.kind !== input.expectedKind || + input.text.slice(trigger.rangeStart, trigger.rangeEnd) !== input.expectedText + ) { + return null; + } + + let rangeEnd = trigger.rangeEnd; + if (input.extendSlashCommandToken) { + while (rangeEnd < input.text.length && !/\s/u.test(input.text[rangeEnd] ?? "")) { + rangeEnd += 1; + } + } + + return replaceTextRange(input.text, trigger.rangeStart, rangeEnd, input.replacement); +} + +export function resolveComposerInteractionMode(input: { + readonly interactionMode: ProviderInteractionMode | null | undefined; + readonly planModeEnabled: boolean; +}): ProviderInteractionMode { + return input.planModeEnabled + ? (input.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) + : DEFAULT_PROVIDER_INTERACTION_MODE; +} + +export function canSubmitExistingThreadDraft(input: { + readonly hasContent: boolean; + readonly planModePreferenceLoaded: boolean; +}): boolean { + return input.hasContent && input.planModePreferenceLoaded; +} + +export function resolveComposerEnqueueInteractionMode(input: { + readonly interactionMode: ProviderInteractionMode | null | undefined; + readonly planModeEnabled: boolean; + readonly preferenceLoaded: boolean; +}): ProviderInteractionMode | null { + return input.preferenceLoaded ? resolveComposerInteractionMode(input) : null; +} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14f0fcc95a22..d550d96071f8 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 "./legacy-plan-mode"; +import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; type WorkspaceMode = "local" | "worktree"; @@ -144,6 +145,7 @@ type NewTaskFlowContextValue = { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly planModeEnabled: boolean; + readonly planModePreferenceLoaded: boolean; readonly expandedProvider: string | null; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; @@ -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, @@ -1022,6 +1025,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { runtimeMode, interactionMode, planModeEnabled, + planModePreferenceLoaded, expandedProvider, environments, selectedProject, @@ -1074,6 +1078,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { finishEditingPendingTask, interactionMode, planModeEnabled, + planModePreferenceLoaded, loadBranches, loadMoreBranches, projectScopes, diff --git a/apps/mobile/src/features/threads/new-task-submit.test.ts b/apps/mobile/src/features/threads/new-task-submit.test.ts new file mode 100644 index 000000000000..0bb886e74855 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-submit.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + canSubmitNewTaskDraft, + resolveNewTaskComposerSelection, + shouldApplyNewTaskCommandSelection, + shouldInterpretNewTaskSubmit, +} from "./new-task-submit"; + +describe("new-task submission eligibility", () => { + const readyInput = { + text: "Implement the task", + incomingShareReady: true, + importingShare: false, + planModePreferenceLoaded: true, + submitting: false, + workspaceMode: "local" as const, + selectedBranchName: null, + }; + + it("accepts a ready local draft", () => { + expect(canSubmitNewTaskDraft(readyInput)).toBe(true); + }); + + it("blocks submission while incoming share work is incomplete", () => { + expect(canSubmitNewTaskDraft({ ...readyInput, incomingShareReady: false })).toBe(false); + expect(canSubmitNewTaskDraft({ ...readyInput, importingShare: true })).toBe(false); + }); + + it("blocks submission while React state is active", () => { + expect(canSubmitNewTaskDraft({ ...readyInput, submitting: true })).toBe(false); + }); + + it("blocks submission until the plan mode preference is hydrated", () => { + expect(canSubmitNewTaskDraft({ ...readyInput, planModePreferenceLoaded: false })).toBe(false); + }); + + it("requires non-empty text and a non-empty worktree branch", () => { + expect(canSubmitNewTaskDraft({ ...readyInput, text: " \n\t" })).toBe(false); + expect( + canSubmitNewTaskDraft({ + ...readyInput, + workspaceMode: "worktree", + selectedBranchName: null, + }), + ).toBe(false); + expect( + canSubmitNewTaskDraft({ + ...readyInput, + workspaceMode: "worktree", + selectedBranchName: "", + }), + ).toBe(false); + expect( + canSubmitNewTaskDraft({ + ...readyInput, + workspaceMode: "worktree", + selectedBranchName: "main", + }), + ).toBe(true); + }); + + it("interprets submit-time commands only when creating a new task", () => { + expect(shouldInterpretNewTaskSubmit({ editingPendingTask: null })).toBe(true); + expect(shouldInterpretNewTaskSubmit({ editingPendingTask: {} })).toBe(false); + }); + + it("blocks command selection while an incoming share transfer is pending", () => { + expect(shouldApplyNewTaskCommandSelection({ incomingShareTransferPending: false })).toBe(true); + expect(shouldApplyNewTaskCommandSelection({ incomingShareTransferPending: true })).toBe(false); + }); + + it("resets selection to the hydrated draft end only when the draft key changes", () => { + expect( + resolveNewTaskComposerSelection({ + previousDraftKey: "new-task:project-1", + draftKey: "pending-task:message-1", + promptLength: 17, + selection: { start: 2, end: 2 }, + }), + ).toEqual({ start: 17, end: 17 }); + expect( + resolveNewTaskComposerSelection({ + previousDraftKey: "pending-task:message-1", + draftKey: "pending-task:message-1", + promptLength: 4, + selection: { start: 7, end: 7 }, + }), + ).toEqual({ start: 4, end: 4 }); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-submit.ts b/apps/mobile/src/features/threads/new-task-submit.ts new file mode 100644 index 000000000000..f25c62993764 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-submit.ts @@ -0,0 +1,45 @@ +export function shouldInterpretNewTaskSubmit(input: { + readonly editingPendingTask: object | null; +}): boolean { + return input.editingPendingTask === null; +} + +export function shouldApplyNewTaskCommandSelection(input: { + readonly incomingShareTransferPending: boolean; +}): boolean { + return !input.incomingShareTransferPending; +} + +export function resolveNewTaskComposerSelection(input: { + readonly previousDraftKey: string | null; + readonly draftKey: string | null; + readonly promptLength: number; + readonly selection: { readonly start: number; readonly end: number }; +}) { + if (input.previousDraftKey !== input.draftKey) { + return { start: input.promptLength, end: input.promptLength }; + } + return { + start: Math.max(0, Math.min(input.selection.start, input.promptLength)), + end: Math.max(0, Math.min(input.selection.end, input.promptLength)), + }; +} + +export function canSubmitNewTaskDraft(input: { + readonly text: string; + readonly incomingShareReady: boolean; + readonly importingShare: boolean; + readonly planModePreferenceLoaded: boolean; + readonly submitting: boolean; + readonly workspaceMode: "local" | "worktree"; + readonly selectedBranchName: string | null; +}): boolean { + return ( + input.text.trim().length > 0 && + input.incomingShareReady && + !input.importingShare && + input.planModePreferenceLoaded && + !input.submitting && + (input.workspaceMode !== "worktree" || Boolean(input.selectedBranchName)) + ); +} diff --git a/apps/mobile/src/features/threads/thread-composer-send.test.ts b/apps/mobile/src/features/threads/thread-composer-send.test.ts new file mode 100644 index 000000000000..aebc92755b0f --- /dev/null +++ b/apps/mobile/src/features/threads/thread-composer-send.test.ts @@ -0,0 +1,31 @@ +import { MessageId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { sendThreadComposerMessage } from "./thread-composer-send"; + +describe("thread composer send", () => { + it("does not run sent side effects when the draft was not queued", async () => { + const onSent = vi.fn(); + + await expect( + sendThreadComposerMessage({ + onSendMessage: async () => null, + onSent, + }), + ).resolves.toBeNull(); + expect(onSent).not.toHaveBeenCalled(); + }); + + it("runs sent side effects after a draft is queued", async () => { + const messageId = MessageId.make("message-1"); + const onSent = vi.fn(); + + await expect( + sendThreadComposerMessage({ + onSendMessage: async () => messageId, + onSent, + }), + ).resolves.toBe(messageId); + expect(onSent).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-composer-send.ts b/apps/mobile/src/features/threads/thread-composer-send.ts new file mode 100644 index 000000000000..08c93a3be5c9 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-composer-send.ts @@ -0,0 +1,12 @@ +import type { MessageId } from "@t3tools/contracts"; + +export async function sendThreadComposerMessage(input: { + readonly onSendMessage: () => Promise; + readonly onSent: () => void; +}): Promise { + const messageId = await input.onSendMessage(); + if (messageId === null) return null; + + input.onSent(); + return messageId; +} diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.test.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.test.ts new file mode 100644 index 000000000000..6bcc3cf08404 --- /dev/null +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.test.ts @@ -0,0 +1,39 @@ +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted<{ + preferences: AsyncResult.AsyncResult<{ readonly planModeEnabled?: boolean }, never> | null; + reconciliationReady: boolean; +}>(() => ({ preferences: null, reconciliationReady: false })); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => testState.preferences, +})); + +vi.mock("../../state/preferences", () => ({ + mobilePreferencesAtom: Symbol("mobile-preferences"), +})); + +vi.mock("../../state/synced-client-preferences", () => ({ + usePlanModePreferenceReconciliationReady: () => testState.reconciliationReady, +})); + +import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; + +describe("useLegacyPlanModeState", () => { + beforeEach(() => { + testState.preferences = AsyncResult.success({ planModeEnabled: false }); + testState.reconciliationReady = false; + }); + + it("keeps send gating closed when only device preferences have loaded", () => { + expect(useLegacyPlanModeState()).toEqual({ enabled: false, loaded: false }); + }); + + it("uses the device value after reconciliation readiness", () => { + testState.preferences = AsyncResult.success({ planModeEnabled: true }); + testState.reconciliationReady = true; + + expect(useLegacyPlanModeState()).toEqual({ enabled: true, loaded: true }); + }); +}); diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts index 25ec4ff0e7d8..0be447e9d6b3 100644 --- a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts @@ -2,6 +2,7 @@ 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 { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; /** @@ -13,9 +14,10 @@ export function useLegacyPlanModeEnabled(): boolean { return useLegacyPlanModeState().enabled; } -export function useLegacyPlanModeState(): { readonly enabled: boolean; readonly loaded: boolean } { +export function useLegacyPlanModeState() { const preferences = useAtomValue(mobilePreferencesAtom); - const loaded = AsyncResult.isSuccess(preferences); + const reconciliationReady = usePlanModePreferenceReconciliationReady(); + const loaded = AsyncResult.isSuccess(preferences) && reconciliationReady; return { enabled: resolveLegacyPlanModeEnabled({ loaded, diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 7b94dc629154..ec7616bf9c08 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -172,9 +172,65 @@ 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 }); + }); - await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); + it("drops non-canonical synced preference stamps used for lexical LWW ordering", async () => { + mocks.setPreferencesJson( + JSON.stringify({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAt: "2026-08-14T12:00:00Z", + }), + 10, + ); + + await expect(loadPreferences()).resolves.toEqual({ planModeEnabled: true }); + }); + + it("loads per-field synced preference stamps and legacy aggregate stamps", async () => { + mocks.setPreferencesJson( + JSON.stringify({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + syncedClientPreferencesUpdatedAt: "2026-08-14T11:00:00.000Z", + }), + 10, + ); + + await expect(loadPreferences()).resolves.toMatchObject({ + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + syncedClientPreferencesUpdatedAt: "2026-08-14T11:00:00.000Z", + }); + }); + + it("saves the Plan Mode field stamp", async () => { + mocks.setPreferencesJson( + JSON.stringify({ + syncedClientPreferencesUpdatedAtByField: {}, + }), + 10, + ); + + await expect( + savePreferencesPatch({ + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + }), + ).resolves.toMatchObject({ + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + }); }); it("persists independent light and dark theme choices", async () => { diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index dfaeab9cd6ba..2041f2f0260f 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,7 +5,12 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { + type SidebarProjectGroupingMode, + SyncedClientPreferencesUpdatedAt, + SyncedClientPreferencesUpdatedAtByField, + type SyncedClientPreferencesUpdatedAtByField as SyncedClientPreferencesUpdatedAtByFieldValue, +} from "@t3tools/contracts"; import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; import * as MobileDatabase from "./mobile-database"; @@ -14,6 +19,10 @@ 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; @@ -32,6 +41,10 @@ export interface Preferences { readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; readonly autoSettleOnMerge?: boolean; + readonly planModeEnabled?: boolean; + readonly syncedClientPreferencesUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByFieldValue; + /** @deprecated Aggregate-clock cache retained for older persisted blobs. */ + readonly syncedClientPreferencesUpdatedAt?: string; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -40,8 +53,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()( @@ -98,8 +109,10 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; autoSettleOnMerge?: boolean; - legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; + syncedClientPreferencesUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByFieldValue; + syncedClientPreferencesUpdatedAt?: string; + legacyThreadListEnabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -164,12 +177,22 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.autoSettleOnMerge === "boolean") { preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; } - if (typeof parsed.legacyThreadListEnabled === "boolean") { - preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; - } if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if ( + parsed.syncedClientPreferencesUpdatedAtByField !== undefined && + isSyncedClientPreferencesUpdatedAtByField(parsed.syncedClientPreferencesUpdatedAtByField) + ) { + preferences.syncedClientPreferencesUpdatedAtByField = + parsed.syncedClientPreferencesUpdatedAtByField; + } + if (isSyncedClientPreferencesUpdatedAt(parsed.syncedClientPreferencesUpdatedAt)) { + preferences.syncedClientPreferencesUpdatedAt = parsed.syncedClientPreferencesUpdatedAt; + } + if (typeof parsed.legacyThreadListEnabled === "boolean") { + preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; + } return preferences; } @@ -353,7 +376,19 @@ export const make = Effect.fn("MobilePreferencesStore.make")(function* () { try: () => transform(current), catch: (cause) => new MobilePreferencesSaveError({ cause }), }); - const next: Preferences = { ...current, ...patch }; + let next: Preferences = { + ...current, + ...patch, + }; + if (patch.syncedClientPreferencesUpdatedAtByField !== undefined) { + next = { + ...next, + syncedClientPreferencesUpdatedAtByField: { + ...current.syncedClientPreferencesUpdatedAtByField, + ...patch.syncedClientPreferencesUpdatedAtByField, + }, + }; + } const payload = yield* encode(PREFERENCES_KEY, next); yield* saveJson(payload); return next; diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index c53594eb2306..766eddad6f77 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -25,10 +25,12 @@ vi.mock("../lib/runtime", async () => { import type { Preferences } from "../persistence/mobile-preferences"; import { createMobilePreferencesState, + mergeConfirmedPreferences, MobilePreferencesLoadError, MobilePreferencesSaveError, MobilePreferencesStore, } from "./preferences"; +import { resolvePlanModeLocalPatchPersistence } from "./synced-client-preferences-model"; function deferred() { let resolve!: (value: A) => void; @@ -124,6 +126,67 @@ 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, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:01:00.000Z", + }, + }); + registry.set(state.updatePreferencesAtom, { + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:02:00.000Z", + }, + }); + registry.set(state.persistReconciledPreferencesAtom, { + expectedUpdatedAtByField: { planModeEnabled: "2026-08-14T12:01:00.000Z" }, + patch: { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:01:00.000Z", + }, + }, + }); + + expect( + Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom))), + ).toMatchObject({ + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:02:00.000Z", + }, + }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(savePatch).toHaveBeenCalledTimes(2); + }), + ); + + unmountReconciliation(); + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + it.effect("falls back to empty preferences when secure storage cannot be read", () => Effect.gen(function* () { const state = makePreferencesState({ @@ -220,6 +283,119 @@ describe("mobile preferences state", () => { }), ); + it.effect("keeps a failed reconciliation effective without retrying its optimistic window", () => + Effect.gen(function* () { + const updatedAt = "2026-08-15T12:01:00.000Z"; + let saveCount = 0; + const releaseSave = deferred(); + const state = makePreferencesState({ + load: Effect.succeed({ + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-15T12:00:00.000Z", + }, + }), + savePatch: () => { + saveCount += 1; + return Effect.promise(() => releaseSave.promise).pipe( + Effect.andThen( + Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })), + ), + ); + }, + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + const localPatch = { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { planModeEnabled: updatedAt }, + } as const; + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true }); + const first = resolvePlanModeLocalPatchPersistence({ + attemptedKey: null, + localPatch, + }); + registry.set(state.updatePreferencesAtom, localPatch); + const optimistic = Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom))); + expect(optimistic.planModeEnabled).toBe(true); + const optimisticWindow = resolvePlanModeLocalPatchPersistence({ + attemptedKey: first.nextAttemptedKey, + localPatch: null, + }); + releaseSave.resolve(); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect( + Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom))) + .planModeEnabled, + ).toBe(true); + }), + ); + const afterRollback = resolvePlanModeLocalPatchPersistence({ + attemptedKey: optimisticWindow.nextAttemptedKey, + localPatch, + }); + + expect(afterRollback.shouldPersist).toBe(false); + expect(saveCount).toBe(1); + expect( + Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom))) + .syncedClientPreferencesUpdatedAtByField?.planModeEnabled, + ).toBe(updatedAt); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + + it("keeps a newer in-memory reconciliation over an unrelated saved preference", () => { + expect( + mergeConfirmedPreferences( + { + baseFontSize: 18, + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-15T12:00:00.000Z", + }, + }, + { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-15T12:01:00.000Z", + }, + }, + ), + ).toEqual({ + baseFontSize: 18, + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-15T12:01:00.000Z", + }, + }); + }); + + it("accepts a successfully saved Plan Mode value at a newer stamp", () => { + const saved = { + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-15T12:02:00.000Z", + }, + } as const; + + expect( + mergeConfirmedPreferences(saved, { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-15T12:01:00.000Z", + }, + }), + ).toBe(saved); + }); + it.effect("rolls back to the last confirmed value after a later save fails", () => Effect.gen(function* () { let saveCount = 0; @@ -269,4 +445,82 @@ describe("mobile preferences state", () => { registry.dispose(); }), ); + + it.effect("does not persist a failed preference stamp through a concurrent write", () => + Effect.gen(function* () { + const failedStamp = "2026-08-15T12:01:00.000Z"; + const firstSaveStarted = deferred(); + const releaseFirstSave = deferred(); + let saveCount = 0; + let persisted: Preferences = { + baseFontSize: 16, + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-15T12:00:00.000Z", + }, + }; + const state = makePreferencesState({ + load: Effect.succeed(persisted), + savePatch: (patch) => + Effect.gen(function* () { + saveCount += 1; + if (saveCount === 1) { + yield* Effect.sync(firstSaveStarted.resolve); + yield* Effect.promise(() => releaseFirstSave.promise); + return yield* Effect.fail( + new MobilePreferencesSaveError({ cause: new Error("write failed") }), + ); + } + let next = { + ...persisted, + ...patch, + }; + if (patch.syncedClientPreferencesUpdatedAtByField !== undefined) { + next = { + ...next, + syncedClientPreferencesUpdatedAtByField: { + ...persisted.syncedClientPreferencesUpdatedAtByField, + ...patch.syncedClientPreferencesUpdatedAtByField, + }, + }; + } + persisted = next; + return persisted; + }), + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { planModeEnabled: failedStamp }, + }); + yield* Effect.promise(() => firstSaveStarted.promise); + registry.set(state.updatePreferencesAtom, { + baseFontSize: 18, + }); + 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", + }, + }); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); }); diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index d173cf55be5a..8df424eb19b2 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -1,5 +1,10 @@ import * as Effect from "effect/Effect"; +import * as Struct from "effect/Struct"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { + SYNCED_CLIENT_PREFERENCE_FIELDS, + type SyncedClientPreferencesUpdatedAtByField, +} from "@t3tools/contracts"; import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; import * as Runtime from "../lib/runtime"; @@ -15,6 +20,47 @@ interface OptimisticPreferences { readonly versions: Partial>; } +function clearSettledOptimisticPreferences( + optimistic: OptimisticPreferences, + patch: Partial, + version: number, +): OptimisticPreferences { + const keys = Struct.keys(patch).filter((key) => optimistic.versions[key] === version); + return { + values: Struct.omit(optimistic.values, keys), + versions: Struct.omit(optimistic.versions, keys), + }; +} + +export interface ReconciledPreferencesPatch { + readonly expectedUpdatedAtByField: SyncedClientPreferencesUpdatedAtByField; + readonly patch: Partial; +} + +export function mergeConfirmedPreferences(saved: Preferences, current: Preferences): Preferences { + const currentUpdatedAt = + current.syncedClientPreferencesUpdatedAtByField?.planModeEnabled ?? + current.syncedClientPreferencesUpdatedAt; + const savedUpdatedAt = + saved.syncedClientPreferencesUpdatedAtByField?.planModeEnabled ?? + saved.syncedClientPreferencesUpdatedAt; + if ( + current.planModeEnabled === undefined || + currentUpdatedAt === undefined || + (savedUpdatedAt !== undefined && savedUpdatedAt >= currentUpdatedAt) + ) { + return saved; + } + return { + ...saved, + planModeEnabled: current.planModeEnabled, + syncedClientPreferencesUpdatedAtByField: { + ...saved.syncedClientPreferencesUpdatedAtByField, + planModeEnabled: currentUpdatedAt, + }, + }; +} + /** * Owns the device preference blob for the lifetime of the app registry. * Optimistic patches are kept separately so writes made while persistence is @@ -58,51 +104,57 @@ 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.tap((saved) => Effect.sync(() => { - get.set(confirmedPreferencesAtom, saved); + get.set( + confirmedPreferencesAtom, + mergeConfirmedPreferences(saved, get(confirmedPreferencesAtom)), + ); 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) { - if (optimistic.versions[key] === version) { - delete values[key]; - delete currentVersions[key]; - } - } - get.set(optimisticPatchAtom, { - values: values as Partial, - versions: currentVersions as Partial>, - }); + get.set( + optimisticPatchAtom, + clearSettledOptimisticPreferences(optimistic, normalizedPatch, version), + ); }), ), Effect.tapError(() => Effect.sync(() => { - 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) { - if (optimistic.versions[key] === version) { - delete values[key]; - delete currentVersions[key]; - } + if (normalizedPatch.syncedClientPreferencesUpdatedAtByField !== undefined) { + get.set(confirmedPreferencesAtom, { + ...get(confirmedPreferencesAtom), + ...normalizedPatch, + }); } - get.set(optimisticPatchAtom, { - values: values as Partial, - versions: currentVersions as Partial>, - }); + const optimistic = get(optimisticPatchAtom); + get.set( + optimisticPatchAtom, + clearSettledOptimisticPreferences(optimistic, normalizedPatch, version), + ); }), ), ); @@ -114,7 +166,28 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime { + const current = get(preferencesAtom); + if ( + !AsyncResult.isSuccess(current) || + SYNCED_CLIENT_PREFERENCE_FIELDS.some((field) => { + const expectedUpdatedAt = input.expectedUpdatedAtByField[field]; + return ( + expectedUpdatedAt !== undefined && + (current.value.syncedClientPreferencesUpdatedAtByField?.[field] ?? + current.value.syncedClientPreferencesUpdatedAt) !== expectedUpdatedAt + ); + }) + ) { + return Effect.void; + } + get.set(updatePreferencesAtom, input.patch); + return Effect.void; + }) + .pipe(Atom.withLabel("mobile:preferences:persist-reconciled")); + + return { preferencesAtom, updatePreferencesAtom, persistReconciledPreferencesAtom } as const; } const mobilePreferencesRuntime = Atom.runtime(Runtime.runtimeContextLayer); @@ -122,3 +195,5 @@ export const mobilePreferencesState = createMobilePreferencesState(mobilePrefere export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom; export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom; +export const persistReconciledMobilePreferencesAtom = + mobilePreferencesState.persistReconciledPreferencesAtom; diff --git a/apps/mobile/src/state/synced-client-preferences-model.ts b/apps/mobile/src/state/synced-client-preferences-model.ts new file mode 100644 index 000000000000..71df7bac9648 --- /dev/null +++ b/apps/mobile/src/state/synced-client-preferences-model.ts @@ -0,0 +1,649 @@ +import { + EnvironmentId, + getSyncedClientPreferenceUpdatedAt, + nextSyncedClientPreferencesUpdatedAt, + SYNCED_CLIENT_PREFERENCE_FIELDS, + type PatchSyncedClientPreferencesRequest, + type SyncedClientPreferenceField, + type SyncedClientPreferences, + type SyncedClientPreferencesPatch, + type SyncedClientPreferencesUpdatedAtByField, +} from "@t3tools/contracts"; +import { + createPlanModePreferencePatchRequest, + SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS, + syncedClientPreferenceRetryDelayMs, +} from "@t3tools/client-runtime/synced-client-preferences"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import * as Schema from "effect/Schema"; + +import type { Preferences } from "../persistence/mobile-preferences"; + +const SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; +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; + readonly preferences: SyncedClientPreferences | undefined; + readonly canPatch?: boolean; +} + +export interface PlanModePreferencePatchTarget { + readonly environmentId: EnvironmentId; + readonly input: PatchSyncedClientPreferencesRequest; +} + +export interface LocalSyncedClientPreferencesState { + readonly values: Partial; + readonly updatedAtByField?: SyncedClientPreferencesUpdatedAtByField; + readonly legacyUpdatedAt?: string; +} + +export interface LocalSyncedClientPreferencesPatch { + readonly values: SyncedClientPreferencesPatch; + readonly updatedAtByField: SyncedClientPreferencesUpdatedAtByField; +} + +interface EnvironmentPreferenceCandidate { + readonly source: EnvironmentId; + readonly updatedAt: string; +} + +function compareEnvironmentPreferenceCandidates( + left: EnvironmentPreferenceCandidate, + right: EnvironmentPreferenceCandidate, +): number { + return left.updatedAt.localeCompare(right.updatedAt) || left.source.localeCompare(right.source); +} + +const PlanModePreferenceReconciliationIdentity = Schema.Tuple([ + Schema.String, + EnvironmentId, + Schema.Boolean, +]); +type PlanModePreferenceReconciliationIdentity = + typeof PlanModePreferenceReconciliationIdentity.Type; +const decodePlanModePreferenceReconciliationIdentity = Schema.decodeUnknownSync( + Schema.fromJsonString(PlanModePreferenceReconciliationIdentity), +); + +function parsePlanModePreferenceReconciliationKey( + key: string, +): PlanModePreferenceReconciliationIdentity | undefined { + return key === "" ? undefined : decodePlanModePreferenceReconciliationIdentity(key); +} + +function comparePlanModePreferenceReconciliationIdentities( + left: PlanModePreferenceReconciliationIdentity | undefined, + right: PlanModePreferenceReconciliationIdentity | undefined, +): number { + if (left === undefined) return right === undefined ? 0 : -1; + if (right === undefined) return 1; + return compareEnvironmentPreferenceCandidates( + { updatedAt: left[0], source: left[1] }, + { updatedAt: right[0], source: right[1] }, + ); +} + +export function createPlanModePreferenceReconciliationKey( + states: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly connectionState: EnvironmentConnectionPhase; + readonly shellStatus: EnvironmentShellStatus; + readonly preferences: SyncedClientPreferences | undefined; + }>, +): string { + // Readiness is a monotonic watermark over the deterministic live winner. A newer + // stamp always closes gating; at the same stamp, a higher environment id closes it + // only when its value differs. Removals, fallback states, and same-value churn cannot. + const winner = states.reduce( + (currentWinner, { environmentId, connectionState, shellStatus, preferences }) => { + if (connectionState !== "connected" || shellStatus !== "live") return currentWinner; + const value = preferences?.planModeEnabled; + const updatedAt = getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"); + if (value === undefined || updatedAt === undefined) return currentWinner; + const candidate = [updatedAt, environmentId, value] as const; + return comparePlanModePreferenceReconciliationIdentities(candidate, currentWinner) > 0 + ? candidate + : currentWinner; + }, + undefined, + ); + return winner === undefined ? "" : JSON.stringify(winner); +} + +export function advancePlanModePreferenceReconciliationKey( + appliedKey: string | null, + currentKey: string, +): string { + if (appliedKey === null) return currentKey; + return comparePlanModePreferenceReconciliationIdentities( + parsePlanModePreferenceReconciliationKey(currentKey), + parsePlanModePreferenceReconciliationKey(appliedKey), + ) >= 0 + ? currentKey + : appliedKey; +} + +export function isPlanModePreferenceReconciliationReady(input: { + readonly connectionsLoaded: boolean; + readonly environmentCount: number; + readonly currentKey: string; + readonly appliedKey: string | null; +}): boolean { + if (!input.connectionsLoaded) return false; + if (input.environmentCount === 0) return true; + if (input.appliedKey === null) return false; + const current = parsePlanModePreferenceReconciliationKey(input.currentKey); + const applied = parsePlanModePreferenceReconciliationKey(input.appliedKey); + if (comparePlanModePreferenceReconciliationIdentities(current, applied) < 0) return true; + if (current === undefined || applied === undefined) return current === applied; + return current[0] === applied[0] && current[2] === applied[2]; +} + +export function shouldPreservePlanModeLocalValue(input: { + readonly currentKey: string; + readonly appliedKey: string | null; +}): boolean { + if (input.appliedKey === null) return false; + return ( + comparePlanModePreferenceReconciliationIdentities( + parsePlanModePreferenceReconciliationKey(input.currentKey), + parsePlanModePreferenceReconciliationKey(input.appliedKey), + ) < 0 + ); +} + +export function hasPlanModePreferenceReconciliationAttempted( + states: ReadonlyArray<{ + readonly connectionState: EnvironmentConnectionPhase; + readonly shellStatus: EnvironmentShellStatus; + }>, +): boolean { + return ( + states.some( + ({ connectionState, shellStatus }) => + connectionState === "connected" && shellStatus === "live", + ) || + states.every( + ({ connectionState }) => + connectionState === "available" || + connectionState === "error" || + connectionState === "offline", + ) + ); +} + +type MutableSyncedClientPreferencesPatch = { + -readonly [Field in SyncedClientPreferenceField]?: SyncedClientPreferencesPatch[Field]; +}; +type MutableSyncedClientPreferencesUpdatedAtByField = { + -readonly [Field in SyncedClientPreferenceField]?: string; +}; + +function setPreferenceValue( + patch: MutableSyncedClientPreferencesPatch, + field: Field, + value: SyncedClientPreferencesPatch[Field], +): void { + patch[field] = value; +} + +function setPreferenceUpdatedAt( + updatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField, + field: Field, + updatedAt: string, +): void { + updatedAtByField[field] = updatedAt; +} + +function localPreferenceUpdatedAt( + local: LocalSyncedClientPreferencesState, + field: SyncedClientPreferenceField, +): string | undefined { + if (local.values[field] === undefined) return undefined; + return local.updatedAtByField?.[field] ?? local.legacyUpdatedAt; +} + +export function 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; + cancelRetry?: () => void; + } + + interface EnvironmentReconciliation { + reconciliation?: Reconciliation; + settledKey?: string; + exhaustedKey?: string; + } + + const environmentReconciliations = new Map(); + const cancel = (environmentId: EnvironmentId) => { + const state = environmentReconciliations.get(environmentId); + state?.reconciliation?.cancelRetry?.(); + if (state !== undefined) state.reconciliation = undefined; + }; + const settleFailure = (reconciliation: Reconciliation) => { + const { environmentId } = reconciliation.target; + const state = environmentReconciliations.get(environmentId); + if (state?.reconciliation !== reconciliation) return; + if (reconciliation.attempt >= SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS) { + state.reconciliation = undefined; + state.exhaustedKey = reconciliation.key; + return; + } + const delayMs = syncedClientPreferenceRetryDelayMs(reconciliation.attempt); + reconciliation.cancelRetry = scheduleRetry(() => { + reconciliation.cancelRetry = undefined; + if (environmentReconciliations.get(environmentId)?.reconciliation === reconciliation) { + dispatch(reconciliation); + } + }, delayMs); + }; + const dispatch = (reconciliation: Reconciliation) => { + reconciliation.attempt += 1; + void reconciliation.patch().then( + (preferences) => { + const { environmentId } = reconciliation.target; + const state = environmentReconciliations.get(environmentId); + if (state?.reconciliation !== reconciliation) return; + if (preferences === null) { + settleFailure(reconciliation); + return; + } + state.reconciliation = undefined; + state.settledKey = reconciliation.key; + state.exhaustedKey = undefined; + 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 environmentReconciliations.keys()) { + if (nextEnvironmentIds.has(environmentId)) continue; + cancel(environmentId); + environmentReconciliations.delete(environmentId); + } + for (const environmentId of nextEnvironmentIds) { + if (!environmentReconciliations.has(environmentId)) { + environmentReconciliations.set(environmentId, {}); + } + } + }, + observe( + environmentId: EnvironmentId, + value: boolean | undefined, + updatedAt: string | undefined, + ) { + const reconciliation = environmentReconciliations.get(environmentId)?.reconciliation; + const observedKey = reconciliationKey(value, updatedAt); + if (observedKey !== undefined && reconciliation?.key === observedKey) cancel(environmentId); + const state = environmentReconciliations.get(environmentId); + if (observedKey !== undefined && state?.exhaustedKey === observedKey) { + state.exhaustedKey = undefined; + } + }, + reconcile(input: { + readonly target: PlanModePreferencePatchTarget; + readonly patch: ( + target: PlanModePreferencePatchTarget, + ) => Promise>; + readonly persist: (patch: Partial) => void; + }) { + const { environmentId } = input.target; + const state = environmentReconciliations.get(environmentId); + const key = targetReconciliationKey(input.target); + if ( + state === undefined || + (key !== undefined && (state.settledKey === key || state.exhaustedKey === key)) + ) { + return; + } + const current = state.reconciliation; + if (key !== undefined && current?.key === key) { + current.patch = async () => { + const result = await input.patch(input.target); + return result._tag === "Success" ? result.value : null; + }; + current.persist = input.persist; + return; + } + if (current !== undefined) cancel(environmentId); + state.settledKey = undefined; + state.exhaustedKey = undefined; + const reconciliation: Reconciliation = { + target: input.target, + key, + attempt: 0, + patch: async () => { + const result = await input.patch(input.target); + return result._tag === "Success" ? result.value : null; + }, + persist: input.persist, + }; + state.reconciliation = reconciliation; + dispatch(reconciliation); + }, + reset() { + for (const environmentId of environmentReconciliations.keys()) cancel(environmentId); + environmentReconciliations.clear(); + }, + }; +} + +export function canonicalPlanModePreferencePatch( + preferences: SyncedClientPreferences, +): Partial | null { + const updatedAt = getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"); + return preferences.planModeEnabled === undefined || updatedAt === undefined + ? null + : { + planModeEnabled: preferences.planModeEnabled, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: updatedAt, + }, + }; +} + +export function nextMobileSyncedPreferencesUpdatedAt( + localUpdatedAts: ReadonlyArray, + now: string, + authoritativeUpdatedAts: ReadonlyArray = [], +): string { + const maximumUpdatedAt = Date.parse(now) + SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS; + return nextSyncedClientPreferencesUpdatedAt( + [ + ...localUpdatedAts.filter( + (candidate) => candidate !== undefined && Date.parse(candidate) <= maximumUpdatedAt, + ), + ...authoritativeUpdatedAts, + ], + now, + ); +} + +export function createSyncedClientPreferencesWrite(input: { + readonly patch: SyncedClientPreferencesPatch; + readonly connectedEnvironmentIds: ReadonlyArray; + readonly currentUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByField; + readonly legacyCurrentUpdatedAt?: string; + readonly authoritativePreferences?: ReadonlyArray; + readonly now: string; +}) { + const fields = SYNCED_CLIENT_PREFERENCE_FIELDS.filter( + (field) => input.patch[field] !== undefined, + ); + const updatedAt = nextMobileSyncedPreferencesUpdatedAt( + fields.map((field) => input.currentUpdatedAtByField?.[field] ?? input.legacyCurrentUpdatedAt), + input.now, + input.authoritativePreferences?.flatMap((preferences) => + fields.map((field) => getSyncedClientPreferenceUpdatedAt(preferences, field)), + ), + ); + const updatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField = { + ...input.currentUpdatedAtByField, + }; + for (const field of fields) setPreferenceUpdatedAt(updatedAtByField, field, updatedAt); + const planModeEnabled = input.patch.planModeEnabled; + if (planModeEnabled === undefined) { + throw new Error("Plan Mode writes require a planModeEnabled value"); + } + const request = createPlanModePreferencePatchRequest(planModeEnabled, updatedAt); + return { + localPatch: { values: input.patch, updatedAtByField }, + environmentPatches: input.connectedEnvironmentIds.map((environmentId) => ({ + environmentId, + input: request, + })), + }; +} + +export function createPlanModePreferenceWrite(input: { + readonly value: boolean; + readonly connectedEnvironmentIds: ReadonlyArray; + readonly currentUpdatedAtByField?: SyncedClientPreferencesUpdatedAtByField; + readonly legacyCurrentUpdatedAt?: string; + readonly authoritativePreferences?: ReadonlyArray; + readonly now: string; +}) { + const write = createSyncedClientPreferencesWrite({ + patch: { planModeEnabled: input.value }, + connectedEnvironmentIds: input.connectedEnvironmentIds, + currentUpdatedAtByField: input.currentUpdatedAtByField, + legacyCurrentUpdatedAt: input.legacyCurrentUpdatedAt, + authoritativePreferences: input.authoritativePreferences, + now: input.now, + }); + return { + localPatch: { + planModeEnabled: input.value, + syncedClientPreferencesUpdatedAtByField: write.localPatch.updatedAtByField, + }, + environmentPatches: write.environmentPatches, + }; +} + +export function createPlanModePreferenceWriteController() { + let latestRequestedUpdatedAt: string | undefined; + let settledRequestedUpdatedAt: string | undefined; + + return { + create(input: Parameters[0]) { + const persistedUpdatedAt = input.currentUpdatedAtByField?.planModeEnabled; + const currentUpdatedAt = + latestRequestedUpdatedAt === undefined || + (persistedUpdatedAt !== undefined && persistedUpdatedAt > latestRequestedUpdatedAt) + ? persistedUpdatedAt + : latestRequestedUpdatedAt; + const currentUpdatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField = { + ...input.currentUpdatedAtByField, + }; + if (currentUpdatedAt !== undefined) { + currentUpdatedAtByField.planModeEnabled = currentUpdatedAt; + } + const write = createPlanModePreferenceWrite({ + ...input, + currentUpdatedAtByField, + }); + latestRequestedUpdatedAt = + write.localPatch.syncedClientPreferencesUpdatedAtByField.planModeEnabled; + settledRequestedUpdatedAt = undefined; + return write; + }, + settle(input: { + readonly target: PlanModePreferencePatchTarget; + readonly result: AtomCommandResult; + }): Partial | 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); + }, + }; +} + +export function reconcileSyncedClientPreferences(input: { + readonly local: LocalSyncedClientPreferencesState; + readonly environments: ReadonlyArray; + readonly now: string; + readonly fields?: ReadonlyArray; + readonly preserveLocalOnEqualStamp?: boolean; +}) { + if (input.environments.length === 0) { + return { localPatch: null, environmentPatches: [] }; + } + + const localValues: MutableSyncedClientPreferencesPatch = {}; + const localUpdatedAtByField: MutableSyncedClientPreferencesUpdatedAtByField = { + ...input.local.updatedAtByField, + }; + const environmentPatches: PlanModePreferencePatchTarget[] = []; + let localChanged = false; + const sortedEnvironments = [...input.environments].sort((left, right) => + left.environmentId.localeCompare(right.environmentId), + ); + const hasPatchableEnvironment = sortedEnvironments.some( + (environment) => environment.canPatch !== false, + ); + + for (const field of input.fields ?? SYNCED_CLIENT_PREFERENCE_FIELDS) { + const localValue = input.local.values[field]; + const localUpdatedAt = localPreferenceUpdatedAt(input.local, field); + const environmentCandidates = sortedEnvironments.flatMap((environment) => { + const value = environment.preferences?.[field]; + const updatedAt = getSyncedClientPreferenceUpdatedAt(environment.preferences, field); + return value === undefined || + updatedAt === undefined || + (environment.canPatch === false && localUpdatedAt !== undefined) + ? [] + : [{ source: environment.environmentId, value, updatedAt }]; + }); + environmentCandidates.sort(compareEnvironmentPreferenceCandidates); + const latestEnvironment = environmentCandidates.at(-1); + const latestObservedEnvironmentUpdatedAt = latestEnvironment?.updatedAt; + const boundedLocalUpdatedAt = + hasPatchableEnvironment && + localUpdatedAt !== undefined && + latestObservedEnvironmentUpdatedAt !== undefined && + localUpdatedAt > latestObservedEnvironmentUpdatedAt && + Date.parse(localUpdatedAt) > + Date.parse(input.now) + SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS + ? nextMobileSyncedPreferencesUpdatedAt([], latestObservedEnvironmentUpdatedAt, [ + latestObservedEnvironmentUpdatedAt, + ]) + : localUpdatedAt; + // Exact remote ties use environment id for deterministic convergence. + const localWins = + localValue !== undefined && + boundedLocalUpdatedAt !== undefined && + (latestEnvironment === undefined || + boundedLocalUpdatedAt > latestEnvironment.updatedAt || + (input.preserveLocalOnEqualStamp === true && + boundedLocalUpdatedAt === latestEnvironment.updatedAt)); + const value = localWins ? localValue : (latestEnvironment?.value ?? localValue); + if (value === undefined) continue; + const winningUpdatedAt = + (localWins ? boundedLocalUpdatedAt : latestEnvironment?.updatedAt) ?? input.now; + const hasEqualStampConflict = + latestEnvironment !== undefined && + ((boundedLocalUpdatedAt === latestEnvironment.updatedAt && localValue !== value) || + (!localWins && + environmentCandidates.some( + (candidate) => + candidate.updatedAt === latestEnvironment.updatedAt && candidate.value !== value, + ))); + const updatedAt = hasEqualStampConflict + ? nextMobileSyncedPreferencesUpdatedAt([], winningUpdatedAt, [winningUpdatedAt]) + : winningUpdatedAt; + + if (localValue !== value || localUpdatedAt !== updatedAt) { + setPreferenceValue(localValues, field, value); + setPreferenceUpdatedAt(localUpdatedAtByField, field, updatedAt); + localChanged = true; + } + + for (const environment of sortedEnvironments) { + if (environment.canPatch === false) continue; + if ( + environment.preferences?.[field] === value && + getSyncedClientPreferenceUpdatedAt(environment.preferences, field) === updatedAt + ) { + continue; + } + environmentPatches.push({ + environmentId: environment.environmentId, + input: createPlanModePreferencePatchRequest(value, 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 preserveLocalOnEqualStamp?: boolean; +}) { + let local: LocalSyncedClientPreferencesState = { + values: + input.localPlanModeEnabled === undefined + ? {} + : { planModeEnabled: input.localPlanModeEnabled }, + }; + if (input.localUpdatedAt !== undefined) { + local = { + ...local, + updatedAtByField: { planModeEnabled: input.localUpdatedAt }, + }; + } + const reconciliation = reconcileSyncedClientPreferences({ + local, + environments: input.environments, + now: input.now, + fields: ["planModeEnabled"], + preserveLocalOnEqualStamp: input.preserveLocalOnEqualStamp, + }); + const planModeEnabled = reconciliation.localPatch?.values.planModeEnabled; + return { + localPatch: + planModeEnabled === undefined || reconciliation.localPatch === null + ? null + : { + planModeEnabled, + syncedClientPreferencesUpdatedAtByField: reconciliation.localPatch.updatedAtByField, + }, + environmentPatches: reconciliation.environmentPatches, + }; +} + +export function resolvePlanModeLocalPatchPersistence(input: { + readonly attemptedKey: string | null; + readonly localPatch: ReturnType["localPatch"]; +}) { + if (input.localPatch === null) { + return { shouldPersist: false, nextAttemptedKey: input.attemptedKey } as const; + } + const nextAttemptedKey = JSON.stringify(input.localPatch); + return { + shouldPersist: nextAttemptedKey !== input.attemptedKey, + nextAttemptedKey, + } as const; +} 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..0db5b0a38543 --- /dev/null +++ b/apps/mobile/src/state/synced-client-preferences.test.ts @@ -0,0 +1,1289 @@ +import { CommandId, EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + advancePlanModePreferenceReconciliationKey, + createPlanModePreferenceReconciliationKey, + createPlanModePreferenceReconciliationController, + createPlanModePreferenceWrite, + createPlanModePreferenceWriteController, + createSyncedClientPreferencesWrite, + hasPlanModePreferenceReconciliationAttempted, + isPlanModePreferenceReconciliationReady, + nextMobileSyncedPreferencesUpdatedAt, + reconcilePlanModePreferences, + resolvePlanModeLocalPatchPersistence, +} 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, + updatedAtByField: { planModeEnabled: updatedAt }, + updatedAt, + }, +}); + +describe("synced client preferences", () => { + const flushReconciliation = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + const makeRetryScheduler = () => { + const scheduled: Array<{ + readonly delayMs: number; + readonly run: () => void; + readonly cancelled: () => boolean; + }> = []; + const schedule = (retry: () => void, delayMs: number) => { + let cancelled = false; + scheduled.push({ + delayMs, + run: () => { + if (!cancelled) retry(); + }, + cancelled: () => cancelled, + }); + return () => { + cancelled = true; + }; + }; + return { schedule, scheduled }; + }; + + it("uses the device preference immediately when the loaded catalog has no environments", () => { + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 0, + currentKey: "[]", + appliedKey: null, + }), + ).toBe(true); + }); + + it("waits for catalog hydration before applying the no-environment fallback", () => { + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: false, + environmentCount: 0, + currentKey: "[]", + appliedKey: null, + }), + ).toBe(false); + }); + + it("waits for an environment reconciliation to apply", () => { + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: "current", + appliedKey: null, + }), + ).toBe(false); + }); + + it("opens gating after the current environment reconciliation applies", () => { + const currentKey = createPlanModePreferenceReconciliationKey([ + livePlanModeEnvironment("environment-1", true, "2026-08-14T12:00:00.000Z"), + ]); + + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey, + appliedKey: currentKey, + }), + ).toBe(true); + }); + + it("keeps send gating open while an unrelated environment reconnects", () => { + const liveEnvironment = { + environmentId: environmentId("live"), + connectionState: "connected", + shellStatus: "live", + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + } as const; + const connectingKey = createPlanModePreferenceReconciliationKey([ + liveEnvironment, + { + environmentId: environmentId("flapping"), + connectionState: "connecting", + shellStatus: "cached", + preferences: undefined, + }, + ]); + const reconnectingKey = createPlanModePreferenceReconciliationKey([ + liveEnvironment, + { + environmentId: environmentId("flapping"), + connectionState: "reconnecting", + shellStatus: "cached", + preferences: undefined, + }, + ]); + + expect( + hasPlanModePreferenceReconciliationAttempted([ + liveEnvironment, + { connectionState: "reconnecting", shellStatus: "cached" }, + ]), + ).toBe(true); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 2, + currentKey: reconnectingKey, + appliedKey: connectingKey, + }), + ).toBe(true); + }); + + it("keeps the applied reconciliation state when an offline environment leaves", () => { + const remainingOfflineEnvironment = { + environmentId: environmentId("remaining-offline"), + connectionState: "offline", + shellStatus: "cached", + preferences: undefined, + } as const; + const offlineKey = createPlanModePreferenceReconciliationKey([ + { + environmentId: environmentId("offline"), + connectionState: "offline", + shellStatus: "cached", + preferences: undefined, + }, + remainingOfflineEnvironment, + ]); + const remainingKey = createPlanModePreferenceReconciliationKey([remainingOfflineEnvironment]); + + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: remainingKey, + appliedKey: offlineKey, + }), + ).toBe(true); + expect(advancePlanModePreferenceReconciliationKey(offlineKey, remainingKey)).toBe(offlineKey); + }); + + it("does not accept a stale live shell while its environment is reconnecting", () => { + expect( + hasPlanModePreferenceReconciliationAttempted([ + { connectionState: "reconnecting", shellStatus: "live" }, + ]), + ).toBe(false); + }); + + it("uses the device fallback after the first offline reconciliation attempt", () => { + const offlineState = { + environmentId: environmentId("offline"), + connectionState: "offline", + shellStatus: "cached", + preferences: undefined, + } as const; + const offlineKey = createPlanModePreferenceReconciliationKey([offlineState]); + + expect(hasPlanModePreferenceReconciliationAttempted([offlineState])).toBe(true); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: offlineKey, + appliedKey: null, + }), + ).toBe(false); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: offlineKey, + appliedKey: offlineKey, + }), + ).toBe(true); + }); + + it("reconciles a newly live newer preference without blocking reconnect churn", () => { + const environment = { + environmentId: environmentId("environment-1"), + shellStatus: "cached", + preferences: undefined, + } as const; + const offlineKey = createPlanModePreferenceReconciliationKey([ + { ...environment, connectionState: "offline" }, + ]); + const connectingKey = createPlanModePreferenceReconciliationKey([ + { ...environment, connectionState: "connecting" }, + ]); + const reconnectingKey = createPlanModePreferenceReconciliationKey([ + { ...environment, connectionState: "reconnecting" }, + ]); + const liveKey = createPlanModePreferenceReconciliationKey([ + { + ...environment, + connectionState: "connected", + shellStatus: "live", + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ]); + + expect(connectingKey).toBe(reconnectingKey); + expect(connectingKey).toBe(offlineKey); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: reconnectingKey, + appliedKey: offlineKey, + }), + ).toBe(true); + expect(liveKey).not.toBe(offlineKey); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: liveKey, + appliedKey: offlineKey, + }), + ).toBe(false); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey: liveKey, + appliedKey: advancePlanModePreferenceReconciliationKey(offlineKey, liveKey), + }), + ).toBe(true); + }); + + it("keeps gating open for a newly live preference older than the applied watermark", () => { + const currentKey = createPlanModePreferenceReconciliationKey([ + livePlanModeEnvironment("older", false, "2026-08-14T12:00:00.000Z"), + ]); + const appliedKey = createPlanModePreferenceReconciliationKey([ + livePlanModeEnvironment("newer", true, "2026-08-14T12:01:00.000Z"), + ]); + + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 1, + currentKey, + appliedKey, + }), + ).toBe(true); + }); + + it("reconciles a newly live equal-stamp winner with a different value", () => { + const updatedAt = "2026-08-14T12:00:00.000Z"; + const initialEnvironment = livePlanModeEnvironment("environment-1", false, updatedAt); + const appliedKey = createPlanModePreferenceReconciliationKey([initialEnvironment]); + const currentKey = createPlanModePreferenceReconciliationKey([ + initialEnvironment, + livePlanModeEnvironment("environment-2", true, updatedAt), + ]); + + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 2, + currentKey, + appliedKey, + }), + ).toBe(false); + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 2, + currentKey, + appliedKey: advancePlanModePreferenceReconciliationKey(appliedKey, currentKey), + }), + ).toBe(true); + }); + + it("keeps gating open for a newly live equal-stamp winner with the same value", () => { + const updatedAt = "2026-08-14T12:00:00.000Z"; + const initialEnvironment = livePlanModeEnvironment("environment-1", false, updatedAt); + const appliedKey = createPlanModePreferenceReconciliationKey([initialEnvironment]); + const currentKey = createPlanModePreferenceReconciliationKey([ + initialEnvironment, + livePlanModeEnvironment("environment-2", false, updatedAt), + ]); + + expect( + isPlanModePreferenceReconciliationReady({ + connectionsLoaded: true, + environmentCount: 2, + currentKey, + appliedKey, + }), + ).toBe(true); + }); + + it("bounds excessively future-skewed local stamps", () => { + expect( + nextMobileSyncedPreferencesUpdatedAt( + ["2026-08-14T12:05:00.001Z"], + "2026-08-14T12:00:00.000Z", + ), + ).toBe("2026-08-14T12:00:00.000Z"); + expect( + nextMobileSyncedPreferencesUpdatedAt( + ["2026-08-14T12:05:00.000Z"], + "2026-08-14T12:00:00.000Z", + ), + ).toBe("2026-08-14T12:05:00.001Z"); + }); + + it("advances past authoritative environment stamps on a slow device clock", () => { + expect( + nextMobileSyncedPreferencesUpdatedAt([], "2026-08-14T12:00:00.000Z", [ + "2026-08-14T13:00:00.000Z", + ]), + ).toBe("2026-08-14T13:00:00.001Z"); + }); + + it("adopts the environment plan mode into the device cache on connect", () => { + expect( + reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T11:00:00.000Z", + environments: [ + { + environmentId: environmentId("environment-1"), + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ + localPatch: { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + }, + environmentPatches: [], + }); + }); + + it("keeps a stamped device preference when a newer environment is read-only", () => { + expect( + reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T11:00:00.000Z", + environments: [ + { + environmentId: environmentId("read-only"), + canPatch: false, + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ localPatch: null, environmentPatches: [] }); + }); + + it("fans a mobile toggle out to every connected environment", () => { + const write = createPlanModePreferenceWrite({ + value: true, + connectedEnvironmentIds: [environmentId("environment-1"), environmentId("environment-2")], + currentUpdatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + authoritativePreferences: [ + { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T12:02:00.000Z" }, + updatedAt: "2026-08-14T12:02:00.000Z", + }, + ], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(write.localPatch).toEqual({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:02:00.001Z", + }, + }); + expect(write.environmentPatches).toEqual([ + { + environmentId: environmentId("environment-1"), + input: { + commandId: CommandId.make("client-preferences:2026-08-14T12:02:00.001Z:1"), + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:02:00.001Z", + }, + }, + { + environmentId: environmentId("environment-2"), + input: { + commandId: CommandId.make("client-preferences:2026-08-14T12:02:00.001Z:1"), + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:02:00.001Z", + }, + }, + ]); + }); + + 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], + now: "2026-08-14T12:00:00.000Z", + }); + const second = controller.create({ + value: false, + connectedEnvironmentIds: [environment], + now: "2026-08-14T12:00:00.000Z", + }); + + expect(second.localPatch.syncedClientPreferencesUpdatedAtByField.planModeEnabled).toBe( + "2026-08-14T12:00:00.001Z", + ); + expect( + controller.settle({ + target: second.environmentPatches[0]!, + result: AsyncResult.success({ + planModeEnabled: false, + updatedAtByField: { planModeEnabled: second.environmentPatches[0]!.input.updatedAt }, + updatedAt: second.environmentPatches[0]!.input.updatedAt, + }), + }), + ).toEqual(second.localPatch); + expect( + controller.settle({ + target: first.environmentPatches[0]!, + result: AsyncResult.success({ + planModeEnabled: true, + updatedAtByField: { planModeEnabled: first.environmentPatches[0]!.input.updatedAt }, + updatedAt: first.environmentPatches[0]!.input.updatedAt, + }), + }), + ).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({ + value: true, + connectedEnvironmentIds: [environmentId("environment-1"), environmentId("environment-2")], + now: "2026-08-14T12:00:00.000Z", + }); + + expect( + controller.settle({ + target: write.environmentPatches[1]!, + result: AsyncResult.success({ + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:01:00.000Z" }, + updatedAt: "2026-08-14T12:01:00.000Z", + }), + }), + ).toEqual({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:01:00.000Z", + }, + }); + expect( + controller.settle({ + target: write.environmentPatches[0]!, + result: AsyncResult.success({ + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T11:59:00.000Z" }, + updatedAt: "2026-08-14T11:59:00.000Z", + }), + }), + ).toBeNull(); + }); + + it("keeps offline toggles device-local", () => { + expect( + createPlanModePreferenceWrite({ + value: false, + connectedEnvironmentIds: [], + now: "2026-08-14T12:00:00.000Z", + }), + ).toEqual({ + localPatch: { + planModeEnabled: false, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + }, + environmentPatches: [], + }); + }); + + it("stamps only fields included in a partial local write", () => { + expect( + createSyncedClientPreferencesWrite({ + patch: { planModeEnabled: true }, + connectedEnvironmentIds: [environmentId("environment-1")], + currentUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + now: "2026-08-14T12:30:00.000Z", + }), + ).toEqual({ + localPatch: { + values: { planModeEnabled: true }, + updatedAtByField: { + planModeEnabled: "2026-08-14T12:30:00.000Z", + }, + }, + environmentPatches: [ + { + environmentId: environmentId("environment-1"), + input: { + commandId: CommandId.make("client-preferences:2026-08-14T12:30:00.000Z:1"), + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:30:00.000Z", + }, + }, + ], + }); + }); + + it("reconciles stale environments to the most recent stamped value", () => { + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T10:00:00.000Z", + environments: [ + { + environmentId: environmentId("environment-1"), + preferences: { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T11:00:00.000Z" }, + updatedAt: "2026-08-14T11:00:00.000Z", + }, + }, + { + environmentId: environmentId("environment-2"), + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(reconciliation.localPatch).toMatchObject({ planModeEnabled: true }); + expect(reconciliation.environmentPatches).toHaveLength(1); + expect(reconciliation.environmentPatches[0]?.environmentId).toBe( + environmentId("environment-1"), + ); + expect(reconciliation.environmentPatches[0]?.input.patch.planModeEnabled).toBe(true); + }); + + it("reconciles the deterministic equal-stamp winner after topology changes", async () => { + const updatedAt = "2026-08-14T12:00:00.000Z"; + const promotedUpdatedAt = "2026-08-14T12:00:00.001Z"; + 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, + updatedAtByField: { planModeEnabled: target.input.updatedAt }, + 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, + updatedAtByField: { planModeEnabled: updatedAt }, + updatedAt, + }, + }, + { + environmentId: higherEnvironmentId, + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: updatedAt }, + 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: promotedUpdatedAt }, + }, + environmentPatches: [ + { + environmentId: lowerEnvironmentId, + input: { + commandId: CommandId.make(`client-preferences:${promotedUpdatedAt}:1`), + patch: { planModeEnabled: true }, + updatedAt: promotedUpdatedAt, + }, + }, + { + environmentId: higherEnvironmentId, + input: { + commandId: CommandId.make(`client-preferences:${promotedUpdatedAt}:1`), + patch: { planModeEnabled: true }, + updatedAt: promotedUpdatedAt, + }, + }, + ], + }; + expect({ + ascending, + descending, + patchedValues: patch.mock.calls.map(([target]) => target), + }).toEqual({ + ascending: expected, + descending: expected, + patchedValues: [ + { + environmentId: lowerEnvironmentId, + input: { + commandId: CommandId.make(`client-preferences:${updatedAt}:0`), + patch: { planModeEnabled: false }, + updatedAt, + }, + }, + { + environmentId: lowerEnvironmentId, + input: { + commandId: CommandId.make(`client-preferences:${promotedUpdatedAt}:1`), + patch: { planModeEnabled: true }, + updatedAt: promotedUpdatedAt, + }, + }, + ], + }); + }); + + it("preserves the promoted equal-stamp winner across restart and topology changes", () => { + const updatedAt = "2026-08-14T12:00:00.000Z"; + const promotedUpdatedAt = "2026-08-14T12:00:00.001Z"; + const lowerEnvironmentId = environmentId("environment-1"); + const higherEnvironmentId = environmentId("environment-2"); + const initial = reconcilePlanModePreferences({ + localPlanModeEnabled: undefined, + localUpdatedAt: undefined, + environments: [ + { + environmentId: lowerEnvironmentId, + preferences: { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: updatedAt }, + updatedAt, + }, + }, + { + environmentId: higherEnvironmentId, + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: updatedAt }, + updatedAt, + }, + }, + ], + now: updatedAt, + }); + const afterRestart = reconcilePlanModePreferences({ + localPlanModeEnabled: initial.localPatch?.planModeEnabled, + localUpdatedAt: initial.localPatch?.syncedClientPreferencesUpdatedAtByField?.planModeEnabled, + environments: [ + { + environmentId: lowerEnvironmentId, + preferences: { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: updatedAt }, + updatedAt, + }, + }, + ], + now: promotedUpdatedAt, + }); + const afterReconnect = reconcilePlanModePreferences({ + localPlanModeEnabled: initial.localPatch?.planModeEnabled, + localUpdatedAt: initial.localPatch?.syncedClientPreferencesUpdatedAtByField?.planModeEnabled, + environments: [ + { + environmentId: higherEnvironmentId, + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: updatedAt }, + updatedAt, + }, + }, + ], + now: promotedUpdatedAt, + }); + + expect(initial.localPatch).toEqual({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { planModeEnabled: promotedUpdatedAt }, + }); + for (const reconciliation of [afterRestart, afterReconnect]) { + expect(reconciliation.localPatch).toBeNull(); + expect(reconciliation.environmentPatches[0]?.input).toEqual({ + commandId: CommandId.make(`client-preferences:${promotedUpdatedAt}:1`), + patch: { planModeEnabled: true }, + updatedAt: promotedUpdatedAt, + }); + } + }); + + it("attempts the same local reconciliation patch only once until it changes", () => { + const patch = { + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.000Z", + }, + } as const; + const first = resolvePlanModeLocalPatchPersistence({ attemptedKey: null, localPatch: patch }); + const repeated = resolvePlanModeLocalPatchPersistence({ + attemptedKey: first.nextAttemptedKey, + localPatch: patch, + }); + const optimisticWindow = resolvePlanModeLocalPatchPersistence({ + attemptedKey: repeated.nextAttemptedKey, + localPatch: null, + }); + const changed = resolvePlanModeLocalPatchPersistence({ + attemptedKey: optimisticWindow.nextAttemptedKey, + localPatch: { ...patch, planModeEnabled: false }, + }); + + expect([first.shouldPersist, repeated.shouldPersist, changed.shouldPersist]).toEqual([ + true, + false, + true, + ]); + expect(optimisticWindow.nextAttemptedKey).toBe(first.nextAttemptedKey); + }); + + it("reconciles when ES2023 change-by-copy array methods are unavailable", () => { + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, "toSorted"); + Reflect.defineProperty(Array.prototype, "toSorted", { + configurable: true, + value: undefined, + }); + + try { + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T10:00:00.000Z", + environments: [ + { + environmentId: environmentId("older"), + preferences: { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T11:00:00.000Z" }, + updatedAt: "2026-08-14T11:00:00.000Z", + }, + }, + { + environmentId: environmentId("newer"), + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(reconciliation.localPatch?.planModeEnabled).toBe(true); + } finally { + if (descriptor === undefined) { + Reflect.deleteProperty(Array.prototype, "toSorted"); + } else { + Reflect.defineProperty(Array.prototype, "toSorted", descriptor); + } + } + }); + + it("reuses the winning stamp across pre-ack reconciliation passes", () => { + const environments = [ + { + environmentId: environmentId("environment-1"), + preferences: { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T11:00:00.000Z" }, + updatedAt: "2026-08-14T11:00:00.000Z", + }, + }, + { + environmentId: environmentId("environment-2"), + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ]; + const first = reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T10:00:00.000Z", + environments, + now: "2026-08-14T12:01:00.000Z", + }); + const second = reconcilePlanModePreferences({ + localPlanModeEnabled: first.localPatch?.planModeEnabled, + localUpdatedAt: first.localPatch?.syncedClientPreferencesUpdatedAtByField?.planModeEnabled, + environments, + now: "2026-08-14T12:01:01.000Z", + }); + + expect(first.environmentPatches[0]?.input.updatedAt).toBe("2026-08-14T12:00:00.000Z"); + expect(second.environmentPatches[0]?.input.updatedAt).toBe( + first.environmentPatches[0]?.input.updatedAt, + ); + }); + + it("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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + { + environmentId: environmentId("stale"), + preferences: { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T11:00:00.000Z" }, + updatedAt: "2026-08-14T11:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(reconciliation.environmentPatches.map((target) => target.environmentId)).toEqual([ + environmentId("stale"), + ]); + }); + + it("preserves a newer local stamp when a later environment has an intermediate stamp", () => { + const localUpdatedAt = "2026-08-14T12:02:00.000Z"; + const first = reconcilePlanModePreferences({ + localPlanModeEnabled: true, + localUpdatedAt, + environments: [ + { + environmentId: environmentId("observed"), + preferences: { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:01:00.000Z" }, + 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: { + commandId: CommandId.make(`client-preferences:${localUpdatedAt}:1`), + patch: { planModeEnabled: true }, + updatedAt: localUpdatedAt, + }, + }, + ], + }, + { + localPatch: null, + environmentPatches: [ + { + environmentId: environmentId("later"), + input: { + commandId: CommandId.make(`client-preferences:${localUpdatedAt}:1`), + patch: { planModeEnabled: true }, + updatedAt: localUpdatedAt, + }, + }, + ], + }, + ]); + }); + + it("bounds a future-skewed local stamp just after the newest observed environment stamp", () => { + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: true, + localUpdatedAt: "2099-01-01T00:00:00.000Z", + environments: [ + { + environmentId: environmentId("environment-1"), + canPatch: true, + preferences: { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }); + + expect(reconciliation.localPatch).toEqual({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: "2026-08-14T12:00:00.001Z", + }, + }); + expect(reconciliation.environmentPatches).toEqual([ + { + environmentId: environmentId("environment-1"), + input: { + commandId: CommandId.make("client-preferences:2026-08-14T12:00:00.001Z:1"), + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.001Z", + }, + }, + ]); + }); + + it("clears pending reconciliation from an older canonical patch ack", async () => { + const target = { + environmentId: environmentId("environment-1"), + input: { + commandId: CommandId.make("client-preferences:test"), + patch: { planModeEnabled: true }, + updatedAt: "2099-01-01T00:00:00.000Z", + }, + } as const; + const canonical = { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:30.000Z" }, + updatedAt: "2026-08-14T12:00:30.000Z", + } as const; + + const controller = createPlanModePreferenceReconciliationController(); + const persist = vi.fn(); + controller.setActiveEnvironmentIds([target.environmentId]); + controller.reconcile({ + target, + patch: async () => AsyncResult.success(canonical), + persist, + }); + await flushReconciliation(); + + const next = reconcilePlanModePreferences({ + localPlanModeEnabled: canonical.planModeEnabled, + localUpdatedAt: canonical.updatedAt, + environments: [ + { + environmentId: target.environmentId, + canPatch: true, + preferences: canonical, + }, + ], + now: "2099-01-01T00:00:01.000Z", + }); + + expect(persist).toHaveBeenCalledWith({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { planModeEnabled: canonical.updatedAt }, + }); + expect(next.environmentPatches).toEqual([]); + }); + + it("does not seed a connected environment without patch scope", () => { + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: true, + localUpdatedAt: undefined, + environments: [ + { + environmentId: environmentId("read-only"), + canPatch: false, + preferences: undefined, + }, + ], + now: "2026-08-14T12:00:00.000Z", + }); + + expect(reconciliation.environmentPatches).toEqual([]); + }); + + it("keeps a newer local choice when the environment is read-only", () => { + expect( + reconcilePlanModePreferences({ + localPlanModeEnabled: false, + localUpdatedAt: "2026-08-14T12:01:00.000Z", + environments: [ + { + environmentId: environmentId("read-only"), + canPatch: false, + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }, + ], + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ localPatch: null, environmentPatches: [] }); + }); + + it("retries failed reconciliation and succeeds within the cap", async () => { + const environment = environmentId("environment-1"); + const target = { + environmentId: environment, + input: { + commandId: CommandId.make("client-preferences:test"), + 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, + updatedAtByField: { planModeEnabled: target.input.updatedAt }, + updatedAt: target.input.updatedAt, + }), + ); + const persist = vi.fn(); + + controller.setActiveEnvironmentIds([environment]); + controller.reconcile({ target, patch, persist }); + await flushReconciliation(); + + expect(patch).toHaveBeenCalledTimes(1); + expect(scheduled).toHaveLength(1); + expect(scheduled[0]?.delayMs).toBe(1_000); + + scheduled[0]?.run(); + await flushReconciliation(); + + expect(patch).toHaveBeenCalledTimes(2); + expect(persist).toHaveBeenCalledWith({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: target.input.updatedAt, + }, + }); + }); + + it("stops retrying automatically after exhausting reconciliation retries", async () => { + const environment = environmentId("environment-1"); + const target = { + environmentId: environment, + input: { + commandId: CommandId.make("client-preferences:test"), + 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]); + }); + + it("retries an exhausted reconciliation only after reconnecting", async () => { + const environment = environmentId("environment-1"); + const target = { + environmentId: environment, + input: { + commandId: CommandId.make("client-preferences:test"), + 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, + updatedAtByField: { planModeEnabled: target.input.updatedAt }, + 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(3); + + controller.setActiveEnvironmentIds([]); + controller.setActiveEnvironmentIds([environment]); + controller.reconcile({ target, patch, persist }); + await flushReconciliation(); + + expect(patch).toHaveBeenCalledTimes(4); + expect(persist).toHaveBeenCalledWith({ + planModeEnabled: true, + syncedClientPreferencesUpdatedAtByField: { + planModeEnabled: target.input.updatedAt, + }, + }); + }); + + it.each(["disconnect", "unmount"] as const)( + "cancels a scheduled reconciliation retry on %s", + async (lifecycleExit) => { + const environment = environmentId("environment-1"); + const target = { + environmentId: environment, + input: { + commandId: CommandId.make("client-preferences:test"), + 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 new file mode 100644 index 000000000000..46321fff1ea2 --- /dev/null +++ b/apps/mobile/src/state/synced-client-preferences.ts @@ -0,0 +1,310 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import { + AuthOrchestrationOperateScope, + getSyncedClientPreferenceUpdatedAt, + type EnvironmentId, + type SyncedClientPreferences, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import { environmentCatalog } from "../connection/catalog"; +import { environmentShell } from "./shell"; +import { environmentPresentations } from "./presentation"; +import { + mobilePreferencesAtom, + persistReconciledMobilePreferencesAtom, + updateMobilePreferencesAtom, +} from "./preferences"; +import { serverEnvironment } from "./server"; +import { environmentSession } from "./session"; +import { useAtomCommand } from "./use-atom-command"; +import { + advancePlanModePreferenceReconciliationKey, + createPlanModePreferenceReconciliationKey, + createPlanModePreferenceReconciliationController, + createPlanModePreferenceWriteController, + hasPlanModePreferenceReconciliationAttempted, + isPlanModePreferenceReconciliationReady, + reconcilePlanModePreferences, + resolvePlanModeLocalPatchPersistence, + shouldPreservePlanModeLocalValue, +} from "./synced-client-preferences-model"; + +interface EnvironmentPreferenceShellSlice { + readonly shellStatus: EnvironmentShellStatus; + readonly preferences: SyncedClientPreferences | undefined; +} + +const environmentPreferenceShellSliceAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: EnvironmentPreferenceShellSlice | undefined; + return Atom.make((get) => { + const shell = get(environmentShell.stateValueAtom(environmentId)); + const preferences = + shell.snapshot._tag === "Some" ? shell.snapshot.value.syncedClientPreferences : undefined; + if (previous?.shellStatus === shell.status && previous.preferences === preferences) { + return previous; + } + previous = { shellStatus: shell.status, preferences }; + return previous; + }); +}); + +const environmentCanPatchPreferencesAtom = Atom.family((environmentId: EnvironmentId) => { + let previous = false; + return Atom.make((get) => { + const session = get(environmentSession.sessionStateValueAtom(environmentId)); + const next = + session?.authenticated === true && + session.scopes?.includes(AuthOrchestrationOperateScope) === true; + if (next === previous) return previous; + previous = next; + return previous; + }); +}); + +interface ConnectedEnvironmentPreferenceState { + readonly environmentId: EnvironmentId; + readonly connectionState: EnvironmentConnectionPhase; + readonly shellStatus: EnvironmentShellStatus; + readonly preferences: SyncedClientPreferences | undefined; + readonly canPatch: boolean; +} + +let previousConnectedEnvironmentPreferenceStates: + | { + readonly connectionsLoaded: boolean; + readonly connectedEnvironmentIds: ReadonlyArray; + readonly reconciliationKey: string; + readonly states: ReadonlyArray; + } + | undefined; + +const connectedEnvironmentPreferenceStatesAtom = Atom.make((get) => { + const catalog = get(environmentCatalog.catalogValueAtom); + const presentations = get(environmentPresentations.presentationsAtom); + const states = [...presentations.entries()].map(([environmentId, presentation]) => { + const shell = get(environmentPreferenceShellSliceAtom(environmentId)); + return { + environmentId, + connectionState: presentation.connection.phase, + shellStatus: shell.shellStatus, + preferences: shell.preferences, + canPatch: get(environmentCanPatchPreferencesAtom(environmentId)), + }; + }); + const reconciliationKey = createPlanModePreferenceReconciliationKey( + states.map(({ environmentId, connectionState, shellStatus, preferences }) => ({ + environmentId, + connectionState, + shellStatus, + preferences, + })), + ); + const connectedEnvironmentIds = states + .filter((state) => state.connectionState === "connected" && state.canPatch) + .map((state) => state.environmentId); + const next = { + connectionsLoaded: catalog.isReady, + connectedEnvironmentIds, + reconciliationKey, + states, + } as const; + const previous = previousConnectedEnvironmentPreferenceStates; + if ( + previous !== undefined && + previous.connectionsLoaded === next.connectionsLoaded && + previous.reconciliationKey === next.reconciliationKey && + previous.states.length === next.states.length && + previous.states.every((state, index) => { + const candidate = next.states[index]; + return ( + candidate !== undefined && + state.environmentId === candidate.environmentId && + state.connectionState === candidate.connectionState && + state.shellStatus === candidate.shellStatus && + state.preferences === candidate.preferences && + state.canPatch === candidate.canPatch + ); + }) + ) { + return previous; + } + previousConnectedEnvironmentPreferenceStates = next; + return next; +}).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 { connectionsLoaded, reconciliationKey, states } = + useConnectedEnvironmentPreferenceStates(); + const reconciledKey = useAtomValue(planModePreferenceReconciledKeyAtom); + const setReconciledKey = useAtomSet(planModePreferenceReconciledKeyAtom); + const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { + label: "synced client preferences reconciliation", + reportFailure: false, + }); + const reconciliationController = useMemo( + () => createPlanModePreferenceReconciliationController(), + [], + ); + const attemptedLocalPatchKeyRef = useRef(null); + + useEffect(() => () => reconciliationController.reset(), [reconciliationController]); + + useEffect(() => { + const liveStates = states.filter( + ({ connectionState, shellStatus }) => + connectionState === "connected" && shellStatus === "live", + ); + reconciliationController.setActiveEnvironmentIds( + liveStates.filter(({ canPatch }) => canPatch).map(({ environmentId }) => environmentId), + ); + for (const { environmentId, preferences } of liveStates) { + reconciliationController.observe( + environmentId, + preferences?.planModeEnabled, + getSyncedClientPreferenceUpdatedAt(preferences, "planModeEnabled"), + ); + } + 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, shellStatus }) => ({ + connectionState, + shellStatus, + })), + ); + if (!reconciliationAttempted) return; + if (liveStates.length === 0) { + // A loaded catalog with only terminal offline states has no server value + // to apply. The device value governs until an environment reconnects. + setReconciledKey(nextReconciledKey); + return; + } + const reconciliation = reconcilePlanModePreferences({ + localPlanModeEnabled: preferencesResult.value.planModeEnabled, + localUpdatedAt: + preferencesResult.value.syncedClientPreferencesUpdatedAtByField?.planModeEnabled ?? + preferencesResult.value.syncedClientPreferencesUpdatedAt, + environments: liveStates.map(({ environmentId, preferences, canPatch }) => ({ + environmentId, + canPatch, + preferences, + })), + now: new Date().toISOString(), + preserveLocalOnEqualStamp: shouldPreservePlanModeLocalValue({ + currentKey: reconciliationKey, + appliedKey: reconciledKey, + }), + }); + const localPersistence = resolvePlanModeLocalPatchPersistence({ + attemptedKey: attemptedLocalPatchKeyRef.current, + localPatch: reconciliation.localPatch, + }); + attemptedLocalPatchKeyRef.current = localPersistence.nextAttemptedKey; + if (localPersistence.shouldPersist && reconciliation.localPatch !== null) { + savePreferences(reconciliation.localPatch); + } + for (const target of reconciliation.environmentPatches) { + reconciliationController.reconcile({ + target, + patch: patchPreferences, + persist: (patch) => + persistReconciledPreferences({ + expectedUpdatedAtByField: { planModeEnabled: target.input.updatedAt }, + patch, + }), + }); + } + setReconciledKey(nextReconciledKey); + }, [ + connectionsLoaded, + patchPreferences, + persistReconciledPreferences, + preferencesResult, + reconciledKey, + reconciliationKey, + reconciliationController, + savePreferences, + setReconciledKey, + 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, + }); + const writeController = useMemo(() => createPlanModePreferenceWriteController(), []); + + return useCallback( + (value: boolean) => { + const current = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : {}; + const write = writeController.create({ + value, + connectedEnvironmentIds, + currentUpdatedAtByField: current.syncedClientPreferencesUpdatedAtByField, + legacyCurrentUpdatedAt: current.syncedClientPreferencesUpdatedAt, + authoritativePreferences: states.map(({ preferences }) => preferences), + now: new Date().toISOString(), + }); + savePreferences(write.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, + 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 eede506976a7..c8148119c7d9 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/legacy-plan-mode"; import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; @@ -89,11 +90,28 @@ export interface ThreadSettingsSnapshot { export function resolveQueuedThreadSettings( message: QueuedThreadMessage, thread: ThreadSettingsSnapshot, + planModeEnabled: boolean, ): ThreadSettingsSnapshot { return { modelSelection: message.modelSelection ?? thread.modelSelection, runtimeMode: message.runtimeMode ?? thread.runtimeMode, - interactionMode: message.interactionMode ?? thread.interactionMode, + interactionMode: resolveComposerInteractionMode({ + interactionMode: message.interactionMode ?? thread.interactionMode, + planModeEnabled, + }), + }; +} + +export function resolveQueuedThreadSendDecision( + message: QueuedThreadMessage, + thread: ThreadSettingsSnapshot, + readPlanModeEnabled: () => boolean, +) { + const planModeEnabled = readPlanModeEnabled(); + return { + settings: resolveQueuedThreadSettings(message, thread, planModeEnabled), + readStartTurnInteractionMode: () => + resolveQueuedThreadSettings(message, thread, readPlanModeEnabled()).interactionMode, }; } diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index b12ad2dc5843..7e9383796d80 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -17,6 +17,7 @@ import { modelSelectionsEqual, resolveThreadOutboxDeliveryAction, resolveThreadOutboxFailureAction, + resolveQueuedThreadSendDecision, resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, @@ -98,11 +99,15 @@ describe("thread outbox", () => { selectedMessage, ); expect( - resolveQueuedThreadSettings(legacyMessage, { - modelSelection: selectedMessage.modelSelection, - runtimeMode: selectedMessage.runtimeMode, - interactionMode: selectedMessage.interactionMode, - }), + resolveQueuedThreadSettings( + legacyMessage, + { + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }, + true, + ), ).toEqual({ modelSelection: selectedMessage.modelSelection, runtimeMode: selectedMessage.runtimeMode, @@ -110,6 +115,60 @@ describe("thread outbox", () => { }); }); + it("forces queued plan-mode settings to default while legacy plan mode is disabled", () => { + const message = { + ...queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }), + interactionMode: "plan", + } satisfies QueuedThreadMessage; + const thread = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "approval-required", + interactionMode: "plan", + } as const; + + expect(resolveQueuedThreadSettings(message, thread, false).interactionMode).toBe("default"); + expect(resolveQueuedThreadSettings(message, thread, true).interactionMode).toBe("plan"); + }); + + it("uses one live interaction mode for metadata and turn delivery", () => { + let planModeEnabled = true; + let preferenceReads = 0; + const message = { + ...queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }), + interactionMode: "plan", + } satisfies QueuedThreadMessage; + const thread = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "approval-required", + interactionMode: "plan", + } as const; + const sendDecision = resolveQueuedThreadSendDecision(message, thread, () => { + preferenceReads += 1; + return planModeEnabled; + }); + + expect(sendDecision.settings.interactionMode).toBe("plan"); + expect(preferenceReads).toBe(1); + planModeEnabled = false; + const deliveryInteractionMode = sendDecision.readStartTurnInteractionMode(); + planModeEnabled = true; + + expect(deliveryInteractionMode).toBe("default"); + expect(preferenceReads).toBe(2); + }); + it("compares model options as part of the queued settings change", () => { const base = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 721c82a0e38e..df00157026dc 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -41,6 +41,11 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; +import { + resolveComposerEnqueueInteractionMode, + resolveComposerInteractionMode, +} from "../features/threads/legacy-plan-mode"; +import { useLegacyPlanModeState } from "../features/threads/use-legacy-plan-mode-enabled"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -78,6 +83,7 @@ export function useThreadComposerState() { const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); useEffect(() => { ensureComposerDraftsLoaded(); @@ -102,7 +108,10 @@ export function useThreadComposerState() { const selectedThread = selectedThreadDetail ?? selectedThreadShell; const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; - const interactionMode = selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null; + const interactionMode = resolveComposerInteractionMode({ + interactionMode: selectedDraft?.interactionMode ?? selectedThread?.interactionMode, + planModeEnabled, + }); const selectedThreadSessionActivity = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; @@ -142,6 +151,12 @@ export function useThreadComposerState() { if (text.length === 0 && attachments.length === 0) { return null; } + const enqueueInteractionMode = resolveComposerEnqueueInteractionMode({ + interactionMode: draft.interactionMode ?? thread.interactionMode, + planModeEnabled, + preferenceLoaded: planModePreferenceLoaded, + }); + if (enqueueInteractionMode === null) return null; const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); @@ -159,7 +174,7 @@ export function useThreadComposerState() { attachments, modelSelection: draft.modelSelection ?? thread.modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, - interactionMode: draft.interactionMode ?? thread.interactionMode, + interactionMode: enqueueInteractionMode, createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey); @@ -175,7 +190,7 @@ export function useThreadComposerState() { ); }); return messageId; - }, [selectedThreadDetail, selectedThreadShell]); + }, [planModeEnabled, planModePreferenceLoaded, selectedThreadDetail, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 68c973ff97e3..dcf4435d4bd9 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, + resolveLegacyPlanModeEnabled, +} from "../features/threads/legacy-plan-mode"; +import { useLegacyPlanModeState } from "../features/threads/use-legacy-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,14 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma return CommandId.make(`${message.commandId}:${setting}`); } +function readPlanModeEnabled(): boolean { + const preferences = appAtomRegistry.get(mobilePreferencesAtom); + return resolveLegacyPlanModeEnabled({ + loaded: AsyncResult.isSuccess(preferences), + preference: 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 +116,7 @@ export function useThreadOutboxDrain(): void { const shellStatuses = useThreadOutboxShellStatuses(); const threads = useThreadShells(); const projects = useProjects(); + const { loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); const { connectedEnvironments } = useRemoteConnectionStatus(); const [retryTick, setRetryTick] = useState(0); const retryAttemptRef = useRef(new Map()); @@ -167,7 +182,11 @@ export function useThreadOutboxDrain(): void { const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { - const settings = resolveQueuedThreadSettings(queuedMessage, thread); + const { settings, readStartTurnInteractionMode } = resolveQueuedThreadSendDecision( + queuedMessage, + thread, + readPlanModeEnabled, + ); const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage); if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { @@ -201,13 +220,14 @@ export function useThreadOutboxDrain(): void { } } - if (settings.interactionMode !== thread.interactionMode) { + const interactionMode = readStartTurnInteractionMode(); + if (interactionMode !== thread.interactionMode) { const interactionResult = await setThreadInteractionMode({ environmentId: queuedMessage.environmentId, input: { commandId: settingsCommandId(queuedMessage, "interaction-mode"), threadId: queuedMessage.threadId, - interactionMode: settings.interactionMode, + interactionMode, createdAt: queuedMessage.createdAt, }, }); @@ -230,7 +250,7 @@ export function useThreadOutboxDrain(): void { }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, - interactionMode: settings.interactionMode, + interactionMode, createdAt: queuedMessage.createdAt, }, }); @@ -251,6 +271,7 @@ export function useThreadOutboxDrain(): void { creation: QueuedThreadCreation, projectCwd: string, ) => { + const planModeEnabled = readPlanModeEnabled(); const modelSelection = queuedMessage.modelSelection; if (modelSelection === undefined) { return false; @@ -269,7 +290,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 +307,8 @@ export function useThreadOutboxDrain(): void { ); useEffect(() => { - if (dispatchingQueuedMessageId !== null) { + // Resolve queued interaction modes only after persisted preferences load. + if (!planModePreferenceLoaded || dispatchingQueuedMessageId !== null) { return; } @@ -414,6 +439,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 6b35f0d54e18..debc9a839eb8 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -39,6 +39,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, + [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..137d1c728d32 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,114 @@ 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:2026-08-14T12:00:00.000Z:1"); + const equalStampDistinctValueCommandId = CommandId.make( + "client-preferences:2026-08-14T12:00:00.000Z:0", + ); + const staleCommandId = CommandId.make("client-preferences-stale"); + + try { + const freshResult = await system.run( + system.engine.dispatch({ + type: "client-preferences.patch", + commandId: freshCommandId, + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }), + ); + const freshReceipt = Option.getOrThrow(await system.commandReceipt(freshCommandId)); + const events = Array.from( + await system.run(Stream.runCollect(system.engine.readEvents(0, 10))), + ); + + expect(freshReceipt).toMatchObject({ + commandId: freshCommandId, + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + resultSequence: freshResult.sequence, + status: "accepted", + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "client-preferences.patched", + sequence: freshResult.sequence, + payload: { + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + await expect(system.shellSnapshot()).resolves.toMatchObject({ + syncedClientPreferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + + const equalStampDistinctValueCommand = { + type: "client-preferences.patch", + commandId: equalStampDistinctValueCommandId, + patch: { planModeEnabled: false }, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const distinctValueResult = await system.run( + system.engine.dispatch(equalStampDistinctValueCommand), + ); + const retryResult = await system.run(system.engine.dispatch(equalStampDistinctValueCommand)); + const eventsAfterRetry = Array.from( + await system.run(Stream.runCollect(system.engine.readEvents(0, 10))), + ); + + expect(distinctValueResult.sequence).not.toBe(freshResult.sequence); + expect(retryResult.sequence).toBe(distinctValueResult.sequence); + expect( + Option.getOrThrow(await system.commandReceipt(equalStampDistinctValueCommandId)), + ).toMatchObject({ + resultSequence: distinctValueResult.sequence, + status: "accepted", + }); + expect(eventsAfterRetry).toHaveLength(2); + expect(eventsAfterRetry[1]).toMatchObject({ + type: "client-preferences.patched", + payload: { + patch: { planModeEnabled: false }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + await expect(system.shellSnapshot()).resolves.toMatchObject({ + syncedClientPreferences: { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "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: false, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + } finally { + await system.dispose(); + } + }); + it("bootstraps command handling from persisted projections without reading the full snapshot", async () => { let nextSequence = 8; const eventStore: OrchestrationEventStoreShape = { @@ -189,6 +304,7 @@ describe("OrchestrationEngine", () => { threads: [], updatedAt: projectionSnapshot.updatedAt, }), + getSyncedClientPreferences: () => Effect.succeed(undefined), getArchivedShellSnapshot: () => Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index da79b4395acb..a08176e39b73 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,9 +1,4 @@ -import type { - OrchestrationEvent, - OrchestrationReadModel, - ProjectId, - ThreadId, -} from "@t3tools/contracts"; +import type { OrchestrationEvent, OrchestrationReadModel } from "@t3tools/contracts"; import { OrchestrationCommand } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -58,23 +53,25 @@ interface CommandEnvelope { startedAtMs: number; } -function commandToAggregateRef(command: OrchestrationCommand): { - readonly aggregateKind: "project" | "thread"; - readonly aggregateId: ProjectId | ThreadId; -} { +function commandToAggregateRef(command: OrchestrationCommand) { switch (command.type) { + case "client-preferences.patch": + return { + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + } as const; case "project.create": case "project.meta.update": case "project.delete": return { aggregateKind: "project", aggregateId: command.projectId, - }; + } as const; default: return { aggregateKind: "thread", aggregateId: command.threadId, - }; + } as const; } } diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..2ed925ca60a4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -159,6 +159,12 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { `; assert.deepEqual(messageRows, [{ messageId: "message-1", text: "hello" }]); + const syncedPreferenceRows = yield* sql<{ readonly singletonId: number }>` + SELECT singleton_id AS "singletonId" + FROM projection_synced_client_preferences + `; + assert.deepEqual(syncedPreferenceRows, []); + const stateRows = yield* sql<{ readonly projector: string; readonly lastAppliedSequence: number; @@ -1171,6 +1177,80 @@ 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 }, + updatedAt: "2026-08-14T12: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 planModeEnabledUpdatedAt: string; + readonly updatedAt: string; + }>` + SELECT + plan_mode_enabled AS "planModeEnabled", + plan_mode_enabled_updated_at AS "planModeEnabledUpdatedAt", + updated_at AS "updatedAt" + FROM projection_synced_client_preferences + `; + const expected = [ + { + planModeEnabled: 0, + planModeEnabledUpdatedAt: "2026-08-14T12:00:00.000Z", + 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..908f0ce1e9a1 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,61 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + const applySyncedClientPreferencesProjection: ProjectorDefinition["apply"] = (event) => { + if (event.type !== "client-preferences.patched") { + return Effect.void; + } + const planModeEnabled = + event.payload.patch.planModeEnabled === undefined + ? null + : Number(event.payload.patch.planModeEnabled); + const planModeEnabledUpdatedAt = + event.payload.patch.planModeEnabled === undefined ? null : event.payload.updatedAt; + return sql` + INSERT INTO projection_synced_client_preferences ( + singleton_id, + plan_mode_enabled, + plan_mode_enabled_updated_at, + updated_at + ) + VALUES ( + 1, + ${planModeEnabled}, + ${planModeEnabledUpdatedAt}, + ${event.payload.updatedAt} + ) + ON CONFLICT (singleton_id) + DO UPDATE SET + plan_mode_enabled = CASE + WHEN excluded.plan_mode_enabled_updated_at IS NOT NULL + AND ( + plan_mode_enabled_updated_at IS NULL + OR excluded.plan_mode_enabled_updated_at >= plan_mode_enabled_updated_at + ) + THEN excluded.plan_mode_enabled + ELSE plan_mode_enabled + END, + plan_mode_enabled_updated_at = CASE + WHEN excluded.plan_mode_enabled_updated_at IS NOT NULL + AND ( + plan_mode_enabled_updated_at IS NULL + OR excluded.plan_mode_enabled_updated_at >= plan_mode_enabled_updated_at + ) + THEN excluded.plan_mode_enabled_updated_at + ELSE plan_mode_enabled_updated_at + END, + updated_at = MAX(updated_at, excluded.updated_at) + `.pipe( + Effect.asVoid, + Effect.mapError(toPersistenceSqlError("ProjectionPipeline.syncedClientPreferences:upsert")), + ); + }; + const projectors: ReadonlyArray = [ + { + name: ORCHESTRATION_PROJECTOR_NAMES.syncedClientPreferences, + apply: applySyncedClientPreferencesProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.projects, apply: applyProjectsProjection, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c6c5ad1d7e8c..480549c66e59 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,11 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), }); const ProjectionStateDbRowSchema = ProjectionState; +const ProjectionSyncedClientPreferencesRowSchema = Schema.Struct({ + planModeEnabled: Schema.NullOr(Schema.Number), + planModeEnabledUpdatedAt: Schema.NullOr(IsoDateTime), + updatedAt: IsoDateTime, +}); const ProjectionCountsRowSchema = Schema.Struct({ projectCount: Schema.Number, threadCount: Schema.Number, @@ -193,6 +199,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 +216,31 @@ function maxIso(left: string | null, right: string): string { return left > right ? left : right; } +function mapSyncedClientPreferences( + row: Option.Option>, +): SyncedClientPreferences | undefined { + return Option.match(row, { + onNone: () => undefined, + onSome: (value) => { + let preferences: SyncedClientPreferences = { + updatedAtByField: {}, + updatedAt: value.updatedAt, + }; + if (value.planModeEnabled !== null) { + preferences = { ...preferences, planModeEnabled: value.planModeEnabled !== 0 }; + } + let updatedAtByField: NonNullable = {}; + if (value.planModeEnabledUpdatedAt !== null) { + updatedAtByField = { + ...updatedAtByField, + planModeEnabled: value.planModeEnabledUpdatedAt, + }; + } + return { ...preferences, updatedAtByField }; + }, + }); +} + function escapeLikePattern(value: string): string { return value.replaceAll("!", "!!").replaceAll("%", "!%").replaceAll("_", "!_"); } @@ -763,6 +795,31 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getSyncedClientPreferencesRow = SqlSchema.findOneOption({ + Request: Schema.Void, + Result: ProjectionSyncedClientPreferencesRowSchema, + execute: () => + sql` + SELECT + plan_mode_enabled AS "planModeEnabled", + plan_mode_enabled_updated_at AS "planModeEnabledUpdatedAt", + updated_at AS "updatedAt" + FROM projection_synced_client_preferences + WHERE singleton_id = 1 + `, + }); + + const readSyncedClientPreferences = (operation: string) => + getSyncedClientPreferencesRow(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + `ProjectionSnapshotQuery.${operation}:query`, + `ProjectionSnapshotQuery.${operation}:decodeRow`, + ), + ), + Effect.map(mapSyncedClientPreferences), + ); + const readProjectionCounts = SqlSchema.findOne({ Request: Schema.Void, Result: ProjectionCountsRowSchema, @@ -1517,6 +1574,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + readSyncedClientPreferences("getSnapshot:getSyncedClientPreferences"), ]), ) .pipe( @@ -1531,6 +1589,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointRows, latestTurnRows, stateRows, + syncedClientPreferences, ]) => Effect.gen(function* () { const messagesByThread = new Map>(); @@ -1541,6 +1600,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const latestTurnByThread = new Map(); let updatedAt: string | null = null; + if (syncedClientPreferences !== undefined) { + updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); + } for (const row of projectRows) { updatedAt = maxIso(updatedAt, row.updatedAt); @@ -1717,6 +1779,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, + syncedClientPreferences, updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -1787,13 +1850,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + readSyncedClientPreferences("getCommandReadModel:getSyncedClientPreferences"), ]), ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + proposedPlanRows, + sessionRows, + latestTurnRows, + stateRows, + syncedClientPreferences, + ]) => Effect.sync(() => { let updatedAt: string | null = null; + if (syncedClientPreferences !== undefined) { + updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); + } const projects: OrchestrationProject[] = []; const threads: OrchestrationThread[] = []; @@ -1925,6 +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,110 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + readSyncedClientPreferences("getShellSnapshot:getSyncedClientPreferences"), ]), ) .pipe( - Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => - Effect.gen(function* () { - let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); + Effect.flatMap( + ([ + projectRows, + threadRows, + sessionRows, + latestTurnRows, + stateRows, + syncedClientPreferences, + ]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + if (syncedClientPreferences !== undefined) { + updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows); - const latestTurnByThread = new Map( - latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), - ); - const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), - ); - - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects: Arr.filterMap(projectRows, (row) => - row.deletedAt === null - ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), - ) - : Result.failVoid, - ), - threads: Arr.filterMap(threadRows, (row) => - row.deletedAt === null - ? Result.succeed({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, - ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - } satisfies OrchestrationThreadShell) - : Result.failVoid, - ), - updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; + const repositoryIdentities = + yield* resolveRepositoryIdentitiesForProjects(projectRows); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: Arr.filterMap(projectRows, (row) => + row.deletedAt === null + ? Result.succeed( + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ) + : Result.failVoid, ), - ), - ); - }), + threads: Arr.filterMap(threadRows, (row) => + row.deletedAt === null + ? Result.succeed({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + } satisfies OrchestrationThreadShell) + : Result.failVoid, + ), + syncedClientPreferences, + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + ), + ), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -2124,97 +2214,110 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + readSyncedClientPreferences("getArchivedShellSnapshot:getSyncedClientPreferences"), ]), ) .pipe( - Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => - Effect.gen(function* () { - let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); + Effect.flatMap( + ([ + projectRows, + threadRows, + sessionRows, + latestTurnRows, + stateRows, + syncedClientPreferences, + ]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + if (syncedClientPreferences !== undefined) { + updatedAt = maxIso(updatedAt, syncedClientPreferences.updatedAt); } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); - const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( - projectRows.filter((row) => activeProjectIds.has(row.projectId)), - ); - const latestTurnByThread = new Map( - latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), - ); - const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), - ); - - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects: Arr.filterMap(projectRows, (row) => - row.deletedAt === null && activeProjectIds.has(row.projectId) - ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), - ) - : Result.failVoid, - ), - threads: threadRows.map( - (row): OrchestrationThreadShell => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, - ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - }), - ), - updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; + const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( + projectRows.filter((row) => activeProjectIds.has(row.projectId)), + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: Arr.filterMap(projectRows, (row) => + row.deletedAt === null && activeProjectIds.has(row.projectId) + ? Result.succeed( + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ) + : Result.failVoid, ), - ), - ); - }), + threads: threadRows.map( + (row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + }), + ), + syncedClientPreferences, + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + ), + ), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -2239,6 +2342,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { })), ); + const getSyncedClientPreferences: ProjectionSnapshotQuery["Service"]["getSyncedClientPreferences"] = + () => readSyncedClientPreferences("getSyncedClientPreferences"); + const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => readProjectionCounts(undefined).pipe( Effect.mapError( @@ -2815,6 +2921,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getCommandReadModel, getSnapshot, getShellSnapshot, + getSyncedClientPreferences, getArchivedShellSnapshot, searchThreads, getSnapshotSequence, diff --git a/apps/server/src/orchestration/Normalizer.test.ts b/apps/server/src/orchestration/Normalizer.test.ts index 113b6e1baeed..e2d2f31b1c8b 100644 --- a/apps/server/src/orchestration/Normalizer.test.ts +++ b/apps/server/src/orchestration/Normalizer.test.ts @@ -70,4 +70,29 @@ describe("canonicalizeClientCommandTimestamps", () => { expect(result.createdAt).toBe(serverReceivedAt); expect(result.bootstrap?.createThread?.createdAt).toBe(serverReceivedAt); }); + + it("falls back to server time for an excessively future-skewed preference stamp", () => { + const command: ClientOrchestrationCommand = { + type: "client-preferences.patch", + commandId: CommandId.make("command-preferences-future"), + patch: { planModeEnabled: true }, + updatedAt: "2026-07-18T00:05:00.001Z", + }; + + expect(canonicalizeClientCommandTimestamps(command, serverReceivedAt)).toEqual({ + ...command, + updatedAt: serverReceivedAt, + }); + }); + + it("preserves a preference stamp within the allowed future skew", () => { + const command: ClientOrchestrationCommand = { + type: "client-preferences.patch", + commandId: CommandId.make("command-preferences-allowed-skew"), + patch: { planModeEnabled: true }, + updatedAt: "2026-07-18T00:05:00.000Z", + }; + + expect(canonicalizeClientCommandTimestamps(command, serverReceivedAt)).toEqual(command); + }); }); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 24c65900b296..ec4e6dbee281 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -15,6 +15,8 @@ import { ServerConfig } from "../config.ts"; import { parseBase64DataUrl } from "../imageMime.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +export const SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; + export const canonicalizeClientCommandTimestamps = ( command: ClientOrchestrationCommand, receivedAt: IsoDateTime, @@ -27,6 +29,16 @@ export const canonicalizeClientCommandTimestamps = ( } : command; + if (canonicalCommand.type === "client-preferences.patch") { + const maximumUpdatedAt = + DateTime.toEpochMillis(DateTime.makeUnsafe(receivedAt)) + + SYNCED_CLIENT_PREFERENCES_MAX_FUTURE_SKEW_MS; + return DateTime.toEpochMillis(DateTime.makeUnsafe(canonicalCommand.updatedAt)) > + maximumUpdatedAt + ? { ...canonicalCommand, updatedAt: receivedAt } + : canonicalCommand; + } + if (canonicalCommand.type !== "thread.turn.start" || !canonicalCommand.bootstrap?.createThread) { return canonicalCommand; } diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 0a00253a2285..11f837c76f19 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -20,6 +20,7 @@ import type { OrchestrationThreadDetailWindow, OrchestrationThreadShell, ProjectId, + SyncedClientPreferences, ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -86,6 +87,12 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** Read only the singleton cross-client preferences projection. */ + readonly getSyncedClientPreferences: () => Effect.Effect< + SyncedClientPreferences | undefined, + ProjectionRepositoryError + >; + /** * Read archived thread shell summaries for the archive page. * diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6aa..2d36b1cfff15 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -224,6 +224,21 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" Crypto.Crypto > { switch (command.type) { + case "client-preferences.patch": + return { + ...(yield* withEventBase({ + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + occurredAt: command.updatedAt, + commandId: command.commandId, + })), + type: "client-preferences.patched", + payload: { + patch: command.patch, + updatedAt: command.updatedAt, + }, + }; + case "project.create": { yield* requireProjectAbsent({ readModel, diff --git a/apps/server/src/orchestration/projector.synced-client-preferences.test.ts b/apps/server/src/orchestration/projector.synced-client-preferences.test.ts new file mode 100644 index 000000000000..67821850c056 --- /dev/null +++ b/apps/server/src/orchestration/projector.synced-client-preferences.test.ts @@ -0,0 +1,57 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + type OrchestrationEvent, + type SyncedClientPreferencesPatch, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function preferenceEvent(input: { + readonly sequence: number; + readonly updatedAt: string; + readonly patch: SyncedClientPreferencesPatch; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`preferences-${input.sequence}`), + type: "client-preferences.patched", + aggregateKind: "client-preferences", + aggregateId: "client-preferences", + occurredAt: input.updatedAt, + commandId: CommandId.make(`preferences-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { patch: input.patch, updatedAt: input.updatedAt }, + }; +} + +it.effect("keeps the newest Plan Mode value and its field clock", () => + Effect.gen(function* () { + const current = yield* projectEvent( + createEmptyReadModel("2026-08-14T10:00:00.000Z"), + preferenceEvent({ + sequence: 1, + updatedAt: "2026-08-14T13:00:00.000Z", + patch: { planModeEnabled: true }, + }), + ); + const afterStaleEvent = yield* projectEvent( + current, + preferenceEvent({ + sequence: 2, + updatedAt: "2026-08-14T12:00:00.000Z", + patch: { planModeEnabled: false }, + }), + ); + + assert.deepEqual(afterStaleEvent.syncedClientPreferences, { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T13:00:00.000Z" }, + updatedAt: "2026-08-14T13:00:00.000Z", + }); + }), +); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..212295a8c279 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,5 +1,7 @@ import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { + ClientPreferencesPatchedPayload, + getSyncedClientPreferenceUpdatedAt, OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, @@ -205,6 +207,38 @@ 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; + const appliesPlanMode = + payload.patch.planModeEnabled !== undefined && + (getSyncedClientPreferenceUpdatedAt(current, "planModeEnabled") ?? payload.updatedAt) <= + payload.updatedAt; + return { + ...nextBase, + updatedAt: model.updatedAt > event.occurredAt ? model.updatedAt : event.occurredAt, + syncedClientPreferences: { + ...current, + ...(appliesPlanMode ? { planModeEnabled: payload.patch.planModeEnabled } : undefined), + updatedAtByField: { + ...current?.updatedAtByField, + ...(appliesPlanMode ? { planModeEnabled: payload.updatedAt } : undefined), + }, + updatedAt: + current !== undefined && current.updatedAt > payload.updatedAt + ? current.updatedAt + : payload.updatedAt, + }, + }; + }), + ); + case "project.created": return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => { diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 18d0e9aa578b..97843487e3b0 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -9,6 +9,7 @@ import { OrchestrationEventMetadata, OrchestrationEventType, ProjectId, + SyncedClientPreferencesAggregateId, ThreadId, } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -35,7 +36,7 @@ const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMeta const AppendEventRequestSchema = Schema.Struct({ eventId: EventId, aggregateKind: OrchestrationAggregateKind, - streamId: Schema.Union([ProjectId, ThreadId]), + streamId: Schema.Union([SyncedClientPreferencesAggregateId, ProjectId, ThreadId]), type: OrchestrationEventType, causationEventId: Schema.NullOr(EventId), correlationId: Schema.NullOr(CommandId), @@ -51,7 +52,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ eventId: EventId, type: OrchestrationEventType, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([SyncedClientPreferencesAggregateId, ProjectId, ThreadId]), occurredAt: IsoDateTime, commandId: Schema.NullOr(CommandId), causationEventId: Schema.NullOr(EventId), diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..20743e69e08e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_ProjectionSyncedClientPreferences.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "ProjectionSyncedClientPreferences", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts new file mode 100644 index 000000000000..5ddd957ff110 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.test.ts @@ -0,0 +1,78 @@ +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* runMigrations({ toMigrationInclusive: 41 }); + yield* runMigrations({ toMigrationInclusive: 41 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_synced_client_preferences) + `; + const rows = yield* sql<{ readonly singletonId: number }>` + SELECT singleton_id AS "singletonId" + FROM projection_synced_client_preferences + `; + const projectorState = yield* sql<{ + readonly projector: string; + readonly lastAppliedSequence: number; + }>` + SELECT + projector, + last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = 'projection.synced-client-preferences' + `; + + assert.deepEqual( + columns.map((column) => column.name), + ["singleton_id", "plan_mode_enabled", "plan_mode_enabled_updated_at", "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..1b9985363b94 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionSyncedClientPreferences.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_synced_client_preferences ( + singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), + plan_mode_enabled INTEGER, + plan_mode_enabled_updated_at TEXT, + 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..243fb533bc0a 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,117 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("gates incremental client preference patches for legacy shell subscribers", () => + Effect.gen(function* () { + const canonicalPreferences = { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + let patchedEvent: OrchestrationEvent | null = null; + const dispatchedCommands: Array<{ + readonly commandId: CommandId; + readonly planModeEnabled: boolean | undefined; + }> = []; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + if (command.type === "client-preferences.patch") { + dispatchedCommands.push({ + commandId: command.commandId, + planModeEnabled: command.patch.planModeEnabled, + }); + 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 patchPreferences = (value: boolean) => ({ + commandId: CommandId.make( + `client-preferences:${canonicalPreferences.updatedAt}:${value ? "1" : "0"}`, + ), + patch: { planModeEnabled: value }, + updatedAt: canonicalPreferences.updatedAt, + }); + const truePatch = patchPreferences(true); + const falsePatch = patchPreferences(false); + const ack = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.syncedClientPreferencesPatch](truePatch), + ), + ); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.syncedClientPreferencesPatch](falsePatch), + ), + ); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.syncedClientPreferencesPatch](falsePatch), + ), + ); + assert.deepEqual(dispatchedCommands, [ + { commandId: truePatch.commandId, planModeEnabled: true }, + { commandId: falsePatch.commandId, planModeEnabled: false }, + { commandId: falsePatch.commandId, planModeEnabled: false }, + ]); + const legacyItems = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.take(1), Stream.runCollect), + ), + ); + const capableItems = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + clientPreferencesStreamItem: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + assert.deepEqual(ack, canonicalPreferences); + assert.deepEqual(legacyItems, [{ kind: "synchronized" }]); + assert.deepEqual(capableItems[0], { + kind: "client-preferences-updated", + sequence: 1, + preferences: canonicalPreferences, + }); + assert.deepEqual(capableItems[1], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("marks an empty shell catch-up replay as synchronized when requested", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e3f7e482b2e0..898f1a7981ab 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -80,6 +80,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getSyncedClientPreferences: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => @@ -138,6 +139,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getSyncedClientPreferences: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -195,6 +197,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getSyncedClientPreferences: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -246,6 +249,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getSyncedClientPreferences: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ebcf65e4b47c..2a56927e872e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -543,10 +543,12 @@ const makeWsRpcLayer = ( }); }; - const toShellStreamEvent = ( + const toShellStreamItem = ( event: OrchestrationEvent, - ): Effect.Effect, never, never> => { + ): Effect.Effect, never, never> => { switch (event.type) { + case "client-preferences.patched": + return clientPreferencesUpdated(event.sequence); case "project.created": case "project.meta-updated": return projectUpsertOrRemove(event.payload.projectId, event.sequence); @@ -583,7 +585,7 @@ const makeWsRpcLayer = ( // If both attempts fail, log and drop the stream item; treating an error as // a missing row would incorrectly remove a still-active aggregate. const retryShellProjectionRead = ( - aggregateKind: "project" | "thread", + aggregateKind: "client-preferences" | "project" | "thread", aggregateId: string, read: Effect.Effect, ): Effect.Effect, never, never> => @@ -600,6 +602,27 @@ const makeWsRpcLayer = ( Effect.orElseSucceed(() => Option.none()), ); + const clientPreferencesUpdated = ( + sequence: number, + ): Effect.Effect, never, never> => + retryShellProjectionRead( + "client-preferences", + "client-preferences", + projectionSnapshotQuery.getSyncedClientPreferences(), + ).pipe( + Effect.map( + Option.flatMap((preferences) => + preferences === undefined + ? Option.none() + : Option.some({ + kind: "client-preferences-updated", + sequence, + preferences, + }), + ), + ), + ); + const projectUpsertOrRemove = ( projectId: ProjectId, sequence: number, @@ -669,7 +692,7 @@ const makeWsRpcLayer = ( ); // Turn a batch of domain events into shell stream items, coalescing by - // aggregate first. `toShellStreamEvent` re-reads the *current* projected + // aggregate first. `toShellStreamItem` re-reads the *current* projected // shell for an aggregate, so within a batch only the latest event per // aggregate matters: a burst of streaming `thread.message-sent` deltas for // one thread collapses into a single shell refetch, and an unrelated @@ -683,7 +706,7 @@ const makeWsRpcLayer = ( const SHELL_REFETCH_CONCURRENCY = 8; const coalesceShellEvents = ( events: ReadonlyArray, - ): Effect.Effect, never, never> => + ): Effect.Effect, never, never> => Effect.gen(function* () { if (events.length === 0) { return []; @@ -695,7 +718,7 @@ const makeWsRpcLayer = ( const survivors = Array.from(latestByAggregate.values()).sort( (left, right) => left.sequence - right.sequence, ); - const shellEvents = yield* Effect.forEach(survivors, toShellStreamEvent, { + const shellEvents = yield* Effect.forEach(survivors, toShellStreamItem, { concurrency: SHELL_REFETCH_CONCURRENCY, }); return shellEvents.flatMap((option) => (Option.isSome(option) ? [option.value] : [])); @@ -709,7 +732,7 @@ const makeWsRpcLayer = ( const SHELL_COALESCE_MAX_CHUNK = 512; const coalesceShellStream = ( stream: Stream.Stream, - ): Stream.Stream => + ): Stream.Stream => stream.pipe( Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), Stream.mapEffect(coalesceShellEvents), @@ -1209,6 +1232,14 @@ const makeWsRpcLayer = ( { startImmediately: true }, ); const bufferedLiveStream = coalesceShellLiveStream(Stream.fromQueue(liveBuffer)); + const compatibleShellStream = ( + stream: Stream.Stream, + ): Stream.Stream => + input.clientPreferencesStreamItem === true + ? stream + : stream.pipe( + Stream.filter((item) => item.kind !== "client-preferences-updated"), + ); const loadSnapshot = projectionSnapshotQuery.getShellSnapshot().pipe( Effect.tapError((cause) => @@ -1257,9 +1288,11 @@ const makeWsRpcLayer = ( // no-afterSequence path does. if (replayGap < 0 || replayGap > SHELL_RESUME_MAX_GAP) { const snapshot = yield* loadSnapshot; - return Stream.concat( - Stream.make({ kind: "snapshot" as const, snapshot }), - synchronizedThenLive, + return compatibleShellStream( + Stream.concat( + Stream.make({ kind: "snapshot" as const, snapshot }), + synchronizedThenLive, + ), ); } const catchUpStream = coalesceShellStream( @@ -1277,16 +1310,18 @@ const makeWsRpcLayer = ( }), ), ); - return Stream.concat(catchUpStream, synchronizedThenLive); + return compatibleShellStream(Stream.concat(catchUpStream, synchronizedThenLive)); } const snapshot = yield* loadSnapshot; - return Stream.concat( - Stream.make({ - kind: "snapshot" as const, - snapshot, - }), - synchronizedThenLive, + return compatibleShellStream( + Stream.concat( + Stream.make({ + kind: "snapshot" as const, + snapshot, + }), + synchronizedThenLive, + ), ); }), { "rpc.aggregate": "orchestration" }, @@ -1531,6 +1566,35 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.syncedClientPreferencesPatch]: ({ commandId, patch, updatedAt }) => + observeRpcEffect( + WS_METHODS.syncedClientPreferencesPatch, + Effect.gen(function* () { + const normalizedCommand = yield* normalizeDispatchCommand({ + type: "client-preferences.patch", + commandId, + patch, + updatedAt, + }); + yield* dispatchNormalizedCommand(normalizedCommand); + const preferences = yield* projectionSnapshotQuery.getSyncedClientPreferences().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load patched client preferences", + cause, + }), + ), + ); + if (preferences === undefined) { + return yield* new OrchestrationGetSnapshotError({ + message: "Synced client preferences projection is missing after patch", + }); + } + return preferences; + }), + { "rpc.aggregate": "client-preferences" }, + ), [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( WS_METHODS.serverDiscoverSourceControl, diff --git a/apps/web/src/hooks/synced-plan-mode.ts b/apps/web/src/hooks/synced-plan-mode.ts new file mode 100644 index 000000000000..2c4990ba40ce --- /dev/null +++ b/apps/web/src/hooks/synced-plan-mode.ts @@ -0,0 +1,552 @@ +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; +import { + createPlanModePreferencePatchRequest, + SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS, + syncedClientPreferenceRetryDelayMs, +} from "@t3tools/client-runtime/synced-client-preferences"; +import { + getSyncedClientPreferenceUpdatedAt, + nextSyncedClientPreferencesUpdatedAt, + type EnvironmentId, + type PatchSyncedClientPreferencesRequest, + type SyncedClientPreferences, + SyncedClientPreferencesUpdatedAt, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useMemo } from "react"; + +export const SHELL_NOT_LIVE = Symbol("shell-not-live"); +const isSyncedClientPreferencesUpdatedAt = Schema.is(SyncedClientPreferencesUpdatedAt); + +function validSyncedClientPreferencesUpdatedAt(updatedAt: string | undefined): string | undefined { + return updatedAt !== undefined && isSyncedClientPreferencesUpdatedAt(updatedAt) + ? updatedAt + : undefined; +} + +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 | undefined; + readonly now: string; +}) { + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( + input.serverPreferences, + "planModeEnabled", + ); + const pendingUpdatedAt = validSyncedClientPreferencesUpdatedAt(input.pendingUpdatedAt); + const currentUpdatedAt = + pendingUpdatedAt !== undefined && + (serverUpdatedAt === undefined || pendingUpdatedAt > serverUpdatedAt) + ? pendingUpdatedAt + : serverUpdatedAt; + const updatedAt = nextSyncedClientPreferencesUpdatedAt([currentUpdatedAt], input.now); + return { request: createPlanModePreferencePatchRequest(input.value, updatedAt) } 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 clientUpdatedAt?: string | undefined; + readonly live: boolean; + readonly serverPreferences: SyncedClientPreferences | undefined; + readonly canPatch: boolean; + readonly now: string; + readonly patch: SyncedPlanModePatch; + readonly persist: (value: boolean, updatedAt: string) => void; + readonly onHydrated?: (() => void) | undefined; +} + +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 resolveSyncedPlanModeCoordinatorEnvironmentIds(input: { + readonly environmentIds: ReadonlyArray; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly hydratedPrimaryEnvironmentId: EnvironmentId | null; + readonly primaryUnavailable: boolean; +}): ReadonlyArray { + if (input.primaryEnvironmentId === null) return []; + if (input.primaryUnavailable) return input.environmentIds; + if (input.hydratedPrimaryEnvironmentId !== input.primaryEnvironmentId) { + return input.environmentIds.includes(input.primaryEnvironmentId) + ? [input.primaryEnvironmentId] + : []; + } + return input.environmentIds; +} + +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), + }; +} + +type SyncedPlanModeRetryScheduler = (retry: () => void, delayMs: number) => () => void; + +const scheduleSyncedPlanModeRetry: SyncedPlanModeRetryScheduler = (retry, delayMs) => { + const timer = setTimeout(retry, delayMs); + return () => clearTimeout(timer); +}; + +export function createSyncedPlanModeHydrationController( + scheduleRetry: SyncedPlanModeRetryScheduler = scheduleSyncedPlanModeRetry, +) { + interface SyncedPlanModeEnvironmentState { + adoptedUpdatedAt?: string; + seedPendingUpdatedAt?: string; + writePending?: { readonly value: boolean; readonly updatedAt: string }; + writeInFlightUpdatedAt?: string; + pendingAdoption?: { readonly value: boolean; readonly updatedAt: string }; + readonly synchronizeAgainByOwner: Map void>; + cancelRetry?: () => void; + patchAttempt: number; + retryEpochActive: boolean; + lastCanPatch: boolean; + } + + 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(), + patchAttempt: 0, + retryEpochActive: false, + lastCanPatch: false, + }; + 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.patchAttempt >= SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS || + state.cancelRetry !== undefined || + getSynchronizeAgain(state) === undefined + ) { + return; + } + const delayMs = syncedClientPreferenceRetryDelayMs(state.patchAttempt); + state.cancelRetry = scheduleRetry(() => { + delete state.cancelRetry; + getSynchronizeAgain(state)?.(); + }, delayMs); + }; + 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, updatedAt: string) => void; + readonly onHydrated: (() => void) | undefined; + }) => { + const state = stateByEnvironment.get(input.environmentId); + if (state === undefined) return; + if (state.writeInFlightUpdatedAt === input.requestedUpdatedAt) { + delete state.writeInFlightUpdatedAt; + } + if (input.result._tag === "Failure") { + const matchingWrite = state.writePending?.updatedAt === input.requestedUpdatedAt; + const matchingSeed = state.seedPendingUpdatedAt === input.requestedUpdatedAt; + if (!matchingWrite && !matchingSeed) return; + if (matchingSeed) delete state.seedPendingUpdatedAt; + requestRetry(state); + return; + } + + const pendingWrite = state.writePending; + const matchingWrite = pendingWrite?.updatedAt === input.requestedUpdatedAt; + const seedMatchesRequest = state.seedPendingUpdatedAt === input.requestedUpdatedAt; + const matchingSeed = + seedMatchesRequest && + (pendingWrite === undefined || pendingWrite.updatedAt <= input.requestedUpdatedAt); + if (seedMatchesRequest) delete state.seedPendingUpdatedAt; + if (!matchingWrite && !matchingSeed) return; + + cancelRetry(state); + state.patchAttempt = 0; + if (matchingWrite) delete state.writePending; + const resultUpdatedAt = getSyncedClientPreferenceUpdatedAt( + input.result.value, + "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, state.pendingAdoption.updatedAt); + markAdopted(state, state.pendingAdoption.updatedAt); + input.onHydrated?.(); + }; + + const dispatchPatch = (input: { + readonly target: SyncedPlanModePatchTarget; + readonly patch: SyncedPlanModePatch; + readonly persist: (value: boolean, updatedAt: string) => void; + readonly onHydrated: (() => void) | undefined; + }) => { + const { environmentId } = input.target; + const requestedUpdatedAt = input.target.input.updatedAt; + const state = stateFor(environmentId); + state.patchAttempt += 1; + state.writeInFlightUpdatedAt = requestedUpdatedAt; + void input.patch(input.target).then((result) => { + settlePatch({ + environmentId, + requestedUpdatedAt, + result, + persist: input.persist, + onHydrated: input.onHydrated, + }); + }); + }; + + const synchronize = ( + input: SyncedPlanModeHydrationInput, + owner = imperativeSynchronizationOwner, + ) => { + const environmentId = input.environmentId; + if (environmentId === null) return; + const state = stateFor(environmentId); + if (!input.live) { + state.retryEpochActive = false; + state.lastCanPatch = input.canPatch; + deactivate(environmentId, owner); + return; + } + if (!state.retryEpochActive || (!state.lastCanPatch && input.canPatch)) { + cancelRetry(state); + state.patchAttempt = 0; + } + state.retryEpochActive = true; + state.lastCanPatch = input.canPatch; + state.synchronizeAgainByOwner.set(owner, () => synchronize(input, owner)); + const deactivateSynchronization = () => deactivate(environmentId, owner); + if (input.serverPreferences?.planModeEnabled !== undefined) { + delete state.seedPendingUpdatedAt; + } + const serverUpdatedAt = getSyncedClientPreferenceUpdatedAt( + input.serverPreferences, + "planModeEnabled", + ); + const clientUpdatedAt = validSyncedClientPreferencesUpdatedAt(input.clientUpdatedAt); + if ( + state.writePending === undefined && + clientUpdatedAt !== undefined && + (serverUpdatedAt === undefined || serverUpdatedAt < clientUpdatedAt) + ) { + state.writePending = { + value: input.clientValue, + updatedAt: clientUpdatedAt, + }; + } + const pendingWrite = state.writePending; + 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, state.pendingAdoption.updatedAt); + } else if (clientUpdatedAt !== state.pendingAdoption.updatedAt) { + input.persist(state.pendingAdoption.value, state.pendingAdoption.updatedAt); + } + markAdopted(state, state.pendingAdoption.updatedAt); + input.onHydrated?.(); + } + if ( + pendingWrite !== undefined && + serverUpdatedAt !== undefined && + serverUpdatedAt >= pendingWrite.updatedAt + ) { + delete state.writePending; + delete state.writeInFlightUpdatedAt; + cancelRetry(state); + state.patchAttempt = 0; + } + const activePendingWrite = state.writePending; + if ( + input.canPatch && + activePendingWrite !== undefined && + state.patchAttempt < SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS && + (serverUpdatedAt === undefined || serverUpdatedAt < activePendingWrite.updatedAt) && + state.writeInFlightUpdatedAt !== activePendingWrite.updatedAt && + state.cancelRetry === undefined + ) { + dispatchPatch({ + target: { + environmentId, + input: createPlanModePreferencePatchRequest( + activePendingWrite.value, + activePendingWrite.updatedAt, + ), + }, + patch: input.patch, + persist: input.persist, + onHydrated: input.onHydrated, + }); + } + let hydrationInput: Parameters[0] = { + 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); + if (action.type === "adopt") { + if (environmentId !== input.primaryEnvironmentId) { + if (!input.canPatch || input.clientValue === action.value) { + return deactivateSynchronization; + } + const next = createSyncedPlanModeWrite({ + value: input.clientValue, + serverPreferences: input.serverPreferences, + pendingUpdatedAt: clientUpdatedAt, + now: input.now, + }); + state.writePending = { + value: input.clientValue, + updatedAt: next.request.updatedAt, + }; + input.persist(input.clientValue, next.request.updatedAt); + dispatchPatch({ + target: { environmentId, input: next.request }, + patch: input.patch, + persist: input.persist, + onHydrated: input.onHydrated, + }); + return deactivateSynchronization; + } + if (!input.canPatch) { + input.onHydrated?.(); + return deactivateSynchronization; + } + markAdopted(state, action.updatedAt); + if (input.clientValue !== action.value || clientUpdatedAt !== action.updatedAt) { + input.persist(action.value, action.updatedAt); + } + input.onHydrated?.(); + return deactivateSynchronization; + } + if (action.type !== "seed") { + if ( + environmentId === input.primaryEnvironmentId && + state.writePending === undefined && + state.writeInFlightUpdatedAt === undefined + ) { + input.onHydrated?.(); + } + return deactivateSynchronization; + } + if (!input.canPatch) { + input.onHydrated?.(); + return deactivateSynchronization; + } + + state.seedPendingUpdatedAt = action.updatedAt; + dispatchPatch({ + target: { + environmentId, + input: createPlanModePreferencePatchRequest(action.value, action.updatedAt), + }, + patch: input.patch, + persist: input.persist, + onHydrated: input.onHydrated, + }); + + 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, updatedAt: string) => void; + }) => { + if (input.environmentId === null) return; + const environmentId = input.environmentId; + const state = stateFor(environmentId); + cancelRetry(state); + state.patchAttempt = 0; + const controllerUpdatedAt = [ + state.adoptedUpdatedAt, + state.seedPendingUpdatedAt, + state.writePending?.updatedAt, + state.writeInFlightUpdatedAt, + state.pendingAdoption?.updatedAt, + ].reduce( + (latest, candidate) => + candidate !== undefined && (latest === undefined || candidate > latest) + ? candidate + : latest, + undefined, + ); + let writeInput: Parameters[0] = { + value: input.value, + serverPreferences: input.serverPreferences, + now: input.now, + }; + if (controllerUpdatedAt !== undefined) { + writeInput = { ...writeInput, pendingUpdatedAt: controllerUpdatedAt }; + } + const next = createSyncedPlanModeWrite(writeInput); + state.writePending = { + value: input.value, + updatedAt: next.request.updatedAt, + }; + input.persist(input.value, next.request.updatedAt); + delete state.pendingAdoption; + if (!input.canPatch) return; + dispatchPatch({ + target: { environmentId, input: next.request }, + patch: input.patch, + persist: input.persist, + onHydrated: undefined, + }); + }; + + return { + synchronize, + write, + getPendingWrite(environmentId: EnvironmentId | null) { + if (environmentId === null) return undefined; + const state = stateByEnvironment.get(environmentId); + return state?.writePending ?? state?.pendingAdoption; + }, + reset() { + for (const state of stateByEnvironment.values()) { + cancelRetry(state); + state.synchronizeAgainByOwner.clear(); + } + stateByEnvironment.clear(); + }, + }; +} + +export function useSyncedPlanModeHydrationEffect( + controller: ReturnType, + input: SyncedPlanModeHydrationInput, +): void { + const synchronizationOwner = useMemo(() => Symbol(), [controller]); + useEffect( + () => controller.synchronize(input, synchronizationOwner), + [ + controller, + input.canPatch, + input.clientHydrated, + input.clientUpdatedAt, + input.clientValue, + input.environmentId, + input.live, + input.patch, + input.persist, + input.onHydrated, + input.primaryEnvironmentId, + input.serverPreferences, + synchronizationOwner, + ], + ); +} diff --git a/apps/web/src/hooks/useSettings.environment-target.test.ts b/apps/web/src/hooks/useSettings.environment-target.test.ts new file mode 100644 index 000000000000..584280975333 --- /dev/null +++ b/apps/web/src/hooks/useSettings.environment-target.test.ts @@ -0,0 +1,249 @@ +import { EnvironmentId, type PatchSyncedClientPreferencesRequest } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../test/reactHookHarness"; + +const testState = vi.hoisted(() => ({ + primaryEnvironmentIds: new Array(), + sessionAtom: Symbol("session"), + updateSettingsAtom: Symbol("update-settings"), + patchPreferencesAtom: Symbol("patch-preferences"), + serverSettingsAtom: Symbol("server-settings"), + sessionEnvironmentIds: new Array(), + updateSettings: vi.fn(), + patchPreferences: vi.fn(), + setClientSettings: vi.fn(async () => undefined), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useEffect: (effect: () => void | (() => void)) => effect(), + useMemo: reactHookHarness.useMemo, + useSyncExternalStore: ( + _subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => Snapshot, + ): Snapshot => getSnapshot(), + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: symbol) => + atom === testState.serverSettingsAtom + ? {} + : atom === testState.sessionAtom + ? { + authenticated: true, + scopes: ["orchestration:operate"], + } + : { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ + persistence: { + setClientSettings: testState.setClientSettings, + }, + }), +})); + +vi.mock("~/state/environments", () => ({ + usePrimaryEnvironment: () => ({ environmentId: testState.primaryEnvironmentIds[0] }), +})); + +vi.mock("~/state/server", () => ({ + primaryServerSettingsAtom: Symbol("primary-settings"), + serverEnvironment: { + settingsValueAtom: () => testState.serverSettingsAtom, + updateSettings: testState.updateSettingsAtom, + patchSyncedClientPreferences: testState.patchPreferencesAtom, + }, +})); + +vi.mock("~/state/session", () => ({ + environmentSession: { + sessionStateValueAtom: (environmentId: EnvironmentId) => { + testState.sessionEnvironmentIds.push(environmentId); + return testState.sessionAtom; + }, + }, +})); + +vi.mock("~/state/shell", () => ({ + environmentShell: { + stateValueAtom: () => Symbol("shell"), + }, +})); + +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (atom: symbol) => + atom === testState.patchPreferencesAtom ? testState.patchPreferences : testState.updateSettings, +})); + +import { + __resetClientSettingsPersistenceForTests, + SyncedPlanModeEnvironmentSync, + useEnvironmentSettings, + useUpdateClientSettings, + useUpdateEnvironmentSettings, +} from "./useSettings"; + +function deferred() { + let resolve!: (value: undefined) => void; + const promise = new Promise((resume) => { + resolve = resume; + }); + return { promise, resolve } as const; +} + +describe("useUpdateEnvironmentSettings", () => { + beforeEach(() => { + hooks.reset(); + __resetClientSettingsPersistenceForTests(); + testState.patchPreferences.mockReset(); + testState.patchPreferences.mockImplementation( + async (target: { + readonly environmentId: EnvironmentId; + readonly input: PatchSyncedClientPreferencesRequest; + }) => + AsyncResult.success({ + planModeEnabled: target.input.patch.planModeEnabled, + updatedAtByField: { planModeEnabled: target.input.updatedAt }, + updatedAt: target.input.updatedAt, + }), + ); + testState.setClientSettings.mockClear(); + testState.primaryEnvironmentIds.length = 0; + testState.primaryEnvironmentIds.push(EnvironmentId.make("primary")); + testState.sessionEnvironmentIds.length = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("patches synced preferences in the supplied secondary environment", () => { + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + hooks.beginRender(); + const updateSettings = useUpdateEnvironmentSettings(secondaryEnvironmentId); + + updateSettings({ planModeEnabled: true }); + + expect(testState.patchPreferences).toHaveBeenCalledWith({ + environmentId: secondaryEnvironmentId, + input: { + commandId: expect.stringMatching(/^client-preferences:/u), + patch: { planModeEnabled: true }, + updatedAt: expect.any(String), + }, + }); + }); + + it("persists the Plan Mode value and watermark atomically", async () => { + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + hooks.beginRender(); + const updateSettings = useUpdateEnvironmentSettings(secondaryEnvironmentId); + + updateSettings({ planModeEnabled: true }); + + await vi.waitFor(() => expect(testState.setClientSettings).toHaveBeenCalledOnce()); + expect(testState.setClientSettings).toHaveBeenCalledWith( + expect.objectContaining({ + planModeEnabled: true, + planModeUpdatedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/u), + }), + ); + }); + + it("serializes whole-document client settings persistence", async () => { + const firstWrite = deferred(); + testState.setClientSettings + .mockImplementationOnce(() => firstWrite.promise) + .mockResolvedValueOnce(undefined); + hooks.beginRender(); + const updateSettings = useUpdateClientSettings(); + + updateSettings({ wordWrap: false }); + updateSettings({ wordWrap: true }); + await vi.waitFor(() => expect(testState.setClientSettings).toHaveBeenCalledOnce()); + + firstWrite.resolve(undefined); + await vi.waitFor(() => expect(testState.setClientSettings).toHaveBeenCalledTimes(2)); + expect(testState.setClientSettings).toHaveBeenLastCalledWith( + expect.objectContaining({ wordWrap: true }), + ); + }); + + it("checks synced-preference access in the supplied secondary environment", () => { + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + hooks.beginRender(); + + useEnvironmentSettings(secondaryEnvironmentId); + + expect(testState.sessionEnvironmentIds).toEqual([secondaryEnvironmentId]); + }); + + it("keeps an optimistic plan mode toggle while the shell is stale", () => { + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + testState.patchPreferences.mockImplementation(() => new Promise(() => undefined)); + hooks.beginRender(); + const updateSettings = useUpdateEnvironmentSettings(secondaryEnvironmentId); + + updateSettings({ planModeEnabled: true }); + hooks.reset(); + hooks.beginRender(); + + expect( + useEnvironmentSettings(secondaryEnvironmentId, (settings) => settings.planModeEnabled), + ).toBe(true); + }); + + it("retries a failed secondary preference patch and reconciles the UI", async () => { + vi.useFakeTimers(); + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + testState.patchPreferences + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail("offline"))) + .mockImplementationOnce(async (target) => + AsyncResult.success({ + planModeEnabled: false, + updatedAtByField: { planModeEnabled: target.input.updatedAt }, + updatedAt: target.input.updatedAt, + }), + ); + hooks.beginRender(); + SyncedPlanModeEnvironmentSync({ environmentId: secondaryEnvironmentId }); + hooks.beginRender(); + useEnvironmentSettings(secondaryEnvironmentId); + hooks.beginRender(); + const updateSettings = useUpdateEnvironmentSettings(secondaryEnvironmentId); + + updateSettings({ planModeEnabled: true }); + await Promise.resolve(); + hooks.beginRender(); + expect( + useEnvironmentSettings(secondaryEnvironmentId, (settings) => settings.planModeEnabled), + ).toBe(true); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(testState.patchPreferences).toHaveBeenCalledTimes(2); + hooks.beginRender(); + expect( + useEnvironmentSettings(secondaryEnvironmentId, (settings) => settings.planModeEnabled), + ).toBe(false); + }); +}); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index b332fe13c2f1..3131629f5f9b 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -1,13 +1,1198 @@ 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 * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { + createSyncedClientPreferencesSliceAtom, + createSyncedPlanModeHydrationController, + createSyncedPlanModeWrite, + resolveSyncedPlanModeCoordinatorEnvironmentIds, + resolveSyncedPlanModeHydrationAction, + type SyncedPlanModeHydrationInput, +} from "./synced-plan-mode"; import { mergeEnvironmentSettings, resolveEnvironmentIdentificationMode } from "./useSettings"; +describe("synced plan mode", () => { + it("hydrates the primary before exposing secondary environments", () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + const environmentIds = [primaryEnvironmentId, secondaryEnvironmentId]; + + expect( + resolveSyncedPlanModeCoordinatorEnvironmentIds({ + environmentIds, + primaryEnvironmentId, + hydratedPrimaryEnvironmentId: null, + primaryUnavailable: false, + }), + ).toEqual([primaryEnvironmentId]); + expect( + resolveSyncedPlanModeCoordinatorEnvironmentIds({ + environmentIds, + primaryEnvironmentId, + hydratedPrimaryEnvironmentId: primaryEnvironmentId, + primaryUnavailable: false, + }), + ).toEqual(environmentIds); + + const events: string[] = []; + createSyncedPlanModeHydrationController().synchronize({ + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: false, + live: true, + serverPreferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch: vi.fn(), + persist: (value, updatedAt) => { + events.push(`persist:${value}:${updatedAt}`); + }, + onHydrated: () => events.push("hydrated"), + }); + + expect(events).toEqual(["persist:true:2026-08-14T12:00:00.000Z", "hydrated"]); + }); + + it("waits through transient primary shell states", () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const onHydrated = vi.fn(); + + createSyncedPlanModeHydrationController().synchronize({ + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: false, + live: false, + serverPreferences: undefined, + canPatch: false, + now: "2026-08-14T12:01:00.000Z", + patch: vi.fn(), + persist: vi.fn(), + onHydrated, + }); + + expect(onHydrated).not.toHaveBeenCalled(); + }); + + it("releases secondary environments when the primary connection is unavailable", () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + + expect( + resolveSyncedPlanModeCoordinatorEnvironmentIds({ + environmentIds: [primaryEnvironmentId, EnvironmentId.make("secondary")], + primaryEnvironmentId, + hydratedPrimaryEnvironmentId: null, + primaryUnavailable: true, + }), + ).toHaveLength(2); + }); + + it("ignores a malformed durable Plan Mode watermark", () => { + const serverPreferences = { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + expect( + createSyncedPlanModeWrite({ + value: true, + serverPreferences, + pendingUpdatedAt: "not-a-timestamp", + now: "2026-08-14T12:01:00.000Z", + }).request.updatedAt, + ).toBe("2026-08-14T12:01:00.000Z"); + + const persist = vi.fn(); + createSyncedPlanModeHydrationController().synchronize({ + environmentId: EnvironmentId.make("primary"), + primaryEnvironmentId: EnvironmentId.make("primary"), + clientHydrated: true, + clientValue: true, + clientUpdatedAt: "not-a-timestamp", + live: true, + serverPreferences, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch: vi.fn(), + persist, + }); + + expect(persist).toHaveBeenCalledWith(false, "2026-08-14T12:00:00.000Z"); + }); + + it("adopts an environment value over the local cache", () => { + expect( + resolveSyncedPlanModeHydrationAction({ + clientHydrated: true, + clientValue: false, + serverPreferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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: undefined, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + now: "2026-08-14T12:01:00.000Z", + }), + ).toEqual({ + request: { + commandId: "client-preferences:2026-08-14T12:01:00.000Z:0", + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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("reconciles a divergent secondary environment to the primary preference", 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:02:00.000Z" }, + 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).toHaveBeenCalledTimes(1); + expect(patch).toHaveBeenCalledWith({ + environmentId: secondaryEnvironmentId, + input: { + commandId: "client-preferences:2026-08-14T12:02:00.001Z:1", + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:02:00.001Z", + }, + }); + }); + + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:30.000Z" }, + updatedAt: "2026-08-14T12:00:30.000Z", + } as const; + const previous = { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const patch = vi.fn(async (target) => + AsyncResult.success({ + planModeEnabled: target.input.patch.planModeEnabled, + updatedAtByField: { planModeEnabled: target.input.updatedAt }, + 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: { + commandId: "client-preferences:2026-08-14T12:01:00.000Z:1", + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }); + }); + + it("restores a durable offline write after the controller restarts", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const previous = { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + let persistedUpdatedAt: string | undefined; + createSyncedPlanModeHydrationController().write({ + environmentId: primaryEnvironmentId, + value: true, + serverPreferences: previous, + canPatch: false, + now: "2026-08-14T12:01:00.000Z", + patch: vi.fn(), + persist: (_value, updatedAt) => { + persistedUpdatedAt = updatedAt; + }, + }); + + const patch = vi.fn(async (target) => + AsyncResult.success({ + planModeEnabled: target.input.patch.planModeEnabled, + updatedAtByField: { planModeEnabled: target.input.updatedAt }, + updatedAt: target.input.updatedAt, + }), + ); + createSyncedPlanModeHydrationController().synchronize({ + environmentId: primaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: true, + clientUpdatedAt: persistedUpdatedAt, + 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: { + commandId: "client-preferences:2026-08-14T12:01:00.000Z:1", + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }); + }); + + it("seeds a missing secondary environment from the global preference", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const secondaryEnvironmentId = EnvironmentId.make("secondary"); + const patch = vi.fn(async (target) => + AsyncResult.success({ + planModeEnabled: target.input.patch.planModeEnabled, + updatedAtByField: { planModeEnabled: target.input.updatedAt }, + updatedAt: target.input.updatedAt, + }), + ); + + createSyncedPlanModeHydrationController().synchronize({ + environmentId: secondaryEnvironmentId, + primaryEnvironmentId, + clientHydrated: true, + clientValue: true, + live: true, + serverPreferences: undefined, + canPatch: true, + now: "2026-08-14T12:01:00.000Z", + patch, + persist: vi.fn(), + }); + await Promise.resolve(); + + expect(patch).toHaveBeenCalledWith({ + environmentId: secondaryEnvironmentId, + input: { + commandId: "client-preferences:2026-08-14T12:01:00.000Z:1", + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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, + updatedAtByField: { planModeEnabled: target.input.updatedAt }, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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 after reconnecting", async () => { + const primaryEnvironmentId = EnvironmentId.make("primary"); + const scheduledRetries: Array<() => void> = []; + const controller = createSyncedPlanModeHydrationController((retry) => { + scheduledRetries.push(retry); + return vi.fn(); + }); + const previous = { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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.synchronize({ + ...hydrationInput, + live: false, + now: "2026-08-14T12:03:00.000Z", + }); + controller.synchronize({ + ...hydrationInput, + now: "2026-08-14T12:04:00.000Z", + }); + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:02:00.000Z" }, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:01:00.000Z" }, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }, + owner, + ); + scheduledRetries[0]?.(); + await Promise.resolve(); + + expect(patch).toHaveBeenCalledOnce(); + expect(persist).toHaveBeenCalledExactlyOnceWith(false, "2026-08-14T12:01:00.000Z"); + }); + + it.each([ + { switchPrimary: true, expectedPersisted: [false] }, + { 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:01:00.000Z" }, + updatedAt: "2026-08-14T12:01:00.000Z", + }, + }, + owner, + ); + } + + resolvePatch( + AsyncResult.success({ + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + } as const; + const stale = { + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T12:01:00.000Z" }, + 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, + }); + persisted.length = 0; + deactivate?.(); + resolvePatch( + AsyncResult.success({ + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:02:00.000Z" }, + 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([true]); + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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", + }); + persisted.length = 0; + deactivate?.(); + resolvePatch( + AsyncResult.success({ + planModeEnabled: false, + updatedAtByField: { planModeEnabled: "2026-08-14T12:02:00.000Z" }, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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, + }); + expect(persisted).toEqual([false, true]); + controller.write({ + environmentId: primaryEnvironmentId, + value: false, + serverPreferences: previous, + canPatch: true, + now: previous.updatedAt, + patch, + persist, + }); + expect(persisted).toEqual([false, true, false]); + persisted.length = 0; + + 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, + updatedAtByField: { planModeEnabled: targets[1]!.input.updatedAt }, + updatedAt: targets[1]!.input.updatedAt, + }), + ); + await Promise.resolve(); + resolvePatches[0]?.( + AsyncResult.success({ + planModeEnabled: true, + updatedAtByField: { planModeEnabled: targets[0]!.input.updatedAt }, + 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, + }); + expect(persisted).toEqual([true]); + persisted.length = 0; + + resolvePatches[0]?.( + AsyncResult.success({ + planModeEnabled: false, + updatedAtByField: { planModeEnabled: targets[0]!.input.updatedAt }, + updatedAt: targets[0]!.input.updatedAt, + }), + ); + await Promise.resolve(); + expect(persisted).toEqual([]); + + resolvePatches[1]?.( + AsyncResult.success({ + planModeEnabled: true, + updatedAtByField: { planModeEnabled: targets[1]!.input.updatedAt }, + 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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( @@ -76,4 +1261,33 @@ 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, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + ); + + expect(settings.planModeEnabled).toBe(true); + }); + + it("keeps a newer local Plan Mode choice visible in a read-only session", () => { + const settings = mergeEnvironmentSettings( + DEFAULT_SERVER_SETTINGS, + { ...DEFAULT_CLIENT_SETTINGS, planModeEnabled: false }, + { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + 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 5e633a5ded59..2aba70e26eb5 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -12,10 +12,13 @@ import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useAtomValue } from "@effect/atom-react"; import { + AuthOrchestrationOperateScope, DEFAULT_SERVER_SETTINGS, + getSyncedClientPreferenceUpdatedAt, type EnvironmentId, ServerSettings, type ServerSettingsPatch, + type SyncedClientPreferences, } from "@t3tools/contracts"; import { type ClientSettingsPatch, @@ -34,9 +37,18 @@ import { themeAllowsSidebarArtwork, } from "~/themePalette"; import * as Struct from "effect/Struct"; +import { Atom } from "effect/unstable/reactivity"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { usePrimaryEnvironment } from "~/state/environments"; +import { environmentSession } from "~/state/session"; +import { environmentShell } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; +import { + SHELL_NOT_LIVE, + createSyncedClientPreferencesSliceAtom, + createSyncedPlanModeHydrationController, + useSyncedPlanModeHydrationEffect, +} from "./synced-plan-mode"; import { useTheme } from "./useTheme"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -49,6 +61,15 @@ let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; let clientSettingsHydrated = false; let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; +let clientSettingsPersistenceQueue = Promise.resolve(); +const EMPTY_SYNCED_CLIENT_PREFERENCES_ATOM = Atom.make(SHELL_NOT_LIVE); +const EMPTY_AUTH_SESSION_ATOM = Atom.make(null); + +const syncedClientPreferencesSliceAtom = Atom.family((environmentId: EnvironmentId) => + createSyncedClientPreferencesSliceAtom(environmentShell.stateValueAtom(environmentId)).pipe( + Atom.withLabel(`web:synced-client-preferences:${environmentId}`), + ), +); function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { @@ -141,8 +162,8 @@ async function hydrateClientSettings(): Promise { function persistClientSettings(settings: ClientSettings): void { replaceClientSettingsSnapshot(settings); - void ensureLocalApi() - .persistence.setClientSettings(settings) + clientSettingsPersistenceQueue = clientSettingsPersistenceQueue + .then(() => ensureLocalApi().persistence.setClientSettings(settings)) .catch((error) => { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { operation: "persist", @@ -151,6 +172,16 @@ function persistClientSettings(settings: ClientSettings): void { }); } +function persistSyncedPlanMode(value: boolean, updatedAt: string): void { + const current = getClientSettingsSnapshot(); + if (current.planModeEnabled === value && current.planModeUpdatedAt === updatedAt) return; + persistClientSettings({ + ...current, + planModeEnabled: value, + planModeUpdatedAt: updatedAt, + }); +} + // ── Key sets for routing patches ───────────────────────────────────── const SERVER_SETTINGS_KEYS = new Set(Struct.keys(ServerSettings.fields)); @@ -215,24 +246,143 @@ function useClientSettingsValue(): ClientSettings { export function mergeEnvironmentSettings( serverSettings: ServerSettings, clientSettings: ClientSettings, + syncedClientPreferences?: SyncedClientPreferences, + syncedPlanModeCanOverrideClient = true, + pendingPlanModeWrite?: { readonly value: boolean; readonly updatedAt: string }, ): UnifiedSettings { - return { ...serverSettings, ...clientSettings }; + const syncedPlanModeUpdatedAt = getSyncedClientPreferenceUpdatedAt( + syncedClientPreferences, + "planModeEnabled", + ); + const pendingPlanModeIsNewer = + pendingPlanModeWrite !== undefined && + (syncedPlanModeUpdatedAt === undefined || + pendingPlanModeWrite.updatedAt > syncedPlanModeUpdatedAt); + let merged: UnifiedSettings = { + ...serverSettings, + ...clientSettings, + }; + if (syncedPlanModeCanOverrideClient) { + if (pendingPlanModeIsNewer) { + merged = { ...merged, planModeEnabled: pendingPlanModeWrite.value }; + } else if (syncedClientPreferences?.planModeEnabled !== undefined) { + merged = { ...merged, planModeEnabled: syncedClientPreferences.planModeEnabled }; + } + } + return merged; } function useMergedSettings( serverSettings: ServerSettings, + syncedClientPreferences: SyncedClientPreferences | undefined, + syncedPlanModeCanOverrideClient: boolean, + pendingPlanModeWrite: { readonly value: boolean; readonly updatedAt: string } | undefined, selector: ((settings: UnifiedSettings) => T) | undefined, ): T { const clientSettings = useClientSettingsValue(); const merged = useMemo( - () => mergeEnvironmentSettings(serverSettings, clientSettings), - [clientSettings, serverSettings], + () => + mergeEnvironmentSettings( + serverSettings, + clientSettings, + syncedClientPreferences, + syncedPlanModeCanOverrideClient, + pendingPlanModeWrite, + ), + [ + clientSettings, + pendingPlanModeWrite, + serverSettings, + syncedClientPreferences, + syncedPlanModeCanOverrideClient, + ], ); return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); } +const syncedPlanModeHydrationController = createSyncedPlanModeHydrationController(); + +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, + onHydrated: (() => void) | undefined, +) { + const clientSettings = useClientSettingsValue(); + const clientHydrated = useClientSettingsHydrated(); + const primaryEnvironmentId = usePrimaryEnvironment()?.environmentId ?? null; + const synced = useEnvironmentSyncedClientPreferences(environmentId); + const canPatch = useCanPatchSyncedClientPreferences(environmentId); + const patchPreferences = useAtomCommand(serverEnvironment.patchSyncedClientPreferences, { + label: "synced client preferences seed", + reportFailure: false, + }); + const persistPlanMode = useCallback(persistSyncedPlanMode, []); + + useSyncedPlanModeHydrationEffect(syncedPlanModeHydrationController, { + environmentId, + primaryEnvironmentId, + clientHydrated, + clientValue: clientSettings.planModeEnabled, + clientUpdatedAt: clientSettings.planModeUpdatedAt, + live: synced.live, + serverPreferences: synced.preferences, + canPatch, + now: new Date().toISOString(), + patch: patchPreferences, + persist: persistPlanMode, + onHydrated, + }); + + return { + ...synced, + canPatch, + pendingWrite: syncedPlanModeHydrationController.getPendingWrite(environmentId), + } as const; +} + +function useSyncedPlanModeState(environmentId: EnvironmentId | null) { + const synced = useEnvironmentSyncedClientPreferences(environmentId); + const canPatch = useCanPatchSyncedClientPreferences(environmentId); + return { + ...synced, + canPatch, + pendingWrite: syncedPlanModeHydrationController.getPendingWrite(environmentId), + } as const; +} + +export function SyncedPlanModeEnvironmentSync(props: { + readonly environmentId: EnvironmentId; + readonly onHydrated?: (() => void) | undefined; +}): null { + useSyncedPlanModeHydration(props.environmentId, props.onHydrated); + return null; +} + export function useClientSettings( selector?: (settings: ClientSettings) => T, ): T { @@ -295,14 +445,29 @@ export function useEnvironmentSettings( selector?: (settings: UnifiedSettings) => T, ): T { const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); - return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); + const synced = useSyncedPlanModeState(environmentId); + return useMergedSettings( + serverSettings ?? DEFAULT_SERVER_SETTINGS, + synced.preferences, + synced.canPatch, + synced.pendingWrite, + selector, + ); } /** Primary-only settings access for the settings UI and other explicitly global surfaces. */ export function usePrimarySettings( selector?: (settings: UnifiedSettings) => T, ): T { - return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + const synced = useSyncedPlanModeState(environmentId); + return useMergedSettings( + useAtomValue(primaryServerSettingsAtom), + synced.preferences, + synced.canPatch, + synced.pendingWrite, + selector, + ); } /** @@ -316,6 +481,15 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { serverEnvironment.updateSettings, "server settings update", ); + const synced = useEnvironmentSyncedClientPreferences(environmentId); + const canPatchSyncedPreferences = useCanPatchSyncedClientPreferences(environmentId); + const patchSyncedClientPreferences = useAtomCommand( + serverEnvironment.patchSyncedClientPreferences, + { + label: "synced client preferences update", + reportFailure: false, + }, + ); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); @@ -328,14 +502,32 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { }); } } - if (Object.keys(clientPatch).length > 0) { + const clientPatchWithoutPlanMode = Struct.omit(clientPatch, ["planModeEnabled"]); + if (Struct.keys(clientPatchWithoutPlanMode).length > 0) { persistClientSettings({ ...getClientSettingsSnapshot(), - ...clientPatch, + ...clientPatchWithoutPlanMode, + }); + } + if (clientPatch.planModeEnabled !== undefined) { + syncedPlanModeHydrationController.write({ + environmentId, + value: clientPatch.planModeEnabled, + serverPreferences: synced.preferences, + canPatch: canPatchSyncedPreferences, + now: new Date().toISOString(), + patch: patchSyncedClientPreferences, + persist: persistSyncedPlanMode, }); } }, - [environmentId, persistServerSettings], + [ + canPatchSyncedPreferences, + environmentId, + patchSyncedClientPreferences, + persistServerSettings, + synced.preferences, + ], ); return updateSettings; @@ -363,8 +555,10 @@ export function __resetClientSettingsPersistenceForTests(): void { clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; clientSettingsHydrated = false; clientSettingsHydrationPromise = null; + clientSettingsPersistenceQueue = Promise.resolve(); clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); + syncedPlanModeHydrationController.reset(); } export function __setClientSettingsForTests(settings: ClientSettings): void { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 91381301418e..5a5b1c1fe135 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; +import { type EnvironmentId, type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { @@ -8,7 +8,7 @@ import { useLocation, useNavigate, } from "@tanstack/react-router"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; @@ -30,7 +30,8 @@ import { } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; -import { useClientSettings } from "../hooks/useSettings"; +import { SyncedPlanModeEnvironmentSync, useClientSettings } from "../hooks/useSettings"; +import { resolveSyncedPlanModeCoordinatorEnvironmentIds } from "../hooks/synced-plan-mode"; import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal"; import { deriveLogicalProjectKeyFromSettings, @@ -140,6 +141,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} @@ -152,6 +154,38 @@ function RootRouteView() { ); } +function SyncedPlanModeCoordinator() { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironment()?.environmentId ?? null; + const primaryEnvironment = environments.find( + ({ environmentId }) => environmentId === primaryEnvironmentId, + ); + const primaryUnavailable = + primaryEnvironment === undefined || + primaryEnvironment.connection.phase === "available" || + primaryEnvironment.connection.phase === "offline" || + primaryEnvironment.connection.phase === "error"; + const [hydratedPrimaryEnvironmentId, setHydratedPrimaryEnvironmentId] = + useState(null); + const markPrimaryHydrated = useCallback(() => { + setHydratedPrimaryEnvironmentId(primaryEnvironmentId); + }, [primaryEnvironmentId]); + const environmentIds = resolveSyncedPlanModeCoordinatorEnvironmentIds({ + environmentIds: environments.map(({ environmentId }) => environmentId), + primaryEnvironmentId, + hydratedPrimaryEnvironmentId, + primaryUnavailable, + }); + + return environmentIds.map((environmentId) => ( + + )); +} + function GlassAppearanceSync() { const glassOpacity = useClientSettings((settings) => settings.glassOpacity); diff --git a/docs/README.md b/docs/README.md index f1698a66e179..cfe41fdeb221 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) diff --git a/docs/user/plan-mode.md b/docs/user/plan-mode.md new file mode 100644 index 000000000000..9ef2a0faaadc --- /dev/null +++ b/docs/user/plan-mode.md @@ -0,0 +1,18 @@ +# Plan Mode + +Enable **Plan Mode (Legacy)** in **Settings → General → Legacy features** on web and desktop, or +in the **Legacy** section of Settings on mobile. The preference follows the connected environment, +so web, desktop, and mobile clients using that environment converge on the same setting. + +Changes made while a client is offline remain available on that device. T3 Code reconciles the +newest saved choice when the environment reconnects. If several environments are connected on +mobile, the most recently updated choice is applied across them. + +When enabled, the composer can send turns in either **Build** or **Plan** mode. Build is the normal +mode: the agent can inspect and change the project. Plan asks the agent to develop a plan before +implementation. Use `/plan` to switch the current composer to Plan and `/default` to switch back to +Build. Submitting either command by itself changes the mode without sending a message. + +When Plan Mode (Legacy) is disabled, those two slash commands are hidden and every new or queued +turn is sent in Build mode. This also applies to drafts created while the setting was still loading, +so an old saved Plan selection cannot bypass the current setting. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f75be5bc44bb..0b589e4cff5f 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -39,6 +39,10 @@ "types": "./src/providerSkills.ts", "default": "./src/providerSkills.ts" }, + "./synced-client-preferences": { + "types": "./src/syncedClientPreferences.ts", + "default": "./src/syncedClientPreferences.ts" + }, "./relay": { "types": "./src/relay/index.ts", "default": "./src/relay/index.ts" diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..f4da4a3e0e4c 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -756,6 +756,10 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), + patchSyncedClientPreferences: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:patch-synced-client-preferences", + tag: WS_METHODS.syncedClientPreferencesPatch, + }), signalProcess: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 40e9bd80dc5b..d0fae94e7f85 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -169,12 +169,14 @@ describe("environment shell synchronization", () => { const subscribeInputs = yield* Queue.unbounded<{ readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; + readonly clientPreferencesStreamItem?: boolean; }>(); const loaderCalls = yield* Ref.make(0); const client = { [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; + readonly clientPreferencesStreamItem?: boolean; }) => Stream.unwrap( Queue.offer(subscribeInputs, input).pipe(Effect.as(Stream.fromQueue(events))), @@ -223,6 +225,7 @@ describe("environment shell synchronization", () => { const subscribeInput = yield* Queue.take(subscribeInputs); expect(subscribeInput.afterSequence).toBeUndefined(); expect(subscribeInput.requestCompletionMarker).toBe(true); + expect(subscribeInput.clientPreferencesStreamItem).toBe(true); expect(yield* Ref.get(loaderCalls)).toBe(1); const synchronizing = yield* SubscriptionRef.get(shellState); expect(synchronizing.status).toBe("synchronizing"); @@ -243,6 +246,7 @@ describe("environment shell synchronization", () => { const resumedInput = yield* Queue.take(subscribeInputs); expect(resumedInput.afterSequence).toBe(resetSnapshot.snapshotSequence); expect(resumedInput.requestCompletionMarker).toBe(true); + expect(resumedInput.clientPreferencesStreamItem).toBe(true); expect(yield* Ref.get(loaderCalls)).toBe(1); }), ); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index c150bbb75b8c..c1d48f635711 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -229,7 +229,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") // If the authoritative refresh failed, omit the cached cursor so the // socket fallback sends a complete snapshot for this new session. if (!canResume || Option.isNone(current.snapshot)) { - return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + const input = { clientPreferencesStreamItem: true as const }; + return supportsCompletionMarker + ? { ...input, requestCompletionMarker: true as const } + : input; } if (!supportsCompletionMarker) { // Without a completion marker there is no synchronized signal for a @@ -242,6 +245,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } return { afterSequence: current.snapshot.value.snapshotSequence, + clientPreferencesStreamItem: true as const, ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), }; }), diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index fdccc4c47dd8..5d74c119691c 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -177,6 +177,33 @@ describe("applyShellStreamEvent", () => { }); }); + it("applies synced client preference updates", () => { + const snapshot = { + ...baseSnapshot, + projects: [stubProject], + threads: [stubThread], + }; + const next = applyShellStreamEvent(snapshot, { + kind: "client-preferences-updated", + sequence: 7, + preferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + }); + + expect(next.syncedClientPreferences).toEqual({ + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }); + expect(next.snapshotSequence).toBe(7); + expect(next.projects).toBe(snapshot.projects); + expect(next.threads).toBe(snapshot.threads); + expect(next.updatedAt).toBe(snapshot.updatedAt); + }); + it("returns original snapshot for unrecognized event kinds", () => { const unknownEvent = { kind: "unknown-future-event", sequence: 99 } as any; const next = applyShellStreamEvent(baseSnapshot, unknownEvent); diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index 3d3b22a1289f..3b41aaf00609 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -40,6 +40,12 @@ export function applyShellStreamEvent( threads: Arr.filter(snapshot.threads, (t) => t.id !== event.threadId), snapshotSequence: event.sequence, }; + case "client-preferences-updated": + return { + ...snapshot, + syncedClientPreferences: event.preferences, + snapshotSequence: event.sequence, + }; default: return snapshot; } diff --git a/packages/client-runtime/src/syncedClientPreferences.test.ts b/packages/client-runtime/src/syncedClientPreferences.test.ts new file mode 100644 index 000000000000..ee1a273a38fc --- /dev/null +++ b/packages/client-runtime/src/syncedClientPreferences.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + createPlanModePreferencePatchRequest, + syncedClientPreferenceRetryDelayMs, +} from "./syncedClientPreferences.js"; + +describe("synced client preferences", () => { + it("keys idempotency by the field stamp and canonical payload", () => { + const first = createPlanModePreferencePatchRequest(true, "2026-08-14T12:00:00.000Z"); + const retry = createPlanModePreferencePatchRequest(true, "2026-08-14T12:00:00.000Z"); + const distinctValue = createPlanModePreferencePatchRequest(false, "2026-08-14T12:00:00.000Z"); + + expect(first).toEqual({ + commandId: "client-preferences:2026-08-14T12:00:00.000Z:1", + patch: { planModeEnabled: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }); + expect(retry.commandId).toBe(first.commandId); + expect(distinctValue.commandId).toBe("client-preferences:2026-08-14T12:00:00.000Z:0"); + }); + + it("uses the shared exponential retry policy", () => { + expect([1, 2, 3].map(syncedClientPreferenceRetryDelayMs)).toEqual([1_000, 2_000, 4_000]); + }); +}); diff --git a/packages/client-runtime/src/syncedClientPreferences.ts b/packages/client-runtime/src/syncedClientPreferences.ts new file mode 100644 index 000000000000..9d47e2280d37 --- /dev/null +++ b/packages/client-runtime/src/syncedClientPreferences.ts @@ -0,0 +1,22 @@ +import { + CommandId, + type PatchSyncedClientPreferencesRequest, + type SyncedClientPreferencesUpdatedAt, +} from "@t3tools/contracts"; + +export const SYNCED_CLIENT_PREFERENCE_MAX_ATTEMPTS = 3; + +export function syncedClientPreferenceRetryDelayMs(attempt: number): number { + return 1_000 * 2 ** (attempt - 1); +} + +export function createPlanModePreferencePatchRequest( + value: boolean, + updatedAt: SyncedClientPreferencesUpdatedAt, +): PatchSyncedClientPreferencesRequest { + return { + commandId: CommandId.make(`client-preferences:${updatedAt}:${value ? "1" : "0"}`), + patch: { planModeEnabled: value }, + updatedAt, + }; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..a6bc920e4d0f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -15,6 +15,7 @@ export * from "./model.ts"; export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; +export * from "./syncedClientPreferences.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index f403e6de26cc..c7e0c01a7e7f 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -54,6 +54,13 @@ const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPaylo const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); +const LegacyShellSnapshot = Schema.Struct({ + snapshotSequence: Schema.Number, + projects: Schema.Array(Schema.Unknown), + threads: Schema.Array(Schema.Unknown), + updatedAt: Schema.String, +}); +const decodeLegacyShellSnapshot = Schema.decodeUnknownEffect(LegacyShellSnapshot); it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => Effect.gen(function* () { @@ -942,3 +949,41 @@ it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false); }); + +it.effect("rejects unknown-only synced preference patches through generic dispatch", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "client-preferences.patch", + commandId: "preferences-empty-patch", + patch: { unsupported: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), +); + +it.effect("keeps new shell snapshots decodable by legacy clients", () => + Effect.gen(function* () { + const decoded = yield* decodeLegacyShellSnapshot({ + snapshotSequence: 1, + projects: [], + threads: [], + syncedClientPreferences: { + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T12:00:00.000Z" }, + updatedAt: "2026-08-14T12:00:00.000Z", + }, + updatedAt: "2026-08-14T12:00:00.000Z", + }); + + assert.deepStrictEqual(decoded, { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-08-14T12:00:00.000Z", + }); + }), +); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cd9f3a747876..868106f7f482 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -22,6 +22,11 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { + SyncedClientPreferences, + SyncedClientPreferencesPatch, + SyncedClientPreferencesUpdatedAt, +} from "./syncedClientPreferences.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -426,6 +431,7 @@ export const OrchestrationReadModel = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProject), threads: Schema.Array(OrchestrationThread), + syncedClientPreferences: Schema.optional(SyncedClientPreferences), updatedAt: IsoDateTime, }); export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; @@ -501,6 +507,8 @@ export const OrchestrationShellSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProjectShell), threads: Schema.Array(OrchestrationThreadShell), + // Optional so cached snapshots and older servers remain wire-compatible. + syncedClientPreferences: Schema.optional(SyncedClientPreferences), updatedAt: IsoDateTime, }); export type OrchestrationShellSnapshot = typeof OrchestrationShellSnapshot.Type; @@ -526,6 +534,11 @@ export const OrchestrationShellStreamEvent = Schema.Union([ sequence: NonNegativeInt, threadId: ThreadId, }), + Schema.Struct({ + kind: Schema.Literal("client-preferences-updated"), + sequence: NonNegativeInt, + preferences: SyncedClientPreferences, + }), ]); export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type; @@ -556,6 +569,11 @@ export const OrchestrationSubscribeShellInput = Schema.Struct({ * snapshot or catch-up replay and before it begins emitting live events. */ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * Opts into `client-preferences-updated` stream items. Older clients use a + * closed output union and must not receive that newer variant. + */ + clientPreferencesStreamItem: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShellInput.Type; @@ -633,6 +651,16 @@ export const OrchestrationThreadDetailSnapshot = Schema.Struct({ }); export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; +export const SyncedClientPreferencesAggregateId = Schema.Literal("client-preferences"); +export type SyncedClientPreferencesAggregateId = typeof SyncedClientPreferencesAggregateId.Type; + +const ClientPreferencesPatchCommand = Schema.Struct({ + type: Schema.Literal("client-preferences.patch"), + commandId: CommandId, + patch: SyncedClientPreferencesPatch, + updatedAt: SyncedClientPreferencesUpdatedAt, +}); + export const ProjectCreateCommand = Schema.Struct({ type: Schema.Literal("project.create"), commandId: CommandId, @@ -910,6 +938,7 @@ const ThreadSessionStopCommand = Schema.Struct({ }); const DispatchableClientOrchestrationCommand = Schema.Union([ + ClientPreferencesPatchCommand, ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, @@ -938,6 +967,7 @@ export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; export const ClientOrchestrationCommand = Schema.Union([ + ClientPreferencesPatchCommand, ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, @@ -1056,6 +1086,7 @@ export const OrchestrationCommand = Schema.Union([ export type OrchestrationCommand = typeof OrchestrationCommand.Type; export const OrchestrationEventType = Schema.Literals([ + "client-preferences.patched", "project.created", "project.meta-updated", "project.deleted", @@ -1088,10 +1119,19 @@ export const OrchestrationEventType = Schema.Literals([ ]); export type OrchestrationEventType = typeof OrchestrationEventType.Type; -export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]); +export const OrchestrationAggregateKind = Schema.Literals([ + "client-preferences", + "project", + "thread", +]); export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type; export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]); +export const ClientPreferencesPatchedPayload = Schema.Struct({ + patch: SyncedClientPreferencesPatch, + updatedAt: SyncedClientPreferencesUpdatedAt, +}); + export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, @@ -1332,7 +1372,7 @@ const EventBaseFields = { sequence: NonNegativeInt, eventId: EventId, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([SyncedClientPreferencesAggregateId, ProjectId, ThreadId]), occurredAt: IsoDateTime, commandId: Schema.NullOr(CommandId), causationEventId: Schema.NullOr(EventId), @@ -1341,6 +1381,11 @@ const EventBaseFields = { } as const; export const OrchestrationEvent = Schema.Union([ + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("client-preferences.patched"), + payload: ClientPreferencesPatchedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("project.created"), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..a483ef633505 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -181,6 +181,10 @@ import { } from "./resourceTelemetry.ts"; import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { + PatchSyncedClientPreferencesRequest, + SyncedClientPreferences, +} from "./syncedClientPreferences.ts"; import { SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -262,6 +266,7 @@ export const WS_METHODS = { serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", + syncedClientPreferencesPatch: "syncedClientPreferences.patch", serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", @@ -387,6 +392,16 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting error: Schema.Union([ServerSettingsError, 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, @@ -993,6 +1008,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsPatchSyncedClientPreferencesRpc, WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 96ee5b85c05a..1f8065e94747 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -214,6 +214,9 @@ export const ClientSettingsSchema = Schema.Struct({ // default UI; this beta flag restores it (plus the /plan and /default slash // commands) for users who still rely on the old workflow. planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Internal reconciliation watermark for an offline Plan Mode write. Keeping + // it beside the value makes the write durable across browser/app restarts. + planModeUpdatedAt: Schema.optionalKey(Schema.String), // Legacy sidebar (the original per-project tree). Deliberately a fresh key // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the // old keys, so everyone, including prior beta opt-outs, resets to the new @@ -902,6 +905,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), ), planModeEnabled: Schema.optionalKey(Schema.Boolean), + planModeUpdatedAt: Schema.optionalKey(Schema.String), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/syncedClientPreferences.test.ts b/packages/contracts/src/syncedClientPreferences.test.ts new file mode 100644 index 000000000000..a91a6754c1e3 --- /dev/null +++ b/packages/contracts/src/syncedClientPreferences.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { + PatchSyncedClientPreferencesRequest, + SyncedClientPreferences, + SyncedClientPreferencesPatch, +} from "./syncedClientPreferences.ts"; + +const decodePreferences = Schema.decodeUnknownSync(SyncedClientPreferences); +const decodePatch = Schema.decodeUnknownSync(SyncedClientPreferencesPatch); +const decodeRequest = Schema.decodeUnknownSync(PatchSyncedClientPreferencesRequest); + +describe("SyncedClientPreferences", () => { + it("keeps the contract scoped to the Plan Mode rollout", () => { + const preferences = decodePreferences({ + planModeEnabled: true, + updatedAtByField: { planModeEnabled: "2026-08-14T10:00:00.000Z" }, + updatedAt: "2026-08-14T10:00:00.000Z", + themeId: "ignored", + }); + const patch = decodePatch({ planModeEnabled: false, themeId: "ignored" }); + + expect(Object.keys(preferences).sort()).toEqual([ + "planModeEnabled", + "updatedAt", + "updatedAtByField", + ]); + expect(patch).toEqual({ planModeEnabled: false }); + }); + + it("rejects non-canonical LWW stamps", () => { + for (const updatedAt of ["not-a-date", "2026-02-30T00:00:00.000Z"]) { + expect(() => + decodePreferences({ planModeEnabled: true, updatedAtByField: {}, updatedAt }), + ).toThrow(); + } + }); + + it("requires projected preferences to carry per-field clocks", () => { + expect(() => + decodePreferences({ + planModeEnabled: true, + updatedAt: "2026-08-14T10:00:00.000Z", + }), + ).toThrow(); + }); + + it("rejects empty and unknown-only patches at the RPC boundary", () => { + expect(() => decodePatch({})).toThrow(); + expect(() => + decodeRequest({ + commandId: "client-preferences:test", + patch: { unsupported: true }, + updatedAt: "2026-08-14T12:00:00.000Z", + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/syncedClientPreferences.ts b/packages/contracts/src/syncedClientPreferences.ts new file mode 100644 index 000000000000..11b35fbe221c --- /dev/null +++ b/packages/contracts/src/syncedClientPreferences.ts @@ -0,0 +1,72 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { CommandId, IsoDateTime } from "./baseSchemas.ts"; + +const SyncedClientPreferencesUpdatedAtPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +export const SyncedClientPreferencesUpdatedAt = IsoDateTime.check( + Schema.makeFilter( + (value) => + (SyncedClientPreferencesUpdatedAtPattern.test(value) && + Option.exists(DateTime.make(value), (parsed) => DateTime.formatIso(parsed) === value)) || + "Synced client preferences updatedAt must be a canonical UTC timestamp.", + ), +); +export type SyncedClientPreferencesUpdatedAt = typeof SyncedClientPreferencesUpdatedAt.Type; + +export function nextSyncedClientPreferencesUpdatedAt( + updatedAts: ReadonlyArray, + now: string, +): string { + let latest: string | undefined; + for (const updatedAt of updatedAts) { + if (updatedAt !== undefined && (latest === undefined || updatedAt > latest)) latest = updatedAt; + } + if (latest === undefined || now > latest) return now; + return DateTime.formatIso(DateTime.add(DateTime.makeUnsafe(latest), { milliseconds: 1 })); +} + +const SyncedClientPreferenceFields = { + planModeEnabled: Schema.optionalKey(Schema.Boolean), +} as const; + +export const SYNCED_CLIENT_PREFERENCE_FIELDS = ["planModeEnabled"] as const; +export type SyncedClientPreferenceField = (typeof SYNCED_CLIENT_PREFERENCE_FIELDS)[number]; + +export const SyncedClientPreferencesUpdatedAtByField = Schema.Struct({ + planModeEnabled: Schema.optionalKey(SyncedClientPreferencesUpdatedAt), +}); +export type SyncedClientPreferencesUpdatedAtByField = + typeof SyncedClientPreferencesUpdatedAtByField.Type; + +export const SyncedClientPreferences = Schema.Struct({ + ...SyncedClientPreferenceFields, + updatedAtByField: SyncedClientPreferencesUpdatedAtByField, + 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]; +} + +export const SyncedClientPreferencesPatch = Schema.Struct(SyncedClientPreferenceFields).check( + Schema.makeFilter( + (patch) => + patch.planModeEnabled !== undefined || + "Synced client preferences patch must include at least one supported preference.", + ), +); +export type SyncedClientPreferencesPatch = typeof SyncedClientPreferencesPatch.Type; + +export const PatchSyncedClientPreferencesRequest = Schema.Struct({ + commandId: CommandId, + patch: SyncedClientPreferencesPatch, + updatedAt: SyncedClientPreferencesUpdatedAt, +}); +export type PatchSyncedClientPreferencesRequest = typeof PatchSyncedClientPreferencesRequest.Type;