diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 9be57907b358d914e76ac3d7fa405f32de763a37..b1b286b32ac4a9f436918bedca6c40c3cd42b169 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,3 +1,4 @@ +import { UiSettingsSyncProvider } from "./state/uiSettingsSync"; import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; @@ -65,7 +66,9 @@ export default function App() { - + + + diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 4fbfb98bfdd26f2622d5cd640985c8f228c898dc..83a25b40060c49a7099ac1f14b9a0b33c59544a3 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -1,3 +1,4 @@ +import { UiSettingsSyncSection } from "./UiSettingsSyncSection"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -21,6 +22,7 @@ export function SettingsAppearanceRouteScreen() { paddingBottom: Math.max(insets.bottom, 18) + 18, }} > + diff --git a/apps/mobile/src/features/settings/UiSettingsSyncSection.tsx b/apps/mobile/src/features/settings/UiSettingsSyncSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8020e88c9c60b8c69da0f4bbac71fd9c1f835bd9 --- /dev/null +++ b/apps/mobile/src/features/settings/UiSettingsSyncSection.tsx @@ -0,0 +1,163 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/reactivity"; +import { useState } from "react"; +import { View } from "react-native"; +import type { EnvironmentId, UiSettingsProfile } from "@t3tools/contracts"; +import { AppText as Text } from "../../components/AppText"; +import { useEnvironments } from "../../state/environments"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + uiSettingsSyncAtoms, + useUiSettingsSync, + useUiSettingsSyncActions, +} from "../../state/uiSettingsSync"; +import { SettingsSection } from "./components/SettingsSection"; +import { SettingsActionRow } from "./components/SettingsActionRow"; +import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; + +export function UiSettingsSyncSection() { + const { environments } = useEnvironments(); + const sync = useUiSettingsSync(); + const actions = useUiSettingsSyncActions(); + const [selected, setSelected] = useState(null); + const [saved, setSaved] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const read = useAtomCommand(uiSettingsSyncAtoms.read, { reportFailure: false }); + const permission = useAtomValue(uiSettingsSyncAtoms.patch.permissionAtom(selected)); + const eligible = environments.filter( + (entry) => + entry.connection.phase === "connected" && + entry.serverConfig?.environment.capabilities.uiSettingsSync === true, + ); + const active = environments.find((entry) => entry.environmentId === sync.environmentId); + + async function inspect() { + if (!selected) return; + setBusy(true); + setError(null); + const result = await read({ environmentId: selected, input: {} }); + if (AsyncResult.isSuccess(result)) setSaved(result.value); + else setError("Unable to read the profile. Reconnect to this environment and try again."); + setBusy(false); + } + async function enable(mode: "adopt" | "publish") { + if (!selected || !saved) return; + setBusy(true); + setError(null); + try { + await actions.enable(selected, saved, mode); + setSaved(null); + setSelected(null); + } catch { + setError("Unable to enable sync. Check device storage and try again."); + } finally { + setBusy(false); + } + } + return ( + + + {eligible.map((entry, index) => ( + 0} + disabled={busy} + onPress={() => { + setSelected(entry.environmentId); + setSaved(null); + setError(null); + }} + /> + ))} + void inspect()} + /> + {saved && saved.revision > 0 ? ( + void enable("adopt")} + /> + ) : null} + {saved ? ( + void enable("publish")} + /> + ) : null} + {saved ? ( + setSaved(null)} + /> + ) : null} + {sync.environmentId ? ( + { + void actions + .disable() + .catch(() => + setError("Unable to disable sync. Check device storage and try again."), + ); + }} + /> + ) : null} + + + {sync.environmentId === null + ? "Off" + : sync.status === "synced" + ? `Following ${active?.label ?? "your saved environment"}` + : sync.status === "syncing" + ? "Syncing…" + : "Sync paused. Changes stay on this device until the profile environment reconnects."} + + {saved ? ( + + {saved.revision === 0 + ? "No profile saved. Publish this device’s current preferences to start." + : "Use the saved profile, or publish this device’s shared preferences for other participating devices."} + + ) : null} + {eligible.length === 0 ? ( + + Connect to an updated private environment to set up sync. + + ) : null} + {selected && !permission ? ( + + Settings access is required. Update this connection’s permissions in Connections. + + ) : null} + {sync.conflicts.length > 0 ? ( + + Newer saved preferences replaced conflicting edits. Change a preference again to use your + choice. + + ) : null} + {error ? ( + + {error} + + ) : null} + + ); +} diff --git a/apps/mobile/src/lib/followUpBehavior.ts b/apps/mobile/src/lib/followUpBehavior.ts index dfb5cae60ac6480e3149d4a1c6e5c259e91bc6ba..18c3a1ea04f8d85a9a0e1c577b1da9ba9c9eb6bf 100644 --- a/apps/mobile/src/lib/followUpBehavior.ts +++ b/apps/mobile/src/lib/followUpBehavior.ts @@ -3,10 +3,6 @@ import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/com /** * What the send button does while a turn is already running: `queue` waits for * the turn to finish, `steer` interrupts it with the new message. - * - * Web keeps the same choice in its per-client settings. Mobile has no - * client-settings sync, so it is stored per device alongside the other - * composer preferences. */ export type FollowUpBehavior = Extract; diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index b9f7a17b4d5ff09fff52b2e40c7bd5f70738c9e8..4f5ff9f916ce33017b00a880633ba37481e93298 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -232,6 +232,24 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); + it("keeps the profile host and offline edits across reloads, then persists disabling sync", async () => { + const uiSettingsSync = { + environmentId: EnvironmentId.make("personal"), + profile: { revision: 3, values: { fontSizeCode: 17 }, fieldRevisions: { fontSizeCode: 3 } }, + pending: { + patch: { followUpBehavior: "steer" as const }, + baseRevisions: { followUpBehavior: 0 }, + }, + }; + await savePreferencesPatch({ uiSettingsSync, themeId: "material-you" }); + await expect(loadPreferences()).resolves.toEqual({ uiSettingsSync, themeId: "material-you" }); + await savePreferencesPatch({ uiSettingsSync: null }); + await expect(loadPreferences()).resolves.toEqual({ + uiSettingsSync: null, + themeId: "material-you", + }); + }); + it("persists thread list shelf preferences", async () => { await expect( savePreferencesPatch({ diff --git a/apps/mobile/src/lib/uiSettingsSync.test.ts b/apps/mobile/src/lib/uiSettingsSync.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..68e9d2a403c0c1105043afd890df7c1d3c5272b2 --- /dev/null +++ b/apps/mobile/src/lib/uiSettingsSync.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; +import { portableMobileUiSettings, mobileUiSettingsPatch } from "./uiSettingsSync"; + +describe("native UI profile portability", () => { + it("maps shared controls without publishing device defaults, native fonts, or integration state", () => { + expect(portableMobileUiSettings({})).toEqual({}); + expect( + portableMobileUiSettings({ + followUpBehavior: "steer", + projectGroupingMode: "repository_path", + workingShelfEnabled: true, + themeId: "material-you", + baseFontSize: 22, + terminalFontSize: 10.5, + microphones: [], + liveActivitiesEnabled: true, + }), + ).toEqual({ + followUpBehavior: "steer", + sidebarProjectGroupingMode: "repository_path", + sidebarWorkingShelfEnabled: true, + }); + }); + it("applies shared controls without changing native appearance or dropping desktop profile fields", () => { + expect( + mobileUiSettingsPatch({ + fontSizeCode: 18, + fontFamilySans: "Example", + wordWrap: true, + theme: { selection: "custom", mode: "dark", halves: {}, customThemes: [] }, + followUpBehavior: "queue", + sidebarProjectGroupingMode: "separate", + sidebarWorkingShelfEnabled: false, + }), + ).toEqual({ + followUpBehavior: "queue", + projectGroupingMode: "separate", + workingShelfEnabled: false, + }); + }); +}); diff --git a/apps/mobile/src/lib/uiSettingsSync.ts b/apps/mobile/src/lib/uiSettingsSync.ts new file mode 100644 index 0000000000000000000000000000000000000000..73d1c7de67ca897a3e9de84af3913014221666bd --- /dev/null +++ b/apps/mobile/src/lib/uiSettingsSync.ts @@ -0,0 +1,28 @@ +import type { UiSettingsValues } from "@t3tools/contracts"; +import type { Preferences } from "../persistence/mobile-preferences"; + +/** Native typography and palettes have their own units and catalog; only equivalent controls travel. */ +export function portableMobileUiSettings(preferences: Preferences): UiSettingsValues { + return { + ...(preferences.followUpBehavior === undefined + ? {} + : { followUpBehavior: preferences.followUpBehavior }), + ...(preferences.workingShelfEnabled === undefined + ? {} + : { sidebarWorkingShelfEnabled: preferences.workingShelfEnabled }), + ...(preferences.projectGroupingMode === undefined + ? {} + : { sidebarProjectGroupingMode: preferences.projectGroupingMode }), + }; +} +export function mobileUiSettingsPatch(values: UiSettingsValues): Partial { + return { + ...(values.followUpBehavior === undefined ? {} : { followUpBehavior: values.followUpBehavior }), + ...(values.sidebarWorkingShelfEnabled === undefined + ? {} + : { workingShelfEnabled: values.sidebarWorkingShelfEnabled }), + ...(values.sidebarProjectGroupingMode === undefined + ? {} + : { projectGroupingMode: values.sidebarProjectGroupingMode }), + }; +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 808383183e4c17b3cf025c763e7a70de38b7f72b..c587869c10c5567d40aa3cfc303f7d351fe8d518 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -1,3 +1,4 @@ +import { UiSettingsSyncCache } from "@t3tools/client-runtime/state/ui-settings-sync"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -49,10 +50,12 @@ const PullRequestListPreferences = Schema.Struct({ }); export type PullRequestListPreferences = typeof PullRequestListPreferences.Type; +const isUiSettingsSyncCache = Schema.is(UiSettingsSyncCache); const isThreadListFilters = Schema.is(ThreadListFilters); const isPullRequestListPreferences = Schema.is(PullRequestListPreferences); export interface Preferences { + readonly uiSettingsSync?: UiSettingsSyncCache | null; readonly pullRequestHideTestFiles?: boolean; readonly pullRequestMergeMethod?: PullRequestMergeMethod; readonly liveActivitiesEnabled?: boolean; @@ -136,6 +139,7 @@ export class MobilePreferencesStore extends Context.Service< function sanitizePreferences(parsed: Preferences): Preferences { const preferences: { + uiSettingsSync?: UiSettingsSyncCache | null; pullRequestHideTestFiles?: boolean; pullRequestMergeMethod?: PullRequestMergeMethod; liveActivitiesEnabled?: boolean; @@ -283,6 +287,10 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (isPullRequestListPreferences(parsed.pullRequestListFilters)) { preferences.pullRequestListFilters = parsed.pullRequestListFilters; } + if (parsed.uiSettingsSync === null || isUiSettingsSyncCache(parsed.uiSettingsSync)) { + preferences.uiSettingsSync = parsed.uiSettingsSync; + } + return preferences; } diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index 7c28156386bb1ea84ac1774f166158e82f28babc..d113c57512c9c592ba1cdb3dfc32f30feecc4f21 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -9,9 +9,19 @@ interface OptimisticPreferences { readonly versions: Partial>; } -// A bare function is interpreted by useAtomSet as an update to the command's -// AsyncResult, not as a preference transform. Keep transforms inside a payload. +const editListeners = new Set<(patch: Partial) => void>(); +export function subscribeMobilePreferenceEdits( + listener: (patch: Partial) => void, +) { + editListeners.add(listener); + return () => { + editListeners.delete(listener); + }; +} + +// A bare function updates the command result; preference transforms need a payload. type PreferencesUpdate = + | { readonly patch: Partial; readonly source: "sync" } | Partial | { readonly transform: ( @@ -66,11 +76,14 @@ export function createMobilePreferencesState( (update: PreferencesUpdate, get) => { const currentPreferences = get(preferencesAtom); const patch = - "transform" in update - ? update.transform( - AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {}, - ) - : update; + "source" in update + ? update.patch + : "transform" in update + ? update.transform( + AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {}, + ) + : update; + if (!("source" in update)) for (const listener of editListeners) listener(patch); const version = ++nextPatchVersion; const current = get(optimisticPatchAtom); const versions = { ...current.versions }; diff --git a/apps/mobile/src/state/uiSettingsSync.ts b/apps/mobile/src/state/uiSettingsSync.ts new file mode 100644 index 0000000000000000000000000000000000000000..69feef9e65290ebbba02e3e13cc8c0ea0b7e0475 --- /dev/null +++ b/apps/mobile/src/state/uiSettingsSync.ts @@ -0,0 +1,206 @@ +import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { + UiSettingsSync, + createUiSettingsSyncAtoms, + type UiSettingsSyncCache, +} from "@t3tools/client-runtime/state/ui-settings-sync"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, UiSettingsProfile } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { + createContext, + createElement, + use, + useEffect, + useCallback, + useRef, + useSyncExternalStore, + type PropsWithChildren, +} from "react"; +import { portableMobileUiSettings, mobileUiSettingsPatch } from "../lib/uiSettingsSync"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { + mobilePreferencesAtom, + updateMobilePreferencesAtom, + subscribeMobilePreferenceEdits, +} from "./preferences"; +import { useEnvironments } from "./environments"; +import { resolveMobileProjectGroupingSettings } from "./project-grouping"; + +export const uiSettingsSyncAtoms = createUiSettingsSyncAtoms(connectionAtomRuntime); +const listeners = new Set<() => void>(); +let state: { + controller: UiSettingsSync | null; + environmentId: EnvironmentId | null; + status: "disabled" | UiSettingsSync["snapshot"]["status"]; + conflicts: UiSettingsSync["snapshot"]["conflicts"]; +} = { + controller: null, + environmentId: null, + status: "disabled", + conflicts: [], +}; +function notify() { + for (const listener of listeners) listener(); +} +export function useUiSettingsSync() { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + () => state, + ); +} + +function usePersonalUiSettingsSync() { + const registry = use(RegistryContext); + const hydrated = useRef(false); + const preferences = useAtomValue(mobilePreferencesAtom); + const save = useAtomSet(updateMobilePreferencesAtom, { mode: "promise" }); + const sync = useUiSettingsSync(); + const { environments } = useEnvironments(); + const host = environments.find((entry) => entry.environmentId === sync.environmentId); + const activeController = sync.controller; + const connected = + host?.connection.phase === "connected" && + host.serverConfig?.environment.capabilities.uiSettingsSync === true; + + const install = useCallback( + (cache: UiSettingsSyncCache) => { + const next = new UiSettingsSync(cache, { + save: async (value) => { + await save({ patch: { uiSettingsSync: value }, source: "sync" }); + }, + apply: async (values) => { + await save({ patch: mobileUiSettingsPatch(values), source: "sync" }); + }, + patch: async (input) => { + const result = await runAtomCommand( + registry, + uiSettingsSyncAtoms.patch, + { environmentId: cache.environmentId, input }, + { reportFailure: false }, + ); + if (AsyncResult.isFailure(result)) throw Cause.squash(result.cause); + return result.value; + }, + }); + state = { ...state, controller: next }; + const update = () => { + if (state.controller !== next) return; + state = { controller: next, environmentId: cache.environmentId, ...next.snapshot }; + notify(); + }; + next.subscribe(update); + update(); + return next; + }, + [registry, save], + ); + + useEffect(() => { + if (hydrated.current || !AsyncResult.isSuccess(preferences) || preferences.waiting) return; + hydrated.current = true; + if (!state.controller && preferences.value.uiSettingsSync) { + void install(preferences.value.uiSettingsSync).restore(); + } + }, [install, preferences]); + + useEffect( + () => + subscribeMobilePreferenceEdits((patch) => + state.controller?.edit(portableMobileUiSettings(patch)), + ), + [], + ); + useEffect(() => { + const current = activeController; + if (!current || !sync.environmentId) return; + if (!connected) { + current.disconnect(); + return; + } + let disposed = false; + let unsubscribe = () => {}; + const target = { environmentId: sync.environmentId, input: {} }; + void runAtomCommand(registry, uiSettingsSyncAtoms.read, target, { reportFailure: false }).then( + async (result) => { + if (disposed) return; + if (AsyncResult.isFailure(result)) { + current.disconnect(); + return; + } + await current.receive(result.value); + if (disposed) return; + unsubscribe = registry.subscribe( + uiSettingsSyncAtoms.changes(target), + (update) => { + if (AsyncResult.isSuccess(update)) void current.receive(update.value); + else if (AsyncResult.isFailure(update)) current.disconnect(); + }, + { immediate: true }, + ); + }, + ); + return () => { + disposed = true; + unsubscribe(); + current.disconnect(); + }; + }, [activeController, connected, registry, sync.environmentId]); + + return { + enable: async ( + environmentId: EnvironmentId, + profile: UiSettingsProfile, + mode: "adopt" | "publish", + ) => { + if (!AsyncResult.isSuccess(preferences) || preferences.waiting) + throw new Error("Preferences are still loading."); + const cache: UiSettingsSyncCache = { + environmentId, + profile, + pending: { patch: {}, baseRevisions: {} }, + }; + await state.controller?.stop(); + await save({ patch: { uiSettingsSync: cache }, source: "sync" }); + const next = install(cache); + if (mode === "publish") + next.edit( + portableMobileUiSettings({ + ...preferences.value, + followUpBehavior: preferences.value.followUpBehavior ?? "queue", + workingShelfEnabled: preferences.value.workingShelfEnabled ?? false, + projectGroupingMode: resolveMobileProjectGroupingSettings(preferences.value) + .sidebarProjectGroupingMode, + }), + ); + await next.receive(profile); + }, + disable: async () => { + const previous = state.controller; + state = { ...state, controller: null }; + await previous?.stop(); + await save({ patch: { uiSettingsSync: null }, source: "sync" }); + state = { controller: null, environmentId: null, status: "disabled", conflicts: [] }; + notify(); + }, + }; +} + +const UiSettingsSyncContext = createContext | null>( + null, +); +export function UiSettingsSyncProvider({ children }: PropsWithChildren) { + const actions = usePersonalUiSettingsSync(); + return createElement(UiSettingsSyncContext.Provider, { value: actions }, children); +} +export function useUiSettingsSyncActions() { + const actions = use(UiSettingsSyncContext); + if (!actions) throw new Error("UI settings sync is unavailable."); + return actions; +} diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 781cd1333b972f68d2869b430515a33064ce2579..6c852ffb9a7a9d2337df9520cd38d977cf66c2ba 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -32,6 +32,14 @@ import { import * as RpcAuthorization from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { + it("reads personal UI profiles under read access and protects writes", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.uiSettingsGet)).toBe(AuthOrchestrationReadScope); + expect(requiredScopeForRpcMethod(WS_METHODS.uiSettingsSubscribe)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.uiSettingsPatch)).toBe(AuthSettingsWriteScope); + }); + it("requires filesystem read permission to watch file and folder changes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.projectsWatch)).toBe(AuthFilesystemReadScope); }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index dd84dc17e04563adcc79f4aa4458ed1700b1e630..41e065d51503f324c6ed3cb5c45b01ebd3de21a2 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -44,6 +44,8 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; */ export const RPC_REQUIRED_SCOPES = { ...CLIENT_GUARDED_RPC_SCOPES, + [WS_METHODS.uiSettingsGet]: AuthOrchestrationReadScope, + [WS_METHODS.uiSettingsSubscribe]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 8e635f9814b3d314a81ee717860336116e2906d5..61037cf9ff9c1367764a975bbbe3e307bbd0a526 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -226,6 +226,7 @@ export const make = Effect.gen(function* () { inlineMessageContext: true, requiredWorktreeBootstrap: true, threadSettlement: true, + uiSettingsSync: true, threadAutoSettlement: true, storageCleanup: true, storageCleanupRun: true, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 416ab667c7506db5f524da967bf9bdd827aa33f5..2392898958d0098b2937dbb4817030731a6d42d7 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -15,6 +15,9 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; * prefix. Adding an RPC to `WsRpcGroup` without a label is a type error. */ const RPC_AGGREGATES = { + [WS_METHODS.uiSettingsGet]: "settings", + [WS_METHODS.uiSettingsPatch]: "settings", + [WS_METHODS.uiSettingsSubscribe]: "settings", [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3a11747c340f6a9a24fca502bb9432bc8b63c4a4..dd186689f34fa7f2982479727f4c708075d72cb5 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,3 +1,4 @@ +import * as UiSettings from "./uiSettings.ts"; import * as ScriptProgress from "./progress/ScriptProgress.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; @@ -602,10 +603,13 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), Layer.provideMerge(layerPersistence), - // Both read a user-owned file out of the state directory and stream changes - // to clients; neither depends on the other. Layer.provideMerge( - Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer), + Layer.mergeAll( + Keybindings.layer, + EnvironmentTheme.layer, + UsageLimitSources.layer, + UiSettings.layer, + ), ), Layer.provideMerge(ProviderRegistry.layer), // The instance registry is the new routing keystone — text generation, diff --git a/apps/server/src/uiSettings.test.ts b/apps/server/src/uiSettings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9c23d4f382d4874f3333df9bd5f436788b9a606 --- /dev/null +++ b/apps/server/src/uiSettings.test.ts @@ -0,0 +1,113 @@ +import { UiSettingsProfile } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ServerConfig from "./config.ts"; +import * as UiSettings from "./uiSettings.ts"; + +const encodeProfile = Schema.encodeEffect(Schema.fromJsonString(UiSettingsProfile)); +const decodeProfile = Schema.decodeUnknownEffect(Schema.fromJsonString(UiSettingsProfile)); + +it.layer(NodeServices.layer)("personal UI settings", (it) => { + const service = UiSettings.layer.pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "knife-ui-settings-", + }), + ), + ); + + it.effect("restores a saved profile and refuses to replace unreadable saved preferences", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "knife-ui-settings-restart-" }); + const config = ServerConfig.layerTest(process.cwd(), directory); + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + yield* fs.makeDirectory(stateDir, { recursive: true }); + const saved: UiSettingsProfile = { + revision: 8, + values: { fontSizeCode: 18 }, + fieldRevisions: { fontSizeCode: 8 }, + }; + const encoded = yield* encodeProfile(saved); + yield* fs.writeFileString(`${stateDir}/ui-settings.json`, encoded); + const read = UiSettings.UiSettings.pipe(Effect.flatMap((service) => service.current)); + assert.deepEqual(yield* read.pipe(Effect.provide(Layer.fresh(UiSettings.layer))), saved); + yield* fs.writeFileString(`${stateDir}/ui-settings.json`, "broken"); + const failed = yield* UiSettings.UiSettings.pipe( + Effect.flatMap((service) => + service.patch({ patch: { chatWidth: "full" }, baseRevisions: { chatWidth: 0 } }), + ), + Effect.flip, + Effect.provide(Layer.fresh(UiSettings.layer)), + ); + assert.strictEqual(failed.operation, "read"); + assert.strictEqual(yield* fs.readFileString(`${stateDir}/ui-settings.json`), "broken"); + }).pipe(Effect.provide(config)); + }).pipe(Effect.scoped), + ); + + it.effect("merges independent client edits and rejects stale edits to the same field", () => + Effect.gen(function* () { + const settings = yield* UiSettings.UiSettings; + const first = yield* settings.current; + const second = yield* settings.current; + const font = yield* settings.patch({ + patch: { fontSizeCode: 15 }, + baseRevisions: { fontSizeCode: first.fieldRevisions.fontSizeCode ?? 0 }, + }); + const chat = yield* settings.patch({ + patch: { chatWidth: "full" }, + baseRevisions: { chatWidth: second.fieldRevisions.chatWidth ?? 0 }, + }); + assert.deepEqual(chat.profile.values, { fontSizeCode: 15, chatWidth: "full" }); + const stale = yield* settings.patch({ + patch: { fontSizeCode: 13, timestampFormat: "12-hour" }, + baseRevisions: { fontSizeCode: 0, timestampFormat: 0 }, + }); + assert.deepEqual(stale.conflicts, ["fontSizeCode"]); + assert.strictEqual(stale.profile.values.fontSizeCode, 15); + assert.strictEqual(stale.profile.values.timestampFormat, "12-hour"); + assert.strictEqual(stale.profile.fieldRevisions.fontSizeCode, font.profile.revision); + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const persisted = yield* fs.readFileString(`${stateDir}/ui-settings.json`); + const decoded = yield* decodeProfile(persisted); + assert.deepEqual(decoded, stale.profile); + }).pipe(Effect.provide(service)), + ); + + it.effect("streams the initial profile and delivers an update to a second client", () => + Effect.gen(function* () { + const settings = yield* UiSettings.UiSettings; + const received = yield* settings.changes.pipe( + Stream.take(2), + Stream.tap((profile) => + profile.revision === 0 + ? settings.patch({ patch: { chatWidth: "wide" }, baseRevisions: { chatWidth: 0 } }) + : Effect.void, + ), + Stream.runCollect, + ); + assert.strictEqual(received.length, 2); + assert.strictEqual(received[1]?.values.chatWidth, "wide"); + }).pipe(Effect.provide(service)), + ); + + it.effect("treats a retried committed patch as a no-op and requires a read revision", () => + Effect.gen(function* () { + const settings = yield* UiSettings.UiSettings; + const input = { patch: { fontSizeCode: 16 }, baseRevisions: { fontSizeCode: 0 } }; + const committed = yield* settings.patch(input); + assert.deepEqual(yield* settings.patch(input), committed); + const missingBase = yield* settings.patch({ patch: { fontSizeCode: 18 }, baseRevisions: {} }); + assert.deepEqual(missingBase.conflicts, ["fontSizeCode"]); + assert.deepEqual(missingBase.profile, committed.profile); + }).pipe(Effect.provide(service)), + ); +}); diff --git a/apps/server/src/uiSettings.ts b/apps/server/src/uiSettings.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f56ec785352b4462763ef714ce658e8fbf60255 --- /dev/null +++ b/apps/server/src/uiSettings.ts @@ -0,0 +1,98 @@ +import { + UiSettingsError, + UiSettingsProfile, + type UiSettingsPatchInput, + type UiSettingsPatchResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as ServerConfig from "./config.ts"; + +export class UiSettings extends Context.Service< + UiSettings, + { + readonly current: Effect.Effect; + readonly changes: Stream.Stream; + readonly patch: ( + input: UiSettingsPatchInput, + ) => Effect.Effect; + } +>()("t3/uiSettings") {} + +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { stateDir } = yield* ServerConfig.ServerConfig; + const profilePath = path.join(stateDir, "ui-settings.json"); + const json = Schema.fromJsonString(UiSettingsProfile); + const empty: UiSettingsProfile = { revision: 0, values: {}, fieldRevisions: {} }; + const initial = yield* fs.readFileString(profilePath).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(json)), + Effect.catchIf( + (error) => "reason" in error && error.reason._tag === "NotFound", + () => Effect.succeed(empty), + ), + Effect.mapError((cause) => new UiSettingsError({ operation: "read", cause })), + Effect.result, + ); + const state = yield* SubscriptionRef.make(initial); + const lock = yield* Semaphore.make(1); + const current = SubscriptionRef.get(state).pipe(Effect.flatMap(Effect.fromResult)); + + const patch = Effect.fn("UiSettings.patch")(function* (input: UiSettingsPatchInput) { + return yield* lock + .withPermits(1)( + Effect.gen(function* () { + const profileBefore = yield* current; + const values = { ...profileBefore.values }; + const fieldRevisions = { ...profileBefore.fieldRevisions }; + const conflicts: string[] = []; + const revision = profileBefore.revision + 1; + let changed = false; + + for (const key of Object.keys(input.patch) as Array) { + const value = input.patch[key]; + if (Equal.equals(profileBefore.values[key], value)) continue; + if (input.baseRevisions[key] !== (profileBefore.fieldRevisions[key] ?? 0)) { + conflicts.push(key); + continue; + } + Object.assign(values, { [key]: value }); + fieldRevisions[key] = revision; + changed = true; + } + + if (!changed) return { profile: profileBefore, conflicts }; + const profile: UiSettingsProfile = { revision, values, fieldRevisions }; + const encoded = yield* Schema.encodeEffect(json)(profile).pipe( + Effect.mapError((cause) => new UiSettingsError({ operation: "write", cause })), + ); + yield* fs.makeDirectory(stateDir, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(`${profilePath}.tmp`, encoded)), + Effect.andThen(fs.rename(`${profilePath}.tmp`, profilePath)), + Effect.mapError((cause) => new UiSettingsError({ operation: "write", cause })), + ); + yield* SubscriptionRef.set(state, Result.succeed(profile)); + return { profile, conflicts }; + }), + ) + .pipe(Effect.uninterruptible); + }); + + return UiSettings.of({ + current, + changes: SubscriptionRef.changes(state).pipe(Stream.mapEffect(Effect.fromResult)), + patch, + }); +}); + +export const layer = Layer.effect(UiSettings, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fd91a717a70c4b554a32eb2ae1f13064907a1c55..7c26f559a7df9c6bea77f9189f9ac09a9962f46b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,4 @@ +import * as UiSettings from "./uiSettings.ts"; import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; @@ -1273,6 +1274,7 @@ const layerWsRpc = ( const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const agentSessionImporter = yield* AgentSessionImporter.AgentSessionImporter; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const uiSettings = yield* UiSettings.UiSettings; const keybindings = yield* Keybindings.Keybindings; const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; @@ -2414,6 +2416,9 @@ const layerWsRpc = ( }), [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow, [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports, + [WS_METHODS.uiSettingsGet]: () => uiSettings.current, + [WS_METHODS.uiSettingsPatch]: (input) => uiSettings.patch(input), + [WS_METHODS.uiSettingsSubscribe]: () => uiSettings.changes, [WS_METHODS.serverGetSettings]: (_input) => serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) => diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 370810d8d19ac0cdd33cbe06a03bb1777051f172..9a716db0a9114ae1f90f7b206515dcad08bc00bc 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,3 +1,4 @@ +import { UiSettingsSyncSection } from "./UiSettingsSyncSection"; import { performDesktopUpdate } from "../desktopUpdate.actions"; import { useTerminalPlacement } from "~/hooks/useTerminalPlacement"; import { SettingsGroup } from "./SettingsGroup"; @@ -2220,6 +2221,7 @@ export function GeneralSettingsPanel() { return ( + (null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const read = useAtomCommand(uiSettingsSyncAtoms.read, { reportFailure: false }); + const selected = selection ? EnvironmentId.make(selection) : null; + const permission = useAtomValue(uiSettingsSyncAtoms.patch.permissionAtom(selected)); + const eligible = environments.filter( + (environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.uiSettingsSync === true, + ); + const active = environments.find( + (environment) => environment.environmentId === sync.environmentId, + ); + + async function inspect() { + if (!selected) return; + setBusy(true); + setError(null); + const result = await read({ environmentId: selected, input: {} }); + if (AsyncResult.isSuccess(result)) setSaved({ environmentId: selected, profile: result.value }); + else setError("Unable to read the profile. Reconnect to this environment and try again."); + setBusy(false); + } + + async function enable(mode: "adopt" | "publish") { + if (!saved) return; + setBusy(true); + setError(null); + try { + await enableUiSettingsSync(saved.environmentId, saved.profile, mode); + setSaved(null); + setSelection(""); + } catch { + setError("Unable to enable sync. Check local storage access and try again."); + } finally { + setBusy(false); + } + } + + return ( + + { + void disableUiSettingsSync().catch(() => + setError("Unable to disable sync. Check local storage access and try again."), + ); + }} + > + Disable sync + + ) : undefined + } + > +
+

+ {sync.environmentId === null + ? "Off" + : sync.status === "synced" + ? `Following ${active?.label ?? "your saved environment"}` + : sync.status === "syncing" + ? "Syncing…" + : sync.status === "error" + ? "Sync paused. Reconnect to the profile environment to try again." + : "Offline. Changes stay on this device until the profile environment reconnects."} +

+ {sync.conflicts.length > 0 ? ( +

+ Newer saved preferences replaced conflicting edits. Change a preference again to use + your choice. +

+ ) : null} +
+ + +
+ {eligible.length === 0 ? ( +

+ Connect to an updated private environment to set up sync. +

+ ) : null} + {selected && !permission ? ( +

+ Settings access is required. Update this connection’s permissions in Connections. +

+ ) : null} + {saved ? ( +
+

+ {saved.profile.revision === 0 + ? "No profile saved. Publish this device’s current preferences to start." + : "Use the saved profile, or publish this device’s current preferences. Other participating devices will follow it."} +

+
+ {saved.profile.revision > 0 ? ( + + ) : null} + + +
+
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index bfb3e9e1ca4a6c0954439e075c5e50057ab0f5d0..723b39f6a0d9319d0c5366d789148ed38c83aa5c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -331,6 +331,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Composer context", to: "/settings/appearance", }, + { + id: "ui-settings-sync", + title: "UI settings sync", + to: "/settings/general", + searchTerms: ["personal profile theme fonts devices sync"], + }, { id: "project-grouping", title: "Project grouping", diff --git a/apps/web/src/hooks/useDefaultTheme.test.ts b/apps/web/src/hooks/useDefaultTheme.test.ts index 29b79a591628ad41c88fb2dbe563b2d7b3db8f92..e09e1436c6aac5693fc2ff756970fcf3bc54c21a 100644 --- a/apps/web/src/hooks/useDefaultTheme.test.ts +++ b/apps/web/src/hooks/useDefaultTheme.test.ts @@ -8,9 +8,13 @@ const BASE = { defaultThemeSetAt: "2026-08-28T00:00:00.000Z", appliedGeneration: null, resolves: true, + profileEnabled: false, } as const; describe("default theme adoption", () => { + it("keeps a personal profile ahead of unseen environment defaults", () => { + expect(defaultThemeToApply({ ...BASE, profileEnabled: true })).toBe(null); + }); it("applies a set this client has not seen", () => { expect(defaultThemeToApply(BASE)).toBe( defaultThemeGeneration(BASE.defaultTheme, BASE.defaultThemeSetAt), diff --git a/apps/web/src/hooks/useDefaultTheme.ts b/apps/web/src/hooks/useDefaultTheme.ts index 01c908f736983bb16ad63f827c590c56cb89bad1..afa05d7324abf5be2678c26832691dc7bf023095 100644 --- a/apps/web/src/hooks/useDefaultTheme.ts +++ b/apps/web/src/hooks/useDefaultTheme.ts @@ -1,3 +1,4 @@ +import { personalUiSettingsSyncEnabled, useUiSettingsSync } from "../state/uiSettingsSync"; import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; @@ -34,7 +35,9 @@ export function defaultThemeToApply(input: { readonly defaultThemeSetAt: string; readonly appliedGeneration: string | null; readonly resolves: boolean; + readonly profileEnabled: boolean; }): string | null { + if (input.profileEnabled) return null; if (input.environmentId === null || input.defaultTheme.length === 0) return null; const generation = defaultThemeGeneration(input.defaultTheme, input.defaultThemeSetAt); if (input.appliedGeneration === generation) return null; @@ -71,6 +74,8 @@ function writeAppliedGeneration(storageKey: string, generation: string): void { * resolve yet — the setting and the palette it names arrive independently. */ export function useDefaultThemeAdoption(): void { + const sync = useUiSettingsSync(); + const profileEnabled = sync.controller !== null || personalUiSettingsSyncEnabled(); const environmentId = useAtomValue(primaryEnvironmentIdAtom); const settings = useAtomValue(primaryServerSettingsAtom); const { defaultTheme, defaultThemeSetAt } = settings; @@ -82,6 +87,10 @@ export function useDefaultThemeAdoption(): void { useEffect(() => { if (typeof window === "undefined" || environmentId === null) return; const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`; + if (profileEnabled) { + if (defaultTheme.length > 0) + writeAppliedGeneration(storageKey, defaultThemeGeneration(defaultTheme, defaultThemeSetAt)); + } const definition = getThemeDefinition(defaultTheme); const generation = defaultThemeToApply({ environmentId, @@ -89,6 +98,7 @@ export function useDefaultThemeAdoption(): void { defaultThemeSetAt, appliedGeneration: readAppliedGeneration(storageKey), resolves: definition !== null, + profileEnabled, }); if (generation === null || definition === null) return; @@ -105,6 +115,7 @@ export function useDefaultThemeAdoption(): void { if (half !== null && !setAppearanceMode(half)) return; writeAppliedGeneration(storageKey, generation); }, [ + profileEnabled, environmentId, defaultTheme, defaultThemeSetAt, diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 027373a455624c958b42b534d8e6b5fc91d614ba..c57baeab989a46949b38060a686527b53812c0d0 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -67,6 +67,15 @@ const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; +const clientSettingsEditListeners = new Set<(patch: ClientSettingsPatch) => void>(); + +export function subscribeClientSettingsEdits(listener: (patch: ClientSettingsPatch) => void) { + clientSettingsEditListeners.add(listener); + return () => { + clientSettingsEditListeners.delete(listener); + }; +} + const clientSettingsListeners = new Set<() => void>(); const clientSettingsHydrationListeners = new Set<() => void>(); type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying"; @@ -191,7 +200,11 @@ function enqueueClientSettingsPersistence(work: () => Promise): Promise export function persistClientSettingsPatch( patch: ClientSettingsPatch, persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, + source: "local" | "sync" = "local", ): Promise { + if (source === "local") { + for (const listener of clientSettingsEditListeners) listener(patch); + } // Patches queued before hydration must publish before newer optimistic patches. const deferPatch = clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0; @@ -217,6 +230,7 @@ export function persistClientSettingsPatch( operation: "persist", ...safeErrorLogAttributes(error), }); + if (source === "sync") throw error; }); } diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 7a750fd7434a87a5804d5d5cfd38aedbcc121c42..fc65bb82ec488fe9226b36c99ec62104a8773cbf 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -21,9 +21,47 @@ import { type ThemeAppearance, type ThemeHalves, type ThemePreferenceMode, + getCustomThemePreferences, + replaceCustomThemePreferences, } from "../themePalette"; +import type { UiThemePreferences } from "@t3tools/contracts"; + type Theme = ThemePreference; +const themeEditListeners = new Set<() => void>(); +export function subscribeThemeEdits(listener: () => void) { + themeEditListeners.add(listener); + return () => { + themeEditListeners.delete(listener); + }; +} +function emitThemeEdit() { + for (const listener of themeEditListeners) listener(); +} + +export function readUiThemePreferences(): UiThemePreferences { + const selection = readThemePreference(); + const halves = readThemeHalvesRaw(); + const themeIds = [selection, ...Object.values(halves)]; + return { + selection, + mode: readAppearanceModePreference(selection), + halves, + customThemes: getCustomThemePreferences(themeIds), + }; +} + +export function applyUiThemePreferences(preferences: UiThemePreferences) { + replaceCustomThemePreferences(preferences.customThemes); + window.localStorage.setItem(STORAGE_KEY, preferences.selection); + writeAppearanceModePreference(preferences.mode); + window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, JSON.stringify(preferences.halves)); + themeStorageReadFailure = null; + lastAppliedTheme = null; + applyTheme(preferences.selection, { suppressTransitions: true }); + emitChange(); +} + type ThemeSnapshot = { theme: Theme; resolvedTheme: ThemeAppearance; @@ -555,6 +593,7 @@ export function useTheme() { } applyTheme(next, { suppressTransitions: true }); emitChange(); + emitThemeEdit(); return true; }, []); @@ -580,6 +619,7 @@ export function useTheme() { themeStorageReadFailure = null; applyTheme(getStored(), { suppressTransitions: true }); emitChange(); + emitThemeEdit(); return true; }, []); @@ -624,6 +664,7 @@ export function useTheme() { } applyTheme(getStored(), { suppressTransitions: true }); emitChange(); + emitThemeEdit(); return true; }, [], @@ -648,6 +689,7 @@ export function useTheme() { } applyTheme(getStored(), { suppressTransitions: true }); emitChange(); + emitThemeEdit(); return true; }, []); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8afd27e9629b800fd17579afe3a3c38c74f245f9..8e43622738d95e8a041d9ecda79ae4b79250c82f 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,3 +1,4 @@ +import { usePersonalUiSettingsSync } from "../state/uiSettingsSync"; import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice"; import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; @@ -143,6 +144,7 @@ function RootRouteNotFoundView() { } function RootRouteView() { + usePersonalUiSettingsSync(); useEffect(() => installDesktopPasteAsText(window.desktopBridge, window), []); const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); diff --git a/apps/web/src/state/uiSettingsSync.test.tsx b/apps/web/src/state/uiSettingsSync.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..717e457e160263bcf98b0c92a140f6e5c9959c59 --- /dev/null +++ b/apps/web/src/state/uiSettingsSync.test.tsx @@ -0,0 +1,210 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { act, useEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + persist: vi.fn<(settings: ClientSettings) => Promise>().mockResolvedValue(undefined), + load: vi.fn<() => Promise>().mockResolvedValue(null), + rpc: vi.fn(), + changes: null as Atom.Writable> | null, + supported: false, + read: Symbol("read"), + patch: Symbol("patch"), +})); +vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: undefined })); +vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: undefined })); +vi.mock("../localApi", () => ({ + ensureLocalApi: () => ({ + persistence: { getClientSettings: mocks.load, setClientSettings: mocks.persist }, + }), +})); +vi.mock("./session", () => ({ + environmentSession: {}, + readEnvironmentScope: () => false, + useEnvironmentScope: () => false, +})); +vi.mock("./server", () => ({ serverEnvironment: {}, primaryServerSettingsAtom: undefined })); +vi.mock("./environments", () => ({ + useEnvironments: () => ({ + environments: [ + { + environmentId: "personal", + label: "Personal", + connection: { phase: "connected" }, + serverConfig: { environment: { capabilities: { uiSettingsSync: mocks.supported } } }, + }, + ], + }), +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({ + ...(await original()), + runAtomCommand: mocks.rpc, +})); +vi.mock("@t3tools/client-runtime/state/ui-settings-sync", async (original) => ({ + ...(await original()), + createUiSettingsSyncAtoms: () => ({ + changes: () => mocks.changes, + patch: mocks.patch, + read: mocks.read, + }), +})); + +import { + ensureClientSettingsHydrated, + getClientSettings, + persistClientSettingsPatch, + subscribeClientSettingsEdits, + useClientSettings, + __resetClientSettingsPersistenceForTests, +} from "../hooks/useSettings"; +import { readUiThemePreferences, useTheme } from "../hooks/useTheme"; +import { getStandardThemeColors, getCustomThemes } from "../themePalette"; +import { + enableUiSettingsSync, + disableUiSettingsSync, + usePersonalUiSettingsSync, +} from "./uiSettingsSync"; + +let renderer: ReactTestRenderer | undefined; +const registry = AtomRegistry.make(); +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + await disableUiSettingsSync(); + __resetClientSettingsPersistenceForTests(); + vi.unstubAllGlobals(); +}); + +it("adopts and follows a second client's theme and fonts through the existing stores without echoing", async () => { + const storage = new Map(); + const desktopTheme = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("window", { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + matchMedia: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + addEventListener: () => {}, + removeEventListener: () => {}, + desktopBridge: { setTheme: desktopTheme }, + }); + const style = { setProperty: vi.fn(), removeProperty: vi.fn() }; + const root = { + style, + dataset: {} as Record, + classList: { add: vi.fn(), toggle: vi.fn(), remove: vi.fn() }, + }; + vi.stubGlobal("document", { documentElement: root }); + vi.stubGlobal("requestAnimationFrame", (callback: () => void) => { + callback(); + return 1; + }); + mocks.changes = Atom.make>(AsyncResult.initial()); + mocks.supported = false; + mocks.rpc.mockReset(); + await ensureClientSettingsHydrated(); + let observed = DEFAULT_CLIENT_SETTINGS; + function Client() { + usePersonalUiSettingsSync(); + const settings = useClientSettings(); + useEffect(() => { + observed = settings; + }, [settings]); + useTheme(); + return null; + } + await act(async () => { + renderer = create( + + + , + ); + }); + const theme = { + selection: "personal-dark", + mode: "dark" as const, + halves: {}, + customThemes: [ + { + id: "personal-dark", + name: "Personal dark", + appearance: "dark" as const, + colors: getStandardThemeColors("dark"), + }, + ], + }; + const saved: UiSettingsProfile = { + revision: 1, + values: { theme, fontFamilyCode: "Saved Mono", fontSizeCode: 17, fontSizeTerminal: 15 }, + fieldRevisions: { theme: 1, fontFamilyCode: 1, fontSizeCode: 1, fontSizeTerminal: 1 }, + }; + const edit = vi.fn(); + const unsubscribe = subscribeClientSettingsEdits(edit); + await act(async () => { + await enableUiSettingsSync(EnvironmentId.make("personal"), saved, "adopt"); + }); + expect(observed.fontFamilyCode).toBe("Saved Mono"); + expect(getClientSettings().fontSizeTerminal).toBe(15); + expect(readUiThemePreferences().selection).toBe("personal-dark"); + expect(getCustomThemes()[0]?.id).toBe("personal-dark"); + expect(root.dataset.themeId).toBe("personal-dark"); + expect(desktopTheme).toHaveBeenCalledWith("dark"); + expect(style.setProperty).toHaveBeenCalled(); + expect(edit).not.toHaveBeenCalled(); + expect(mocks.rpc).not.toHaveBeenCalled(); + + mocks.supported = true; + const remote = { + ...saved, + revision: 2, + values: { ...saved.values, fontFamilyCode: "Second Mono", fontSizeTerminal: 18 }, + fieldRevisions: { ...saved.fieldRevisions, fontFamilyCode: 2, fontSizeTerminal: 2 }, + }; + mocks.rpc.mockResolvedValue(AsyncResult.success(remote)); + await act(async () => { + registry.set(mocks.changes!, AsyncResult.success(remote)); + renderer?.update( + + + , + ); + }); + expect(observed.fontFamilyCode).toBe("Second Mono"); + expect(observed.fontSizeTerminal).toBe(18); + expect(edit).not.toHaveBeenCalled(); + expect(mocks.rpc).toHaveBeenCalledTimes(1); + await act(async () => { + registry.set( + mocks.changes!, + AsyncResult.waiting( + AsyncResult.success({ + ...remote, + revision: 3, + values: { ...remote.values, fontFamilyCode: "Live Mono", fontSizeTerminal: 20 }, + fieldRevisions: { ...remote.fieldRevisions, fontFamilyCode: 3, fontSizeTerminal: 3 }, + }), + ), + ); + }); + expect(observed.fontFamilyCode).toBe("Live Mono"); + expect(observed.fontSizeTerminal).toBe(20); + expect(edit).not.toHaveBeenCalled(); + expect(mocks.rpc).toHaveBeenCalledTimes(1); + await act(async () => { + await disableUiSettingsSync(); + await persistClientSettingsPatch({ fontSizeCode: 19 }); + }); + expect(edit).toHaveBeenCalledWith({ fontSizeCode: 19 }); + expect(getClientSettings().fontSizeCode).toBe(19); + expect(mocks.rpc).toHaveBeenCalledTimes(1); + unsubscribe(); +}); diff --git a/apps/web/src/state/uiSettingsSync.ts b/apps/web/src/state/uiSettingsSync.ts new file mode 100644 index 0000000000000000000000000000000000000000..5db8582b7fa8789d89104ec650ac129e26eb8861 --- /dev/null +++ b/apps/web/src/state/uiSettingsSync.ts @@ -0,0 +1,220 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + UiSettingsSync, + UiSettingsSyncCache, + createUiSettingsSyncAtoms, + type UiSettingsSyncCache as SyncCache, +} from "@t3tools/client-runtime/state/ui-settings-sync"; +import { UiSettingsValues, type EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Equal from "effect/Equal"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import { AsyncResult } from "effect/reactivity"; +import { use, useEffect, useSyncExternalStore } from "react"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { + ensureClientSettingsHydrated, + getClientSettings, + persistClientSettingsPatch, + subscribeClientSettingsEdits, +} from "../hooks/useSettings"; +import { + applyUiThemePreferences, + readUiThemePreferences, + subscribeThemeEdits, +} from "../hooks/useTheme"; +import { subscribeToCustomThemeEdits } from "../themePalette"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useEnvironments } from "./environments"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; + +export const uiSettingsSyncAtoms = createUiSettingsSyncAtoms(connectionAtomRuntime); +const STORAGE_KEY = "knife:ui-settings-sync:v1"; +const json = Schema.fromJsonString(UiSettingsSyncCache); +const decodeCache = Schema.decodeUnknownSync(json); +const decodeValues = Schema.decodeUnknownSync(UiSettingsValues); +const listeners = new Set<() => void>(); +let state: { + controller: UiSettingsSync | null; + environmentId: EnvironmentId | null; + status: "disabled" | UiSettingsSync["snapshot"]["status"]; + conflicts: UiSettingsSync["snapshot"]["conflicts"]; +} = { controller: null, environmentId: null, status: "disabled", conflicts: [] }; + +function notify() { + for (const listener of listeners) listener(); +} + +export function personalUiSettingsSyncEnabled() { + if (state.controller) return true; + try { + return window.localStorage.getItem(STORAGE_KEY) !== null; + } catch { + return true; + } +} + +export function useUiSettingsSync() { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + () => state, + ); +} + +async function apply(values: UiSettingsValues) { + if (values.theme && !Equal.equals(readUiThemePreferences(), values.theme)) { + applyUiThemePreferences(values.theme); + } + const patch = Struct.omit(values, ["theme"]); + const local = getClientSettings(); + for (const key of Object.keys(patch) as Array) { + if (Equal.equals(local[key], patch[key])) delete patch[key]; + } + if (Object.keys(patch).length > 0) await persistClientSettingsPatch(patch, undefined, "sync"); +} + +function install(cache: SyncCache) { + const next = new UiSettingsSync(cache, { + save: async (value) => { + window.localStorage.setItem(STORAGE_KEY, Schema.encodeSync(json)(value)); + }, + apply, + patch: async (input) => { + const result = await runAtomCommand( + appAtomRegistry, + uiSettingsSyncAtoms.patch, + { + environmentId: cache.environmentId, + input, + }, + { reportFailure: false }, + ); + if (AsyncResult.isFailure(result)) throw Cause.squash(result.cause); + return result.value; + }, + }); + state = { ...state, controller: next }; + const update = () => { + if (state.controller !== next) return; + state = { controller: next, environmentId: cache.environmentId, ...next.snapshot }; + notify(); + }; + next.subscribe(update); + update(); + return next; +} + +export async function enableUiSettingsSync( + environmentId: EnvironmentId, + profile: UiSettingsProfile, + mode: "adopt" | "publish", +) { + await ensureClientSettingsHydrated(); + const local = + mode === "publish" + ? { ...decodeValues(getClientSettings()), theme: readUiThemePreferences() } + : null; + const cache: SyncCache = { environmentId, profile, pending: { patch: {}, baseRevisions: {} } }; + // Persist consent before installing a controller that can write to the environment. + await state.controller?.stop(); + window.localStorage.setItem(STORAGE_KEY, Schema.encodeSync(json)(cache)); + const next = install(cache); + if (local) next.edit(local); + await next.receive(profile); +} + +export async function disableUiSettingsSync() { + const previous = state.controller; + state = { ...state, controller: null }; + await previous?.stop(); + window.localStorage.removeItem(STORAGE_KEY); + state = { controller: null, environmentId: null, status: "disabled", conflicts: [] }; + notify(); +} + +/** Mounted once for the app; individual settings views never own the sync lifecycle. */ +export function usePersonalUiSettingsSync() { + const registry = use(RegistryContext); + const sync = useUiSettingsSync(); + const { environments } = useEnvironments(); + const environment = environments.find((entry) => entry.environmentId === sync.environmentId); + const activeController = sync.controller; + const connected = + environment?.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.uiSettingsSync === true; + + useEffect(() => { + let disposed = false; + const unlisten = subscribeClientSettingsEdits((patch) => { + state.controller?.edit(decodeValues(patch)); + }); + const themeChanged = () => { + if (!state.controller) return; + try { + state.controller.edit({ theme: readUiThemePreferences() }); + } catch { + state.controller.disconnect(); + } + }; + const untheme = subscribeThemeEdits(themeChanged); + const uncustom = subscribeToCustomThemeEdits(themeChanged); + void ensureClientSettingsHydrated() + .then(() => { + if (disposed || state.controller) return; + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw) return install(decodeCache(raw)).restore(); + }) + .catch(() => { + state = { ...state, status: "error" }; + notify(); + }); + return () => { + disposed = true; + unlisten(); + untheme(); + uncustom(); + }; + }, []); + + useEffect(() => { + const current = activeController; + if (!current || !sync.environmentId) return; + if (!connected) { + current.disconnect(); + return; + } + let disposed = false; + let unsubscribe = () => {}; + const target = { environmentId: sync.environmentId, input: {} }; + void runAtomCommand(registry, uiSettingsSyncAtoms.read, target, { reportFailure: false }).then( + async (result) => { + if (disposed) return; + if (AsyncResult.isFailure(result)) { + current.disconnect(); + return; + } + await current.receive(result.value); + if (disposed) return; + unsubscribe = registry.subscribe( + uiSettingsSyncAtoms.changes(target), + (update) => { + if (AsyncResult.isSuccess(update)) void current.receive(update.value); + else if (AsyncResult.isFailure(update)) current.disconnect(); + }, + { immediate: true }, + ); + }, + ); + return () => { + disposed = true; + unsubscribe(); + current.disconnect(); + }; + }, [activeController, connected, registry, sync.environmentId]); +} diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index da9af454c5a25f3bc7c9aa8a833651857d4762c0..8f84fc932dc64ecfbba3eea4dd661b2c0ba75453 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1,3 +1,4 @@ +import type { UiThemePreferences } from "@t3tools/contracts"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import "culori/css"; @@ -242,6 +243,51 @@ export function invalidateCustomThemes() { const EMPTY_CUSTOM_THEMES: ReadonlyArray = []; +const customThemeEditListeners = new Set<() => void>(); +export function subscribeToCustomThemeEdits(listener: () => void) { + customThemeEditListeners.add(listener); + return () => { + customThemeEditListeners.delete(listener); + }; +} + +export function getCustomThemePreferences( + selected: ReadonlyArray, +): UiThemePreferences["customThemes"] { + const library = getWritableCustomThemeLibrary(); + const selectedIds = new Set(selected.map(themeIdFromPreference)); + const published = environmentThemeDefinitions.filter( + (theme) => + selectedIds.has(theme.id) && !library.themes.some((custom) => custom.id === theme.id), + ); + return [...library.themes, ...published].map( + ({ id, label, appearance, colors, variants, collection, managed }) => ({ + id, + name: label, + appearance, + colors, + ...(variants ? { variants } : {}), + ...(collection ? { collection } : {}), + ...(managed === undefined ? {} : { managed }), + }), + ); +} + +export function replaceCustomThemePreferences(themes: UiThemePreferences["customThemes"]) { + const definitions = themes.map(({ name, ...theme }) => ({ ...theme, label: name })); + const canonical = parseStoredThemes(definitions); + if (canonical.length !== definitions.length) throw new Error("Unable to apply saved themes."); + const library = getWritableCustomThemeLibrary(); + const stored = canonical.filter( + (theme) => + library.themes.some((custom) => custom.id === theme.id) || + !environmentThemeDefinitions.some((published) => + Equal.equals(canonicalizeThemeDefinition(published), theme), + ), + ); + saveCustomThemes(stored, stored, "sync"); +} + export function getCustomThemes(): ReadonlyArray { const snapshot = getCustomThemeLibrarySnapshot(); return snapshot.status === "ready" ? snapshot.themes : EMPTY_CUSTOM_THEMES; @@ -1169,6 +1215,7 @@ export class ThemeLibraryStorageError extends Schema.TaggedError, themes: ReadonlyArray, + source: "local" | "sync" = "local", ): void { if (typeof window === "undefined") return; try { @@ -1183,6 +1230,7 @@ function saveCustomThemes( }); } notifyCustomThemeListeners(); + if (source === "local") for (const listener of customThemeEditListeners) listener(); } function requireWritableCustomThemeLibrary( diff --git a/docs/user/appearance.md b/docs/user/appearance.md index ec02a28ed787ab2c18799b38ffd573eecc753433..a8252b71dcbc84cc41cb6f1237a923eb0099e179 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -2,8 +2,7 @@ On web and desktop, open **Settings → Appearance** to choose a theme and follow the system appearance or stay in light or dark mode. To use different themes for light and dark mode, select -the corresponding preview within each theme. Appearance preferences are saved separately on each -device or browser. +the corresponding preview within each theme. Appearance preferences stay on each device or browser unless you enable UI settings sync. On web and desktop, use **Change theme** in the command palette to select a theme without leaving chat. Press **Cmd+Option+A** on macOS or **Ctrl+Alt+A** on Windows/Linux to open the theme picker directly. @@ -21,6 +20,23 @@ Android uses **Material You Layout** by default unless you have turned it off in It changes shapes, spacing, and controls independently of the selected theme. +## Sync across devices + +On web or desktop, open **Settings → General → UI settings sync** and choose a private connected +environment. Read its profile, then choose **Publish current** to save this device’s preferences +or **Use saved** to adopt the existing profile. Repeat on each participating device using the same +environment. Themes, custom palettes, fonts, and chat presentation follow the profile; window +layout, credentials, permissions, and native integrations stay local. The personal profile takes +precedence over environment theme defaults. + +Mobile joins from **Settings → Appearance → UI settings sync**. It shares follow-up behavior, +project grouping, and the working shelf. Native themes and text sizes stay on the device. + +Changes made offline stay usable locally and sync when the profile environment reconnects. When +two devices change the same preference from an older saved version, the newer saved choice wins. +Change it again to use your choice. **Disable sync** keeps the current preferences on this device; +choosing another environment starts the same publish-or-adopt flow. + ## Composer context Git-backed projects show branch and worktree controls below the composer while you create a thread. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f7444f2108a32a903e947387691e45f13ede4dde..360c4bd0bd5f540c1e96f2d6fb78bcccdda1f127 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -422,6 +422,10 @@ "./state/script-progress": { "types": "./src/state/scriptProgress.ts", "default": "./src/state/scriptProgress.ts" + }, + "./state/ui-settings-sync": { + "types": "./src/state/uiSettingsSync.ts", + "default": "./src/state/uiSettingsSync.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index ae98ebbfea6f02c0c69fb4ea3de299b022398879..b8a25179066b5e694bcf5baf18a4cda7311079d1 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -48,6 +48,7 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; type RpcMethod = WsRpcProtocolClient[TTag]; export type EnvironmentSubscriptionRpcTag = + | typeof WS_METHODS.uiSettingsSubscribe | typeof WS_METHODS.subscribeScriptProgress | typeof WS_METHODS.codexAuthCallbackSubscribe | typeof WS_METHODS.providerAuthSubscribe diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 58a77730a40119537f0718076276b6b672a83380..7e95c18b6a9d3dc04623d09708dbc5216a4f68a5 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -59,6 +59,30 @@ const setup = Effect.gen(function* () { }); describe("command permissions", () => { + it.effect("requires settings access on the selected UI profile environment", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(false), + scopes: [AuthSettingsWriteScope], + permissions: [AuthSettingsWriteScope], + }), + ); + registry.set(sessions(other), AsyncResult.success(grant(true))); + const command = createCommandPermissions(runtime, WS_METHODS.uiSettingsPatch); + expect(registry.get(command.permissionAtom(env))).toBe(true); + yield* command.authorize(registry, env); + expect(registry.get(command.permissionAtom(other))).toBe(false); + expect((yield* command.authorize(registry, other).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + }), + ), + ); + it.effect("requires the destination settings grant to run storage cleanup", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/uiSettingsSync.test.ts b/packages/client-runtime/src/state/uiSettingsSync.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..83e8b9f5b36643c65527d5c3f68ab8d82e335c16 --- /dev/null +++ b/packages/client-runtime/src/state/uiSettingsSync.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { + EnvironmentId, + type UiSettingsProfile, + type UiSettingsValues, + type UiSettingsPatchResult, +} from "@t3tools/contracts"; +import { UiSettingsSync, type UiSettingsSyncCache } from "./uiSettingsSync.ts"; + +const host = EnvironmentId.make("personal"); +const empty: UiSettingsProfile = { revision: 0, values: {}, fieldRevisions: {} }; +const profile: UiSettingsProfile = { + revision: 2, + values: { fontSizeCode: 16, chatWidth: "wide" }, + fieldRevisions: { fontSizeCode: 1, chatWidth: 2 }, +}; + +function setup(saved = profile) { + let cache: UiSettingsSyncCache = { + environmentId: host, + profile: saved, + pending: { patch: {}, baseRevisions: {} }, + }; + const apply = vi.fn<(values: UiSettingsValues) => Promise>().mockResolvedValue(undefined); + const patch = vi.fn<(input: UiSettingsSyncCache["pending"]) => Promise>(); + const save = vi.fn(async (value: UiSettingsSyncCache) => { + cache = value; + }); + const controller = new UiSettingsSync(cache, { save, apply, patch }); + return { controller, patch, save, apply, cache: () => cache }; +} + +function deferred() { + let resolve!: (value: A) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +describe("UI preference sync", () => { + it("adopts the saved profile without publishing bootstrap defaults or echoing", async () => { + const { controller, apply, patch } = setup(); + await controller.receive(profile); + expect(apply).toHaveBeenLastCalledWith(profile.values); + controller.edit({ fontSizeCode: 16 }); + expect(patch).not.toHaveBeenCalled(); + expect(controller.snapshot.status).toBe("synced"); + }); + + it("restores cached preferences while offline without a bootstrap write", async () => { + const { controller, apply, patch } = setup(); + controller.edit({ fontSizeCode: 18 }); + await controller.restore(); + expect(apply).toHaveBeenLastCalledWith({ fontSizeCode: 18, chatWidth: "wide" }); + expect(patch).not.toHaveBeenCalled(); + expect(controller.snapshot.status).toBe("offline"); + }); + + it("keeps deliberate offline edits across a restart and reads before sending", async () => { + const first = setup(); + first.controller.edit({ fontSizeCode: 18 }); + await first.controller.stop(); + expect(first.patch).not.toHaveBeenCalled(); + const applied: UiSettingsValues[] = []; + const send = vi.fn( + async () => + ({ + profile: { + revision: 4, + values: { fontSizeCode: 18, chatWidth: "full" }, + fieldRevisions: { fontSizeCode: 4, chatWidth: 3 }, + }, + conflicts: [], + }) satisfies UiSettingsPatchResult, + ); + const restarted = new UiSettingsSync(first.cache(), { + save: async () => {}, + apply: async (value) => { + applied.push(value); + }, + patch: send, + }); + expect(send).not.toHaveBeenCalled(); + await restarted.receive({ + ...profile, + revision: 3, + values: { ...profile.values, chatWidth: "full" }, + fieldRevisions: { ...profile.fieldRevisions, chatWidth: 3 }, + }); + expect(send).toHaveBeenCalledWith({ + patch: { fontSizeCode: 18 }, + baseRevisions: { fontSizeCode: 1 }, + }); + expect(applied.at(-1)).toEqual({ fontSizeCode: 18, chatWidth: "full" }); + }); + + it("preserves an edit made while an earlier edit is in flight", async () => { + const { controller, patch, cache } = setup(empty); + const started = deferred(); + const response = deferred(); + patch.mockImplementationOnce(() => { + started.resolve(); + return response.promise; + }); + patch.mockResolvedValueOnce({ + profile: { revision: 2, values: { fontSizeCode: 18 }, fieldRevisions: { fontSizeCode: 2 } }, + conflicts: [], + }); + controller.edit({ fontSizeCode: 16 }); + const connected = controller.receive(empty); + await started.promise; + controller.edit({ fontSizeCode: 18 }); + response.resolve({ + profile: { revision: 1, values: { fontSizeCode: 16 }, fieldRevisions: { fontSizeCode: 1 } }, + conflicts: [], + }); + await connected; + expect(patch.mock.calls[1]?.[0]).toEqual({ + patch: { fontSizeCode: 18 }, + baseRevisions: { fontSizeCode: 1 }, + }); + expect(cache().pending.patch).toEqual({}); + expect(cache().profile.values.fontSizeCode).toBe(18); + }); + + it("adopts newer saved values on a same-field conflict while retaining independent edits", async () => { + const { controller, patch, apply } = setup(); + controller.edit({ fontSizeCode: 18, timestampFormat: "12-hour" }); + patch.mockResolvedValue({ + profile: { + revision: 4, + values: { fontSizeCode: 20, timestampFormat: "12-hour" }, + fieldRevisions: { fontSizeCode: 3, timestampFormat: 4 }, + }, + conflicts: ["fontSizeCode"], + }); + await controller.receive(profile); + expect(apply).toHaveBeenLastCalledWith({ fontSizeCode: 20, timestampFormat: "12-hour" }); + expect(controller.snapshot.conflicts).toEqual(["fontSizeCode"]); + }); + + it("does not revive a disconnected client while hydration is applying", async () => { + const { controller, patch, apply } = setup(); + const applying = deferred(); + const ready = deferred(); + apply.mockImplementation(() => { + ready.resolve(); + return applying.promise; + }); + controller.edit({ fontSizeCode: 18 }); + const receiving = controller.receive(profile); + await ready.promise; + controller.disconnect(); + applying.resolve(); + await receiving; + expect(patch).not.toHaveBeenCalled(); + expect(controller.snapshot.status).toBe("offline"); + }); + + it("keeps pending edits when sending fails and retries them after a fresh profile", async () => { + const { controller, patch, cache } = setup(); + controller.edit({ fontSizeCode: 18 }); + patch.mockRejectedValueOnce(new Error("offline")); + await controller.receive(profile); + expect(controller.snapshot.status).toBe("error"); + expect(cache().pending.patch).toEqual({ fontSizeCode: 18 }); + patch.mockResolvedValue({ + profile: { + ...profile, + revision: 3, + values: { ...profile.values, fontSizeCode: 18 }, + fieldRevisions: { ...profile.fieldRevisions, fontSizeCode: 3 }, + }, + conflicts: [], + }); + await controller.receive(profile); + expect(patch).toHaveBeenCalledTimes(2); + expect(controller.snapshot.status).toBe("synced"); + expect(cache().pending.patch).toEqual({}); + }); + + it("flushes a reconnected client after the old connection's pending request fails", async () => { + const { controller, patch, cache } = setup(); + const started = deferred(); + const oldResponse = deferred(); + patch.mockImplementationOnce(() => { + started.resolve(); + return oldResponse.promise; + }); + patch.mockResolvedValueOnce({ + profile: { + ...profile, + revision: 3, + values: { ...profile.values, fontSizeCode: 18 }, + fieldRevisions: { ...profile.fieldRevisions, fontSizeCode: 3 }, + }, + conflicts: [], + }); + + controller.edit({ fontSizeCode: 18 }); + const firstConnection = controller.receive(profile); + await started.promise; + controller.disconnect(); + await controller.receive(profile); + oldResponse.reject(new Error("old connection closed")); + await firstConnection; + + expect(patch).toHaveBeenCalledTimes(2); + expect(cache().pending.patch).toEqual({}); + expect(cache().profile.values.fontSizeCode).toBe(18); + expect(controller.snapshot.status).toBe("synced"); + }); + + it("stops applying and sending after disabling even when an old request finishes", async () => { + const { controller, patch, apply } = setup(); + const started = deferred(); + const response = deferred(); + patch.mockImplementation(() => { + started.resolve(); + return response.promise; + }); + controller.edit({ fontSizeCode: 18 }); + const connecting = controller.receive(profile); + await started.promise; + await controller.stop(); + const calls = apply.mock.calls.length; + response.resolve({ profile, conflicts: [] }); + await connecting; + await controller.receive(profile); + controller.edit({ chatWidth: "full" }); + expect(apply).toHaveBeenCalledTimes(calls); + expect(patch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/client-runtime/src/state/uiSettingsSync.ts b/packages/client-runtime/src/state/uiSettingsSync.ts new file mode 100644 index 0000000000000000000000000000000000000000..32dcf78b0f867cc69ff8289de399f205b914d594 --- /dev/null +++ b/packages/client-runtime/src/state/uiSettingsSync.ts @@ -0,0 +1,209 @@ +import { + UiSettingsProfile, + UiSettingsValues, + UiSettingsPatchInput, + type UiSettingsPatchResult, + EnvironmentId, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Equal from "effect/Equal"; +import type * as EnvironmentRegistry from "../connection/registry.ts"; +import type { Atom } from "effect/reactivity"; +import * as Schema from "effect/Schema"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; + +export const UiSettingsSyncCache = Schema.Struct({ + environmentId: EnvironmentId, + profile: UiSettingsProfile, + pending: UiSettingsPatchInput, +}); +export type UiSettingsSyncCache = typeof UiSettingsSyncCache.Type; + +/** Platform adapters keep their existing preference stores and apply only supported fields. */ +export class UiSettingsSync { + private cache: UiSettingsSyncCache; + private connectionGeneration = 0; + private stopped = false; + private flushing = false; + private persistence: Promise = Promise.resolve(); + private listeners = new Set<() => void>(); + private status: "offline" | "syncing" | "synced" | "error" = "offline"; + private conflicts: ReadonlyArray = []; + + private readonly adapter: { + readonly save: (cache: UiSettingsSyncCache) => Promise; + readonly apply: (values: UiSettingsValues) => Promise; + readonly patch: (input: UiSettingsPatchInput) => Promise; + }; + + constructor(cache: UiSettingsSyncCache, adapter: UiSettingsSync["adapter"]) { + this.cache = cache; + this.adapter = adapter; + } + + get snapshot() { + return { status: this.status, conflicts: this.conflicts }; + } + + readonly subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + private notify() { + for (const listener of this.listeners) listener(); + } + + private save() { + const cache = this.cache; + const saved = this.persistence.then(() => this.adapter.save(cache)); + this.persistence = saved.catch(() => undefined); + return saved; + } + + private get online() { + return this.status === "syncing" || this.status === "synced"; + } + + private get values(): UiSettingsValues { + return { ...this.cache.profile.values, ...this.cache.pending.patch }; + } + + /** Records deliberate edits only; hydration and remote applications never call this. */ + edit(patch: UiSettingsValues) { + if (this.stopped) return; + const pending = { ...this.cache.pending.patch }; + const baseRevisions = { ...this.cache.pending.baseRevisions }; + let changed = false; + for (const key of Object.keys(patch) as Array) { + if (Equal.equals(this.values[key], patch[key])) continue; + Object.assign(pending, { [key]: patch[key] }); + baseRevisions[key] ??= this.cache.profile.fieldRevisions[key] ?? 0; + changed = true; + } + if (!changed) return; + this.cache = { ...this.cache, pending: { patch: pending, baseRevisions } }; + this.conflicts = []; + this.status = this.online ? "syncing" : "offline"; + this.notify(); + void this.save() + .then(() => this.flush()) + .catch(() => this.fail()); + } + + async restore() { + if (this.stopped) return; + try { + await this.adapter.apply(this.values); + } catch { + this.fail(); + } + } + + async receive(profile: UiSettingsProfile) { + if (this.stopped) return; + if (profile.revision < this.cache.profile.revision) return; + const generation = this.connectionGeneration; + this.cache = { ...this.cache, profile }; + try { + await this.save(); + if (this.stopped) return; + await this.adapter.apply(this.values); + if (this.stopped || generation !== this.connectionGeneration) return; + this.status = "syncing"; + await this.flush(); + } catch { + if (generation === this.connectionGeneration) this.fail(); + } + } + + disconnect() { + this.connectionGeneration += 1; + this.status = "offline"; + this.notify(); + } + + stop() { + this.stopped = true; + this.disconnect(); + return this.persistence; + } + + private fail() { + if (this.stopped) return; + this.status = "error"; + this.notify(); + } + + private async flush() { + if (this.flushing || !this.online || this.stopped) return; + const generation = this.connectionGeneration; + this.flushing = true; + try { + while (this.online && !this.stopped) { + const sent = this.cache.pending; + if (Object.keys(sent.patch).length === 0) break; + this.status = "syncing"; + this.notify(); + const result = await this.adapter.patch(sent); + if (this.stopped) return; + const patch = { ...this.cache.pending.patch }; + const baseRevisions = { ...this.cache.pending.baseRevisions }; + for (const key of Object.keys(sent.patch) as Array) { + if (Equal.equals(patch[key], sent.patch[key])) { + delete patch[key]; + delete baseRevisions[key]; + } else if ( + !result.conflicts.includes(key) && + baseRevisions[key] === sent.baseRevisions[key] + ) { + baseRevisions[key] = result.profile.fieldRevisions[key] ?? 0; + } + } + this.conflicts = result.conflicts; + const profile = + result.profile.revision >= this.cache.profile.revision + ? result.profile + : this.cache.profile; + this.cache = { ...this.cache, profile, pending: { patch, baseRevisions } }; + await this.save(); + if (this.stopped) return; + await this.adapter.apply(this.values); + } + if (this.online && !this.stopped) this.status = "synced"; + } catch { + if (generation === this.connectionGeneration) this.fail(); + } finally { + this.flushing = false; + this.notify(); + if (generation !== this.connectionGeneration && this.online && !this.stopped) { + await this.flush(); + } + } + } +} + +export function createUiSettingsSyncAtoms( + runtime: Atom.AtomRuntime, +) { + return { + read: createEnvironmentRpcCommand(runtime, { + label: "ui-settings:read", + tag: WS_METHODS.uiSettingsGet, + }), + patch: createEnvironmentRpcCommand(runtime, { + label: "ui-settings:patch", + tag: WS_METHODS.uiSettingsPatch, + }), + changes: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "ui-settings:changes", + tag: WS_METHODS.uiSettingsSubscribe, + idleTtlMs: 0, + }), + }; +} diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index e85e5df7769c671849e4a8a622e272eb9b36a5ee..d1834503960c150503e57859a43451b01fc5b8e9 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -11,6 +11,7 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope, [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, // Endpoint checks exist to configure exports, so they need the same grant as saving them. [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 53170883dd55b58f3af179807ef1582b2c77b396..377f0bd0eb1ffca5871c9598149df92a3ee153a0 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -96,6 +96,7 @@ export const ServerSelfUpdateCapability = Schema.Literals([ export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; export const ExecutionEnvironmentCapabilities = Schema.Struct({ + uiSettingsSync: Schema.optionalKey(Schema.Boolean), repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), /** Missing on older servers, which still accept inline image attachments. */ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 62babcb97adcbcd785548c35aad78523b7bd284b..c265032c8116a0e45aee7e1fa2cd68e041898e05 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -67,3 +67,5 @@ export * from "./secretRequest.ts"; export * from "./clientRpcPermissions.ts"; export * from "./desktopTerminal.ts"; export * from "./scriptProgress.ts"; + +export * from "./uiSettings.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 822575df5a95047657a774630883c12b1fa14513..82ab52615f4cb7b9c78f87bb39dd962c11c54d57 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,3 +1,9 @@ +import { + UiSettingsProfile, + UiSettingsPatchInput, + UiSettingsPatchResult, + UiSettingsError, +} from "./uiSettings.ts"; import { ScriptProgressSubscribeInput, ScriptProgressEvent, @@ -490,6 +496,9 @@ export const WS_METHODS = { serverRemoveKeybinding: "server.removeKeybinding", serverRunStorageCleanup: "server.runStorageCleanup", serverGetStorageCleanupReport: "server.getStorageCleanupReport", + uiSettingsGet: "uiSettings.get", + uiSettingsPatch: "uiSettings.patch", + uiSettingsSubscribe: "uiSettings.subscribe", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -769,6 +778,23 @@ const WsServerGetStorageCleanupReportRpc = Rpc.make(WS_METHODS.serverGetStorageC error: EnvironmentAuthorizationError, }); +const WsUiSettingsGetRpc = Rpc.make(WS_METHODS.uiSettingsGet, { + payload: Schema.Struct({}), + success: UiSettingsProfile, + error: Schema.Union([UiSettingsError, EnvironmentAuthorizationError]), +}); +const WsUiSettingsPatchRpc = Rpc.make(WS_METHODS.uiSettingsPatch, { + payload: UiSettingsPatchInput, + success: UiSettingsPatchResult, + error: Schema.Union([UiSettingsError, EnvironmentAuthorizationError]), +}); +const WsUiSettingsSubscribeRpc = Rpc.make(WS_METHODS.uiSettingsSubscribe, { + payload: Schema.Struct({}), + success: UiSettingsProfile, + stream: true, + error: Schema.Union([UiSettingsError, EnvironmentAuthorizationError]), +}); + const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1936,6 +1962,9 @@ export const WsRpcGroup = RpcGroup.make( WsServerRemoveKeybindingRpc, WsServerRunStorageCleanupRpc, WsServerGetStorageCleanupReportRpc, + WsUiSettingsGetRpc, + WsUiSettingsPatchRpc, + WsUiSettingsSubscribeRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, diff --git a/packages/contracts/src/uiSettings.ts b/packages/contracts/src/uiSettings.ts new file mode 100644 index 0000000000000000000000000000000000000000..f50badffcc949052adb9b25010f2b8f08e8906f2 --- /dev/null +++ b/packages/contracts/src/uiSettings.ts @@ -0,0 +1,91 @@ +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import { ClientSettingsPatch } from "./settings.ts"; +import { EnvironmentTheme } from "./server.ts"; + +export const UiThemePreferences = Schema.Struct({ + selection: Schema.String.check(Schema.isMaxLength(128)), + mode: Schema.Literals(["system", "light", "dark"]), + halves: Schema.Struct({ + light: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))), + dark: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))), + }), + customThemes: Schema.Array( + Schema.Struct({ + ...EnvironmentTheme.fields, + colors: Schema.requiredKey(EnvironmentTheme.fields.colors), + collection: Schema.optionalKey(Schema.Struct({ id: Schema.String, label: Schema.String })), + managed: Schema.optionalKey(Schema.Boolean), + }), + ).check(Schema.isMaxLength(64)), +}); +export type UiThemePreferences = typeof UiThemePreferences.Type; + +/** Only portable presentation preferences belong in a personal profile. */ +export const UiSettingsValues = Schema.Struct({ + ...Struct.pick(ClientSettingsPatch.fields, [ + "diffColorScheme", + "chatWidth", + "appearanceContrast", + "panelAnimationDurationMs", + "diffFilesCollapsed", + "diffIgnoreWhitespace", + "diffLayout", + "environmentIdentificationMode", + "fontSizeInterface", + "fontSizePrompt", + "fontSizeCode", + "fontSizeTerminal", + "fontFamilyCode", + "fontFamilyComposer", + "fontFamilySans", + "fontFamilyTerminal", + "fontSmoothing", + "persistComposerContextStrip", + "contextWindowMeterEnabled", + "composerCollapseOnScroll", + "composerRichTextEnabled", + "sendShortcut", + "followUpBehavior", + "proactivePanelsEnabled", + "showSkillsInSlashMenu", + "sidebarWorkingShelfEnabled", + "sidebarProjectGroupingMode", + "sidebarProjectSortOrder", + "sidebarThreadSortOrder", + "sidebarThreadPreviewCount", + "timestampFormat", + "wordWrap", + ]), + theme: Schema.optionalKey(UiThemePreferences), +}); +export type UiSettingsValues = typeof UiSettingsValues.Type; + +const Revision = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); +export const UiSettingsProfile = Schema.Struct({ + revision: Revision, + values: UiSettingsValues, + fieldRevisions: Schema.Record(Schema.String, Revision), +}); +export type UiSettingsProfile = typeof UiSettingsProfile.Type; + +export const UiSettingsPatchInput = Schema.Struct({ + patch: UiSettingsValues, + baseRevisions: Schema.Record(Schema.String, Revision), +}); +export type UiSettingsPatchInput = typeof UiSettingsPatchInput.Type; + +export const UiSettingsPatchResult = Schema.Struct({ + profile: UiSettingsProfile, + conflicts: Schema.Array(Schema.String), +}); +export type UiSettingsPatchResult = typeof UiSettingsPatchResult.Type; + +export class UiSettingsError extends Schema.TaggedError()("UiSettingsError", { + operation: Schema.Literals(["read", "write"]), + cause: Schema.Defect(), +}) { + override get message(): string { + return "Unable to sync UI settings. Reconnect to the profile environment to try again."; + } +}