From a4d2ec0fc271b30cf89a4a5153c9b6cf44a986ca Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 21:26:30 -0700 Subject: [PATCH] feat: sync personal UI preferences through a chosen environment Let Clover set up personal UI preferences once and opt paired clients into the same profile on a chosen private environment. Read before publishing or adopting, merge per-field revisions, retain deliberate offline edits, and keep local preferences when sync is disabled or the host changes. Web and desktop share themes, custom palettes, fonts, and presentation preferences. Native mobile shares follow-up behavior, project grouping, and the working shelf while keeping its appearance local. Existing model preferences, keybindings, credentials, permissions, and device integrations keep their current owners. Assisted-by: gpt-6.1-sol --- apps/mobile/src/App.tsx | 5 +- .../SettingsAppearanceRouteScreen.tsx | 2 + .../settings/UiSettingsSyncSection.tsx | 163 ++++++++++++ apps/mobile/src/lib/followUpBehavior.ts | 4 - apps/mobile/src/lib/storage.test.ts | 18 ++ apps/mobile/src/lib/uiSettingsSync.test.ts | 41 +++ apps/mobile/src/lib/uiSettingsSync.ts | 28 +++ .../src/persistence/mobile-preferences.ts | 8 + apps/mobile/src/state/preferences.ts | 27 +- apps/mobile/src/state/uiSettingsSync.ts | 206 +++++++++++++++ apps/server/src/auth/RpcAuthorization.test.ts | 8 + apps/server/src/auth/RpcAuthorization.ts | 2 + .../src/environment/ServerEnvironment.ts | 1 + .../src/observability/RpcInstrumentation.ts | 3 + apps/server/src/server.ts | 10 +- apps/server/src/uiSettings.test.ts | 113 +++++++++ apps/server/src/uiSettings.ts | 98 ++++++++ apps/server/src/ws.ts | 5 + .../components/settings/SettingsPanels.tsx | 2 + .../settings/UiSettingsSyncSection.tsx | 184 ++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 + apps/web/src/hooks/useDefaultTheme.test.ts | 4 + apps/web/src/hooks/useDefaultTheme.ts | 11 + apps/web/src/hooks/useSettings.ts | 14 ++ apps/web/src/hooks/useTheme.ts | 42 ++++ apps/web/src/routes/__root.tsx | 2 + apps/web/src/state/uiSettingsSync.test.tsx | 210 ++++++++++++++++ apps/web/src/state/uiSettingsSync.ts | 220 ++++++++++++++++ apps/web/src/themePalette.ts | 48 ++++ docs/user/appearance.md | 20 +- packages/client-runtime/package.json | 4 + packages/client-runtime/src/rpc/client.ts | 1 + .../src/state/commandPermissions.test.ts | 24 ++ .../src/state/uiSettingsSync.test.ts | 237 ++++++++++++++++++ .../src/state/uiSettingsSync.ts | 209 +++++++++++++++ .../contracts/src/clientRpcPermissions.ts | 1 + packages/contracts/src/environment.ts | 1 + packages/contracts/src/index.ts | 2 + packages/contracts/src/rpc.ts | 29 +++ packages/contracts/src/uiSettings.ts | 91 +++++++ 40 files changed, 2087 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/features/settings/UiSettingsSyncSection.tsx create mode 100644 apps/mobile/src/lib/uiSettingsSync.test.ts create mode 100644 apps/mobile/src/lib/uiSettingsSync.ts create mode 100644 apps/mobile/src/state/uiSettingsSync.ts create mode 100644 apps/server/src/uiSettings.test.ts create mode 100644 apps/server/src/uiSettings.ts create mode 100644 apps/web/src/components/settings/UiSettingsSyncSection.tsx create mode 100644 apps/web/src/state/uiSettingsSync.test.tsx create mode 100644 apps/web/src/state/uiSettingsSync.ts create mode 100644 packages/client-runtime/src/state/uiSettingsSync.test.ts create mode 100644 packages/client-runtime/src/state/uiSettingsSync.ts create mode 100644 packages/contracts/src/uiSettings.ts 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."; + } +} -- 2.54.0