authorclover caruso <git@paperclover.net> 2026-10-15 21:26:30-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
loga4d2ec0fc271b30cf89a4a5153c9b6cf44a986ca
tree04856b0a706245be4e8390a41a80c275dab22a11
parent1af2d44259972f472c2e230e50e41d841169aab4
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

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

40 files changed, 2087 insertions(+), 17 deletions(-)

apps/mobile/src/App.tsx+4-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { UiSettingsSyncProvider } from "./state/uiSettingsSync";
1import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice";2import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice";
2import * as Linking from "expo-linking";3import * as Linking from "expo-linking";
3import * as SplashScreen from "expo-splash-screen";4import * as SplashScreen from "expo-splash-screen";
...@@ -65,7 +66,9 @@ export default function App() {...@@ -65,7 +66,9 @@ export default function App() {
65 <RegistryContext.Provider value={appAtomRegistry}>66 <RegistryContext.Provider value={appAtomRegistry}>
66 <CloudAuthProvider>67 <CloudAuthProvider>
67 <AppearancePreferencesProvider>68 <AppearancePreferencesProvider>
68 <AppContent />69 <UiSettingsSyncProvider>
70 <AppContent />
71 </UiSettingsSyncProvider>
69 </AppearancePreferencesProvider>72 </AppearancePreferencesProvider>
70 </CloudAuthProvider>73 </CloudAuthProvider>
71 </RegistryContext.Provider>74 </RegistryContext.Provider>
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { UiSettingsSyncSection } from "./UiSettingsSyncSection";
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";2import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { useSafeAreaInsets } from "react-native-safe-area-context";3import { useSafeAreaInsets } from "react-native-safe-area-context";
34
...@@ -21,6 +22,7 @@ export function SettingsAppearanceRouteScreen() {...@@ -21,6 +22,7 @@ export function SettingsAppearanceRouteScreen() {
21 paddingBottom: Math.max(insets.bottom, 18) + 18,22 paddingBottom: Math.max(insets.bottom, 18) + 18,
22 }}23 }}
23 >24 >
25 <UiSettingsSyncSection />
24 <ThemeAppearanceSection />26 <ThemeAppearanceSection />
25 <TextAppearanceSection />27 <TextAppearanceSection />
26 <TerminalAppearanceSection />28 <TerminalAppearanceSection />
apps/mobile/src/features/settings/UiSettingsSyncSection.tsx created+163
...@@ -0,0 +1,163 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/reactivity";
3import { useState } from "react";
4import { View } from "react-native";
5import type { EnvironmentId, UiSettingsProfile } from "@t3tools/contracts";
6import { AppText as Text } from "../../components/AppText";
7import { useEnvironments } from "../../state/environments";
8import { useAtomCommand } from "../../state/use-atom-command";
9import {
10 uiSettingsSyncAtoms,
11 useUiSettingsSync,
12 useUiSettingsSyncActions,
13} from "../../state/uiSettingsSync";
14import { SettingsSection } from "./components/SettingsSection";
15import { SettingsActionRow } from "./components/SettingsActionRow";
16import { SettingsChoiceRow } from "./components/SettingsChoiceRow";
17
18export function UiSettingsSyncSection() {
19 const { environments } = useEnvironments();
20 const sync = useUiSettingsSync();
21 const actions = useUiSettingsSyncActions();
22 const [selected, setSelected] = useState<EnvironmentId | null>(null);
23 const [saved, setSaved] = useState<UiSettingsProfile | null>(null);
24 const [busy, setBusy] = useState(false);
25 const [error, setError] = useState<string | null>(null);
26 const read = useAtomCommand(uiSettingsSyncAtoms.read, { reportFailure: false });
27 const permission = useAtomValue(uiSettingsSyncAtoms.patch.permissionAtom(selected));
28 const eligible = environments.filter(
29 (entry) =>
30 entry.connection.phase === "connected" &&
31 entry.serverConfig?.environment.capabilities.uiSettingsSync === true,
32 );
33 const active = environments.find((entry) => entry.environmentId === sync.environmentId);
34
35 async function inspect() {
36 if (!selected) return;
37 setBusy(true);
38 setError(null);
39 const result = await read({ environmentId: selected, input: {} });
40 if (AsyncResult.isSuccess(result)) setSaved(result.value);
41 else setError("Unable to read the profile. Reconnect to this environment and try again.");
42 setBusy(false);
43 }
44 async function enable(mode: "adopt" | "publish") {
45 if (!selected || !saved) return;
46 setBusy(true);
47 setError(null);
48 try {
49 await actions.enable(selected, saved, mode);
50 setSaved(null);
51 setSelected(null);
52 } catch {
53 setError("Unable to enable sync. Check device storage and try again.");
54 } finally {
55 setBusy(false);
56 }
57 }
58 return (
59 <View className="gap-3">
60 <SettingsSection
61 title="UI settings sync"
62 footer="Sync follow-ups, project grouping, and the working shelf. Native themes and text sizes stay on this device."
63 >
64 {eligible.map((entry, index) => (
65 <SettingsChoiceRow
66 key={entry.environmentId}
67 label={entry.label}
68 selected={selected === entry.environmentId}
69 separated={index > 0}
70 disabled={busy}
71 onPress={() => {
72 setSelected(entry.environmentId);
73 setSaved(null);
74 setError(null);
75 }}
76 />
77 ))}
78 <SettingsActionRow
79 icon="arrow.clockwise"
80 label="Read profile"
81 disabled={!selected || !permission || busy}
82 loading={busy}
83 onPress={() => void inspect()}
84 />
85 {saved && saved.revision > 0 ? (
86 <SettingsActionRow
87 icon="checkmark"
88 label="Use saved"
89 disabled={busy}
90 onPress={() => void enable("adopt")}
91 />
92 ) : null}
93 {saved ? (
94 <SettingsActionRow
95 icon="arrow.up"
96 label="Publish current"
97 disabled={busy || !permission}
98 onPress={() => void enable("publish")}
99 />
100 ) : null}
101 {saved ? (
102 <SettingsActionRow
103 icon="xmark"
104 label="Cancel"
105 disabled={busy}
106 onPress={() => setSaved(null)}
107 />
108 ) : null}
109 {sync.environmentId ? (
110 <SettingsActionRow
111 icon="xmark"
112 label="Disable sync"
113 disabled={busy}
114 onPress={() => {
115 void actions
116 .disable()
117 .catch(() =>
118 setError("Unable to disable sync. Check device storage and try again."),
119 );
120 }}
121 />
122 ) : null}
123 </SettingsSection>
124 <Text accessibilityLiveRegion="polite" className="px-2 text-sm text-foreground-muted">
125 {sync.environmentId === null
126 ? "Off"
127 : sync.status === "synced"
128 ? `Following ${active?.label ?? "your saved environment"}`
129 : sync.status === "syncing"
130 ? "Syncing…"
131 : "Sync paused. Changes stay on this device until the profile environment reconnects."}
132 </Text>
133 {saved ? (
134 <Text className="px-2 text-sm text-foreground-muted">
135 {saved.revision === 0
136 ? "No profile saved. Publish this device’s current preferences to start."
137 : "Use the saved profile, or publish this device’s shared preferences for other participating devices."}
138 </Text>
139 ) : null}
140 {eligible.length === 0 ? (
141 <Text className="px-2 text-sm text-foreground-muted">
142 Connect to an updated private environment to set up sync.
143 </Text>
144 ) : null}
145 {selected && !permission ? (
146 <Text className="px-2 text-sm text-foreground-muted">
147 Settings access is required. Update this connection’s permissions in Connections.
148 </Text>
149 ) : null}
150 {sync.conflicts.length > 0 ? (
151 <Text accessibilityLiveRegion="polite" className="px-2 text-sm text-foreground-muted">
152 Newer saved preferences replaced conflicting edits. Change a preference again to use your
153 choice.
154 </Text>
155 ) : null}
156 {error ? (
157 <Text accessibilityRole="alert" className="px-2 text-sm text-danger-foreground">
158 {error}
159 </Text>
160 ) : null}
161 </View>
162 );
163}
apps/mobile/src/lib/followUpBehavior.ts-4
...@@ -3,10 +3,6 @@ import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/com...@@ -3,10 +3,6 @@ import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/com
3/**3/**
4 * What the send button does while a turn is already running: `queue` waits for4 * What the send button does while a turn is already running: `queue` waits for
5 * the turn to finish, `steer` interrupts it with the new message.5 * the turn to finish, `steer` interrupts it with the new message.
6 *
7 * Web keeps the same choice in its per-client settings. Mobile has no
8 * client-settings sync, so it is stored per device alongside the other
9 * composer preferences.
10 */6 */
11export type FollowUpBehavior = Extract<ActiveTurnComposerAction, "queue" | "steer">;7export type FollowUpBehavior = Extract<ActiveTurnComposerAction, "queue" | "steer">;
128
apps/mobile/src/lib/storage.test.ts+18
...@@ -232,6 +232,24 @@ describe("mobile connection storage", () => {...@@ -232,6 +232,24 @@ describe("mobile connection storage", () => {
232 expect(fallback.updatedAt).toEqual(expect.any(Number));232 expect(fallback.updatedAt).toEqual(expect.any(Number));
233 });233 });
234234
235 it("keeps the profile host and offline edits across reloads, then persists disabling sync", async () => {
236 const uiSettingsSync = {
237 environmentId: EnvironmentId.make("personal"),
238 profile: { revision: 3, values: { fontSizeCode: 17 }, fieldRevisions: { fontSizeCode: 3 } },
239 pending: {
240 patch: { followUpBehavior: "steer" as const },
241 baseRevisions: { followUpBehavior: 0 },
242 },
243 };
244 await savePreferencesPatch({ uiSettingsSync, themeId: "material-you" });
245 await expect(loadPreferences()).resolves.toEqual({ uiSettingsSync, themeId: "material-you" });
246 await savePreferencesPatch({ uiSettingsSync: null });
247 await expect(loadPreferences()).resolves.toEqual({
248 uiSettingsSync: null,
249 themeId: "material-you",
250 });
251 });
252
235 it("persists thread list shelf preferences", async () => {253 it("persists thread list shelf preferences", async () => {
236 await expect(254 await expect(
237 savePreferencesPatch({255 savePreferencesPatch({
apps/mobile/src/lib/uiSettingsSync.test.ts created+41
...@@ -0,0 +1,41 @@
1import { describe, expect, it } from "vite-plus/test";
2import { portableMobileUiSettings, mobileUiSettingsPatch } from "./uiSettingsSync";
3
4describe("native UI profile portability", () => {
5 it("maps shared controls without publishing device defaults, native fonts, or integration state", () => {
6 expect(portableMobileUiSettings({})).toEqual({});
7 expect(
8 portableMobileUiSettings({
9 followUpBehavior: "steer",
10 projectGroupingMode: "repository_path",
11 workingShelfEnabled: true,
12 themeId: "material-you",
13 baseFontSize: 22,
14 terminalFontSize: 10.5,
15 microphones: [],
16 liveActivitiesEnabled: true,
17 }),
18 ).toEqual({
19 followUpBehavior: "steer",
20 sidebarProjectGroupingMode: "repository_path",
21 sidebarWorkingShelfEnabled: true,
22 });
23 });
24 it("applies shared controls without changing native appearance or dropping desktop profile fields", () => {
25 expect(
26 mobileUiSettingsPatch({
27 fontSizeCode: 18,
28 fontFamilySans: "Example",
29 wordWrap: true,
30 theme: { selection: "custom", mode: "dark", halves: {}, customThemes: [] },
31 followUpBehavior: "queue",
32 sidebarProjectGroupingMode: "separate",
33 sidebarWorkingShelfEnabled: false,
34 }),
35 ).toEqual({
36 followUpBehavior: "queue",
37 projectGroupingMode: "separate",
38 workingShelfEnabled: false,
39 });
40 });
41});
apps/mobile/src/lib/uiSettingsSync.ts created+28
...@@ -0,0 +1,28 @@
1import type { UiSettingsValues } from "@t3tools/contracts";
2import type { Preferences } from "../persistence/mobile-preferences";
3
4/** Native typography and palettes have their own units and catalog; only equivalent controls travel. */
5export function portableMobileUiSettings(preferences: Preferences): UiSettingsValues {
6 return {
7 ...(preferences.followUpBehavior === undefined
8 ? {}
9 : { followUpBehavior: preferences.followUpBehavior }),
10 ...(preferences.workingShelfEnabled === undefined
11 ? {}
12 : { sidebarWorkingShelfEnabled: preferences.workingShelfEnabled }),
13 ...(preferences.projectGroupingMode === undefined
14 ? {}
15 : { sidebarProjectGroupingMode: preferences.projectGroupingMode }),
16 };
17}
18export function mobileUiSettingsPatch(values: UiSettingsValues): Partial<Preferences> {
19 return {
20 ...(values.followUpBehavior === undefined ? {} : { followUpBehavior: values.followUpBehavior }),
21 ...(values.sidebarWorkingShelfEnabled === undefined
22 ? {}
23 : { workingShelfEnabled: values.sidebarWorkingShelfEnabled }),
24 ...(values.sidebarProjectGroupingMode === undefined
25 ? {}
26 : { projectGroupingMode: values.sidebarProjectGroupingMode }),
27 };
28}
apps/mobile/src/persistence/mobile-preferences.ts+8
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { UiSettingsSyncCache } from "@t3tools/client-runtime/state/ui-settings-sync";
1import * as Context from "effect/Context";2import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -49,10 +50,12 @@ const PullRequestListPreferences = Schema.Struct({...@@ -49,10 +50,12 @@ const PullRequestListPreferences = Schema.Struct({
49});50});
50export type PullRequestListPreferences = typeof PullRequestListPreferences.Type;51export type PullRequestListPreferences = typeof PullRequestListPreferences.Type;
5152
53const isUiSettingsSyncCache = Schema.is(UiSettingsSyncCache);
52const isThreadListFilters = Schema.is(ThreadListFilters);54const isThreadListFilters = Schema.is(ThreadListFilters);
53const isPullRequestListPreferences = Schema.is(PullRequestListPreferences);55const isPullRequestListPreferences = Schema.is(PullRequestListPreferences);
5456
55export interface Preferences {57export interface Preferences {
58 readonly uiSettingsSync?: UiSettingsSyncCache | null;
56 readonly pullRequestHideTestFiles?: boolean;59 readonly pullRequestHideTestFiles?: boolean;
57 readonly pullRequestMergeMethod?: PullRequestMergeMethod;60 readonly pullRequestMergeMethod?: PullRequestMergeMethod;
58 readonly liveActivitiesEnabled?: boolean;61 readonly liveActivitiesEnabled?: boolean;
...@@ -136,6 +139,7 @@ export class MobilePreferencesStore extends Context.Service<...@@ -136,6 +139,7 @@ export class MobilePreferencesStore extends Context.Service<
136139
137function sanitizePreferences(parsed: Preferences): Preferences {140function sanitizePreferences(parsed: Preferences): Preferences {
138 const preferences: {141 const preferences: {
142 uiSettingsSync?: UiSettingsSyncCache | null;
139 pullRequestHideTestFiles?: boolean;143 pullRequestHideTestFiles?: boolean;
140 pullRequestMergeMethod?: PullRequestMergeMethod;144 pullRequestMergeMethod?: PullRequestMergeMethod;
141 liveActivitiesEnabled?: boolean;145 liveActivitiesEnabled?: boolean;
...@@ -283,6 +287,10 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -283,6 +287,10 @@ function sanitizePreferences(parsed: Preferences): Preferences {
283 if (isPullRequestListPreferences(parsed.pullRequestListFilters)) {287 if (isPullRequestListPreferences(parsed.pullRequestListFilters)) {
284 preferences.pullRequestListFilters = parsed.pullRequestListFilters;288 preferences.pullRequestListFilters = parsed.pullRequestListFilters;
285 }289 }
290 if (parsed.uiSettingsSync === null || isUiSettingsSyncCache(parsed.uiSettingsSync)) {
291 preferences.uiSettingsSync = parsed.uiSettingsSync;
292 }
293
286 return preferences;294 return preferences;
287}295}
288296
apps/mobile/src/state/preferences.ts+20-7
...@@ -9,9 +9,19 @@ interface OptimisticPreferences {...@@ -9,9 +9,19 @@ interface OptimisticPreferences {
9 readonly versions: Partial<Record<keyof MobilePreferences.Preferences, number>>;9 readonly versions: Partial<Record<keyof MobilePreferences.Preferences, number>>;
10}10}
1111
12// A bare function is interpreted by useAtomSet as an update to the command's12const editListeners = new Set<(patch: Partial<MobilePreferences.Preferences>) => void>();
13// AsyncResult, not as a preference transform. Keep transforms inside a payload.13export function subscribeMobilePreferenceEdits(
14 listener: (patch: Partial<MobilePreferences.Preferences>) => void,
15) {
16 editListeners.add(listener);
17 return () => {
18 editListeners.delete(listener);
19 };
20}
21
22// A bare function updates the command result; preference transforms need a payload.
14type PreferencesUpdate =23type PreferencesUpdate =
24 | { readonly patch: Partial<MobilePreferences.Preferences>; readonly source: "sync" }
15 | Partial<MobilePreferences.Preferences>25 | Partial<MobilePreferences.Preferences>
16 | {26 | {
17 readonly transform: (27 readonly transform: (
...@@ -66,11 +76,14 @@ export function createMobilePreferencesState(...@@ -66,11 +76,14 @@ export function createMobilePreferencesState(
66 (update: PreferencesUpdate, get) => {76 (update: PreferencesUpdate, get) => {
67 const currentPreferences = get(preferencesAtom);77 const currentPreferences = get(preferencesAtom);
68 const patch =78 const patch =
69 "transform" in update79 "source" in update
70 ? update.transform(80 ? update.patch
71 AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {},81 : "transform" in update
72 )82 ? update.transform(
73 : update;83 AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {},
84 )
85 : update;
86 if (!("source" in update)) for (const listener of editListeners) listener(patch);
74 const version = ++nextPatchVersion;87 const version = ++nextPatchVersion;
75 const current = get(optimisticPatchAtom);88 const current = get(optimisticPatchAtom);
76 const versions = { ...current.versions };89 const versions = { ...current.versions };
apps/mobile/src/state/uiSettingsSync.ts created+206
...@@ -0,0 +1,206 @@
1import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react";
2import {
3 UiSettingsSync,
4 createUiSettingsSyncAtoms,
5 type UiSettingsSyncCache,
6} from "@t3tools/client-runtime/state/ui-settings-sync";
7import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
8import type { EnvironmentId, UiSettingsProfile } from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import { AsyncResult } from "effect/reactivity";
11import {
12 createContext,
13 createElement,
14 use,
15 useEffect,
16 useCallback,
17 useRef,
18 useSyncExternalStore,
19 type PropsWithChildren,
20} from "react";
21import { portableMobileUiSettings, mobileUiSettingsPatch } from "../lib/uiSettingsSync";
22import { connectionAtomRuntime } from "../connection/runtime";
23import {
24 mobilePreferencesAtom,
25 updateMobilePreferencesAtom,
26 subscribeMobilePreferenceEdits,
27} from "./preferences";
28import { useEnvironments } from "./environments";
29import { resolveMobileProjectGroupingSettings } from "./project-grouping";
30
31export const uiSettingsSyncAtoms = createUiSettingsSyncAtoms(connectionAtomRuntime);
32const listeners = new Set<() => void>();
33let state: {
34 controller: UiSettingsSync | null;
35 environmentId: EnvironmentId | null;
36 status: "disabled" | UiSettingsSync["snapshot"]["status"];
37 conflicts: UiSettingsSync["snapshot"]["conflicts"];
38} = {
39 controller: null,
40 environmentId: null,
41 status: "disabled",
42 conflicts: [],
43};
44function notify() {
45 for (const listener of listeners) listener();
46}
47export function useUiSettingsSync() {
48 return useSyncExternalStore(
49 (listener) => {
50 listeners.add(listener);
51 return () => {
52 listeners.delete(listener);
53 };
54 },
55 () => state,
56 );
57}
58
59function usePersonalUiSettingsSync() {
60 const registry = use(RegistryContext);
61 const hydrated = useRef(false);
62 const preferences = useAtomValue(mobilePreferencesAtom);
63 const save = useAtomSet(updateMobilePreferencesAtom, { mode: "promise" });
64 const sync = useUiSettingsSync();
65 const { environments } = useEnvironments();
66 const host = environments.find((entry) => entry.environmentId === sync.environmentId);
67 const activeController = sync.controller;
68 const connected =
69 host?.connection.phase === "connected" &&
70 host.serverConfig?.environment.capabilities.uiSettingsSync === true;
71
72 const install = useCallback(
73 (cache: UiSettingsSyncCache) => {
74 const next = new UiSettingsSync(cache, {
75 save: async (value) => {
76 await save({ patch: { uiSettingsSync: value }, source: "sync" });
77 },
78 apply: async (values) => {
79 await save({ patch: mobileUiSettingsPatch(values), source: "sync" });
80 },
81 patch: async (input) => {
82 const result = await runAtomCommand(
83 registry,
84 uiSettingsSyncAtoms.patch,
85 { environmentId: cache.environmentId, input },
86 { reportFailure: false },
87 );
88 if (AsyncResult.isFailure(result)) throw Cause.squash(result.cause);
89 return result.value;
90 },
91 });
92 state = { ...state, controller: next };
93 const update = () => {
94 if (state.controller !== next) return;
95 state = { controller: next, environmentId: cache.environmentId, ...next.snapshot };
96 notify();
97 };
98 next.subscribe(update);
99 update();
100 return next;
101 },
102 [registry, save],
103 );
104
105 useEffect(() => {
106 if (hydrated.current || !AsyncResult.isSuccess(preferences) || preferences.waiting) return;
107 hydrated.current = true;
108 if (!state.controller && preferences.value.uiSettingsSync) {
109 void install(preferences.value.uiSettingsSync).restore();
110 }
111 }, [install, preferences]);
112
113 useEffect(
114 () =>
115 subscribeMobilePreferenceEdits((patch) =>
116 state.controller?.edit(portableMobileUiSettings(patch)),
117 ),
118 [],
119 );
120 useEffect(() => {
121 const current = activeController;
122 if (!current || !sync.environmentId) return;
123 if (!connected) {
124 current.disconnect();
125 return;
126 }
127 let disposed = false;
128 let unsubscribe = () => {};
129 const target = { environmentId: sync.environmentId, input: {} };
130 void runAtomCommand(registry, uiSettingsSyncAtoms.read, target, { reportFailure: false }).then(
131 async (result) => {
132 if (disposed) return;
133 if (AsyncResult.isFailure(result)) {
134 current.disconnect();
135 return;
136 }
137 await current.receive(result.value);
138 if (disposed) return;
139 unsubscribe = registry.subscribe(
140 uiSettingsSyncAtoms.changes(target),
141 (update) => {
142 if (AsyncResult.isSuccess(update)) void current.receive(update.value);
143 else if (AsyncResult.isFailure(update)) current.disconnect();
144 },
145 { immediate: true },
146 );
147 },
148 );
149 return () => {
150 disposed = true;
151 unsubscribe();
152 current.disconnect();
153 };
154 }, [activeController, connected, registry, sync.environmentId]);
155
156 return {
157 enable: async (
158 environmentId: EnvironmentId,
159 profile: UiSettingsProfile,
160 mode: "adopt" | "publish",
161 ) => {
162 if (!AsyncResult.isSuccess(preferences) || preferences.waiting)
163 throw new Error("Preferences are still loading.");
164 const cache: UiSettingsSyncCache = {
165 environmentId,
166 profile,
167 pending: { patch: {}, baseRevisions: {} },
168 };
169 await state.controller?.stop();
170 await save({ patch: { uiSettingsSync: cache }, source: "sync" });
171 const next = install(cache);
172 if (mode === "publish")
173 next.edit(
174 portableMobileUiSettings({
175 ...preferences.value,
176 followUpBehavior: preferences.value.followUpBehavior ?? "queue",
177 workingShelfEnabled: preferences.value.workingShelfEnabled ?? false,
178 projectGroupingMode: resolveMobileProjectGroupingSettings(preferences.value)
179 .sidebarProjectGroupingMode,
180 }),
181 );
182 await next.receive(profile);
183 },
184 disable: async () => {
185 const previous = state.controller;
186 state = { ...state, controller: null };
187 await previous?.stop();
188 await save({ patch: { uiSettingsSync: null }, source: "sync" });
189 state = { controller: null, environmentId: null, status: "disabled", conflicts: [] };
190 notify();
191 },
192 };
193}
194
195const UiSettingsSyncContext = createContext<ReturnType<typeof usePersonalUiSettingsSync> | null>(
196 null,
197);
198export function UiSettingsSyncProvider({ children }: PropsWithChildren) {
199 const actions = usePersonalUiSettingsSync();
200 return createElement(UiSettingsSyncContext.Provider, { value: actions }, children);
201}
202export function useUiSettingsSyncActions() {
203 const actions = use(UiSettingsSyncContext);
204 if (!actions) throw new Error("UI settings sync is unavailable.");
205 return actions;
206}
apps/server/src/auth/RpcAuthorization.test.ts+8
...@@ -32,6 +32,14 @@ import {...@@ -32,6 +32,14 @@ import {
32import * as RpcAuthorization from "./RpcAuthorization.ts";32import * as RpcAuthorization from "./RpcAuthorization.ts";
3333
34describe("RPC authorization scopes", () => {34describe("RPC authorization scopes", () => {
35 it("reads personal UI profiles under read access and protects writes", () => {
36 expect(requiredScopeForRpcMethod(WS_METHODS.uiSettingsGet)).toBe(AuthOrchestrationReadScope);
37 expect(requiredScopeForRpcMethod(WS_METHODS.uiSettingsSubscribe)).toBe(
38 AuthOrchestrationReadScope,
39 );
40 expect(requiredScopeForRpcMethod(WS_METHODS.uiSettingsPatch)).toBe(AuthSettingsWriteScope);
41 });
42
35 it("requires filesystem read permission to watch file and folder changes", () => {43 it("requires filesystem read permission to watch file and folder changes", () => {
36 expect(requiredScopeForRpcMethod(WS_METHODS.projectsWatch)).toBe(AuthFilesystemReadScope);44 expect(requiredScopeForRpcMethod(WS_METHODS.projectsWatch)).toBe(AuthFilesystemReadScope);
37 });45 });
apps/server/src/auth/RpcAuthorization.ts+2
...@@ -44,6 +44,8 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];...@@ -44,6 +44,8 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
44 */44 */
45export const RPC_REQUIRED_SCOPES = {45export const RPC_REQUIRED_SCOPES = {
46 ...CLIENT_GUARDED_RPC_SCOPES,46 ...CLIENT_GUARDED_RPC_SCOPES,
47 [WS_METHODS.uiSettingsGet]: AuthOrchestrationReadScope,
48 [WS_METHODS.uiSettingsSubscribe]: AuthOrchestrationReadScope,
47 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,49 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
48 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,50 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
49 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,51 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -226,6 +226,7 @@ export const make = Effect.gen(function* () {...@@ -226,6 +226,7 @@ export const make = Effect.gen(function* () {
226 inlineMessageContext: true,226 inlineMessageContext: true,
227 requiredWorktreeBootstrap: true,227 requiredWorktreeBootstrap: true,
228 threadSettlement: true,228 threadSettlement: true,
229 uiSettingsSync: true,
229 threadAutoSettlement: true,230 threadAutoSettlement: true,
230 storageCleanup: true,231 storageCleanup: true,
231 storageCleanupRun: true,232 storageCleanupRun: true,
apps/server/src/observability/RpcInstrumentation.ts+3
...@@ -15,6 +15,9 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];...@@ -15,6 +15,9 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
15 * prefix. Adding an RPC to `WsRpcGroup` without a label is a type error.15 * prefix. Adding an RPC to `WsRpcGroup` without a label is a type error.
16 */16 */
17const RPC_AGGREGATES = {17const RPC_AGGREGATES = {
18 [WS_METHODS.uiSettingsGet]: "settings",
19 [WS_METHODS.uiSettingsPatch]: "settings",
20 [WS_METHODS.uiSettingsSubscribe]: "settings",
18 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2",21 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2",
19 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration",22 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration",
20 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration",23 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration",
apps/server/src/server.ts+7-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as UiSettings from "./uiSettings.ts";
1import * as ScriptProgress from "./progress/ScriptProgress.ts";2import * as ScriptProgress from "./progress/ScriptProgress.ts";
2import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";3import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
3import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";4import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
...@@ -602,10 +603,13 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(...@@ -602,10 +603,13 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
602 Layer.provideMerge(layerVcs),603 Layer.provideMerge(layerVcs),
603 Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)),604 Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)),
604 Layer.provideMerge(layerPersistence),605 Layer.provideMerge(layerPersistence),
605 // Both read a user-owned file out of the state directory and stream changes
606 // to clients; neither depends on the other.
607 Layer.provideMerge(606 Layer.provideMerge(
608 Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer),607 Layer.mergeAll(
608 Keybindings.layer,
609 EnvironmentTheme.layer,
610 UsageLimitSources.layer,
611 UiSettings.layer,
612 ),
609 ),613 ),
610 Layer.provideMerge(ProviderRegistry.layer),614 Layer.provideMerge(ProviderRegistry.layer),
611 // The instance registry is the new routing keystone — text generation,615 // The instance registry is the new routing keystone — text generation,
apps/server/src/uiSettings.test.ts created+113
...@@ -0,0 +1,113 @@
1import { UiSettingsProfile } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import * as ServerConfig from "./config.ts";
10import * as UiSettings from "./uiSettings.ts";
11
12const encodeProfile = Schema.encodeEffect(Schema.fromJsonString(UiSettingsProfile));
13const decodeProfile = Schema.decodeUnknownEffect(Schema.fromJsonString(UiSettingsProfile));
14
15it.layer(NodeServices.layer)("personal UI settings", (it) => {
16 const service = UiSettings.layer.pipe(
17 Layer.provideMerge(
18 ServerConfig.layerTest(process.cwd(), {
19 prefix: "knife-ui-settings-",
20 }),
21 ),
22 );
23
24 it.effect("restores a saved profile and refuses to replace unreadable saved preferences", () =>
25 Effect.gen(function* () {
26 const fs = yield* FileSystem.FileSystem;
27 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "knife-ui-settings-restart-" });
28 const config = ServerConfig.layerTest(process.cwd(), directory);
29 yield* Effect.gen(function* () {
30 const { stateDir } = yield* ServerConfig.ServerConfig;
31 yield* fs.makeDirectory(stateDir, { recursive: true });
32 const saved: UiSettingsProfile = {
33 revision: 8,
34 values: { fontSizeCode: 18 },
35 fieldRevisions: { fontSizeCode: 8 },
36 };
37 const encoded = yield* encodeProfile(saved);
38 yield* fs.writeFileString(`${stateDir}/ui-settings.json`, encoded);
39 const read = UiSettings.UiSettings.pipe(Effect.flatMap((service) => service.current));
40 assert.deepEqual(yield* read.pipe(Effect.provide(Layer.fresh(UiSettings.layer))), saved);
41 yield* fs.writeFileString(`${stateDir}/ui-settings.json`, "broken");
42 const failed = yield* UiSettings.UiSettings.pipe(
43 Effect.flatMap((service) =>
44 service.patch({ patch: { chatWidth: "full" }, baseRevisions: { chatWidth: 0 } }),
45 ),
46 Effect.flip,
47 Effect.provide(Layer.fresh(UiSettings.layer)),
48 );
49 assert.strictEqual(failed.operation, "read");
50 assert.strictEqual(yield* fs.readFileString(`${stateDir}/ui-settings.json`), "broken");
51 }).pipe(Effect.provide(config));
52 }).pipe(Effect.scoped),
53 );
54
55 it.effect("merges independent client edits and rejects stale edits to the same field", () =>
56 Effect.gen(function* () {
57 const settings = yield* UiSettings.UiSettings;
58 const first = yield* settings.current;
59 const second = yield* settings.current;
60 const font = yield* settings.patch({
61 patch: { fontSizeCode: 15 },
62 baseRevisions: { fontSizeCode: first.fieldRevisions.fontSizeCode ?? 0 },
63 });
64 const chat = yield* settings.patch({
65 patch: { chatWidth: "full" },
66 baseRevisions: { chatWidth: second.fieldRevisions.chatWidth ?? 0 },
67 });
68 assert.deepEqual(chat.profile.values, { fontSizeCode: 15, chatWidth: "full" });
69 const stale = yield* settings.patch({
70 patch: { fontSizeCode: 13, timestampFormat: "12-hour" },
71 baseRevisions: { fontSizeCode: 0, timestampFormat: 0 },
72 });
73 assert.deepEqual(stale.conflicts, ["fontSizeCode"]);
74 assert.strictEqual(stale.profile.values.fontSizeCode, 15);
75 assert.strictEqual(stale.profile.values.timestampFormat, "12-hour");
76 assert.strictEqual(stale.profile.fieldRevisions.fontSizeCode, font.profile.revision);
77 const { stateDir } = yield* ServerConfig.ServerConfig;
78 const fs = yield* FileSystem.FileSystem;
79 const persisted = yield* fs.readFileString(`${stateDir}/ui-settings.json`);
80 const decoded = yield* decodeProfile(persisted);
81 assert.deepEqual(decoded, stale.profile);
82 }).pipe(Effect.provide(service)),
83 );
84
85 it.effect("streams the initial profile and delivers an update to a second client", () =>
86 Effect.gen(function* () {
87 const settings = yield* UiSettings.UiSettings;
88 const received = yield* settings.changes.pipe(
89 Stream.take(2),
90 Stream.tap((profile) =>
91 profile.revision === 0
92 ? settings.patch({ patch: { chatWidth: "wide" }, baseRevisions: { chatWidth: 0 } })
93 : Effect.void,
94 ),
95 Stream.runCollect,
96 );
97 assert.strictEqual(received.length, 2);
98 assert.strictEqual(received[1]?.values.chatWidth, "wide");
99 }).pipe(Effect.provide(service)),
100 );
101
102 it.effect("treats a retried committed patch as a no-op and requires a read revision", () =>
103 Effect.gen(function* () {
104 const settings = yield* UiSettings.UiSettings;
105 const input = { patch: { fontSizeCode: 16 }, baseRevisions: { fontSizeCode: 0 } };
106 const committed = yield* settings.patch(input);
107 assert.deepEqual(yield* settings.patch(input), committed);
108 const missingBase = yield* settings.patch({ patch: { fontSizeCode: 18 }, baseRevisions: {} });
109 assert.deepEqual(missingBase.conflicts, ["fontSizeCode"]);
110 assert.deepEqual(missingBase.profile, committed.profile);
111 }).pipe(Effect.provide(service)),
112 );
113});
apps/server/src/uiSettings.ts created+98
...@@ -0,0 +1,98 @@
1import {
2 UiSettingsError,
3 UiSettingsProfile,
4 type UiSettingsPatchInput,
5 type UiSettingsPatchResult,
6} from "@t3tools/contracts";
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as Equal from "effect/Equal";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as Result from "effect/Result";
14import * as Schema from "effect/Schema";
15import * as Semaphore from "effect/Semaphore";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18import * as ServerConfig from "./config.ts";
19
20export class UiSettings extends Context.Service<
21 UiSettings,
22 {
23 readonly current: Effect.Effect<UiSettingsProfile, UiSettingsError>;
24 readonly changes: Stream.Stream<UiSettingsProfile, UiSettingsError>;
25 readonly patch: (
26 input: UiSettingsPatchInput,
27 ) => Effect.Effect<UiSettingsPatchResult, UiSettingsError>;
28 }
29>()("t3/uiSettings") {}
30
31const make = Effect.gen(function* () {
32 const fs = yield* FileSystem.FileSystem;
33 const path = yield* Path.Path;
34 const { stateDir } = yield* ServerConfig.ServerConfig;
35 const profilePath = path.join(stateDir, "ui-settings.json");
36 const json = Schema.fromJsonString(UiSettingsProfile);
37 const empty: UiSettingsProfile = { revision: 0, values: {}, fieldRevisions: {} };
38 const initial = yield* fs.readFileString(profilePath).pipe(
39 Effect.flatMap(Schema.decodeUnknownEffect(json)),
40 Effect.catchIf(
41 (error) => "reason" in error && error.reason._tag === "NotFound",
42 () => Effect.succeed(empty),
43 ),
44 Effect.mapError((cause) => new UiSettingsError({ operation: "read", cause })),
45 Effect.result,
46 );
47 const state = yield* SubscriptionRef.make(initial);
48 const lock = yield* Semaphore.make(1);
49 const current = SubscriptionRef.get(state).pipe(Effect.flatMap(Effect.fromResult));
50
51 const patch = Effect.fn("UiSettings.patch")(function* (input: UiSettingsPatchInput) {
52 return yield* lock
53 .withPermits(1)(
54 Effect.gen(function* () {
55 const profileBefore = yield* current;
56 const values = { ...profileBefore.values };
57 const fieldRevisions = { ...profileBefore.fieldRevisions };
58 const conflicts: string[] = [];
59 const revision = profileBefore.revision + 1;
60 let changed = false;
61
62 for (const key of Object.keys(input.patch) as Array<keyof typeof input.patch>) {
63 const value = input.patch[key];
64 if (Equal.equals(profileBefore.values[key], value)) continue;
65 if (input.baseRevisions[key] !== (profileBefore.fieldRevisions[key] ?? 0)) {
66 conflicts.push(key);
67 continue;
68 }
69 Object.assign(values, { [key]: value });
70 fieldRevisions[key] = revision;
71 changed = true;
72 }
73
74 if (!changed) return { profile: profileBefore, conflicts };
75 const profile: UiSettingsProfile = { revision, values, fieldRevisions };
76 const encoded = yield* Schema.encodeEffect(json)(profile).pipe(
77 Effect.mapError((cause) => new UiSettingsError({ operation: "write", cause })),
78 );
79 yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(
80 Effect.andThen(fs.writeFileString(`${profilePath}.tmp`, encoded)),
81 Effect.andThen(fs.rename(`${profilePath}.tmp`, profilePath)),
82 Effect.mapError((cause) => new UiSettingsError({ operation: "write", cause })),
83 );
84 yield* SubscriptionRef.set(state, Result.succeed(profile));
85 return { profile, conflicts };
86 }),
87 )
88 .pipe(Effect.uninterruptible);
89 });
90
91 return UiSettings.of({
92 current,
93 changes: SubscriptionRef.changes(state).pipe(Stream.mapEffect(Effect.fromResult)),
94 patch,
95 });
96});
97
98export const layer = Layer.effect(UiSettings, make);
apps/server/src/ws.ts+5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as UiSettings from "./uiSettings.ts";
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";2import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import { OrchestrationDispatchCommandError } from "@t3tools/contracts";3import { OrchestrationDispatchCommandError } from "@t3tools/contracts";
3import * as Crypto from "effect/Crypto";4import * as Crypto from "effect/Crypto";
...@@ -1273,6 +1274,7 @@ const layerWsRpc = (...@@ -1273,6 +1274,7 @@ const layerWsRpc = (
1273 const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner;1274 const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner;
1274 const agentSessionImporter = yield* AgentSessionImporter.AgentSessionImporter;1275 const agentSessionImporter = yield* AgentSessionImporter.AgentSessionImporter;
1275 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;1276 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;
1277 const uiSettings = yield* UiSettings.UiSettings;
1276 const keybindings = yield* Keybindings.Keybindings;1278 const keybindings = yield* Keybindings.Keybindings;
1277 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;1279 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
1278 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;1280 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
...@@ -2414,6 +2416,9 @@ const layerWsRpc = (...@@ -2414,6 +2416,9 @@ const layerWsRpc = (
2414 }),2416 }),
2415 [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow,2417 [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow,
2416 [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports,2418 [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports,
2419 [WS_METHODS.uiSettingsGet]: () => uiSettings.current,
2420 [WS_METHODS.uiSettingsPatch]: (input) => uiSettings.patch(input),
2421 [WS_METHODS.uiSettingsSubscribe]: () => uiSettings.changes,
2417 [WS_METHODS.serverGetSettings]: (_input) =>2422 [WS_METHODS.serverGetSettings]: (_input) =>
2418 serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)),2423 serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)),
2419 [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) =>2424 [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) =>
apps/web/src/components/settings/SettingsPanels.tsx+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { UiSettingsSyncSection } from "./UiSettingsSyncSection";
1import { performDesktopUpdate } from "../desktopUpdate.actions";2import { performDesktopUpdate } from "../desktopUpdate.actions";
2import { useTerminalPlacement } from "~/hooks/useTerminalPlacement";3import { useTerminalPlacement } from "~/hooks/useTerminalPlacement";
3import { SettingsGroup } from "./SettingsGroup";4import { SettingsGroup } from "./SettingsGroup";
...@@ -2220,6 +2221,7 @@ export function GeneralSettingsPanel() {...@@ -2220,6 +2221,7 @@ export function GeneralSettingsPanel() {
22202221
2221 return (2222 return (
2222 <SettingsPageContainer>2223 <SettingsPageContainer>
2224 <UiSettingsSyncSection />
2223 <ProjectDefaultsSettings category="general" />2225 <ProjectDefaultsSettings category="general" />
2224 <SettingsSection id="organization" title="Organization">2226 <SettingsSection id="organization" title="Organization">
2225 <SettingsRow2227 <SettingsRow
apps/web/src/components/settings/UiSettingsSyncSection.tsx created+184
...@@ -0,0 +1,184 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/reactivity";
3import { useState } from "react";
4import { EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts";
5import { Button } from "../ui/button";
6import { Select, SelectTrigger, SelectPopup, SelectItem, SelectValue } from "../ui/select";
7import { useEnvironments } from "../../state/environments";
8import { useAtomCommand } from "../../state/use-atom-command";
9import {
10 disableUiSettingsSync,
11 enableUiSettingsSync,
12 uiSettingsSyncAtoms,
13 useUiSettingsSync,
14} from "../../state/uiSettingsSync";
15import { SettingsRow, SettingsSection } from "./settingsLayout";
16import { searchableSetting } from "./settingsSearch";
17
18export function UiSettingsSyncSection() {
19 const sync = useUiSettingsSync();
20 const { environments } = useEnvironments();
21 const [selection, setSelection] = useState("");
22 const [saved, setSaved] = useState<{
23 environmentId: EnvironmentId;
24 profile: UiSettingsProfile;
25 } | null>(null);
26 const [busy, setBusy] = useState(false);
27 const [error, setError] = useState<string | null>(null);
28 const read = useAtomCommand(uiSettingsSyncAtoms.read, { reportFailure: false });
29 const selected = selection ? EnvironmentId.make(selection) : null;
30 const permission = useAtomValue(uiSettingsSyncAtoms.patch.permissionAtom(selected));
31 const eligible = environments.filter(
32 (environment) =>
33 environment.connection.phase === "connected" &&
34 environment.serverConfig?.environment.capabilities.uiSettingsSync === true,
35 );
36 const active = environments.find(
37 (environment) => environment.environmentId === sync.environmentId,
38 );
39
40 async function inspect() {
41 if (!selected) return;
42 setBusy(true);
43 setError(null);
44 const result = await read({ environmentId: selected, input: {} });
45 if (AsyncResult.isSuccess(result)) setSaved({ environmentId: selected, profile: result.value });
46 else setError("Unable to read the profile. Reconnect to this environment and try again.");
47 setBusy(false);
48 }
49
50 async function enable(mode: "adopt" | "publish") {
51 if (!saved) return;
52 setBusy(true);
53 setError(null);
54 try {
55 await enableUiSettingsSync(saved.environmentId, saved.profile, mode);
56 setSaved(null);
57 setSelection("");
58 } catch {
59 setError("Unable to enable sync. Check local storage access and try again.");
60 } finally {
61 setBusy(false);
62 }
63 }
64
65 return (
66 <SettingsSection id="ui-settings-sync-section" title="UI settings sync">
67 <SettingsRow
68 {...searchableSetting("ui-settings-sync")}
69 description="Sync appearance, fonts, and chat preferences through one private environment. Enable it on each device."
70 control={
71 sync.environmentId !== null || sync.status === "error" ? (
72 <Button
73 size="sm"
74 variant="outline"
75 disabled={busy}
76 onClick={() => {
77 void disableUiSettingsSync().catch(() =>
78 setError("Unable to disable sync. Check local storage access and try again."),
79 );
80 }}
81 >
82 Disable sync
83 </Button>
84 ) : undefined
85 }
86 >
87 <div className="space-y-3">
88 <p role="status" className="text-sm text-muted-foreground">
89 {sync.environmentId === null
90 ? "Off"
91 : sync.status === "synced"
92 ? `Following ${active?.label ?? "your saved environment"}`
93 : sync.status === "syncing"
94 ? "Syncing…"
95 : sync.status === "error"
96 ? "Sync paused. Reconnect to the profile environment to try again."
97 : "Offline. Changes stay on this device until the profile environment reconnects."}
98 </p>
99 {sync.conflicts.length > 0 ? (
100 <p role="status" className="text-sm text-muted-foreground">
101 Newer saved preferences replaced conflicting edits. Change a preference again to use
102 your choice.
103 </p>
104 ) : null}
105 <div className="flex flex-wrap items-center gap-2">
106 <Select
107 value={selection}
108 disabled={busy}
109 onValueChange={(value) => {
110 setSelection(value ?? "");
111 setSaved(null);
112 setError(null);
113 }}
114 >
115 <SelectTrigger size="sm" aria-label="Profile environment">
116 <SelectValue>
117 {eligible.find((entry) => entry.environmentId === selection)?.label ??
118 "Choose private environment"}
119 </SelectValue>
120 </SelectTrigger>
121 <SelectPopup>
122 {eligible.map((environment) => (
123 <SelectItem key={environment.environmentId} value={environment.environmentId}>
124 {environment.label}
125 </SelectItem>
126 ))}
127 </SelectPopup>
128 </Select>
129 <Button
130 size="sm"
131 variant="outline"
132 disabled={!selected || !permission || busy}
133 onClick={() => void inspect()}
134 >
135 Read profile
136 </Button>
137 </div>
138 {eligible.length === 0 ? (
139 <p className="text-sm text-muted-foreground">
140 Connect to an updated private environment to set up sync.
141 </p>
142 ) : null}
143 {selected && !permission ? (
144 <p className="text-sm text-muted-foreground">
145 Settings access is required. Update this connection’s permissions in Connections.
146 </p>
147 ) : null}
148 {saved ? (
149 <div className="space-y-2">
150 <p className="text-sm text-muted-foreground">
151 {saved.profile.revision === 0
152 ? "No profile saved. Publish this device’s current preferences to start."
153 : "Use the saved profile, or publish this device’s current preferences. Other participating devices will follow it."}
154 </p>
155 <div className="flex flex-wrap gap-2">
156 {saved.profile.revision > 0 ? (
157 <Button size="sm" disabled={busy} onClick={() => void enable("adopt")}>
158 Use saved
159 </Button>
160 ) : null}
161 <Button
162 size="sm"
163 variant={saved.profile.revision > 0 ? "outline" : "default"}
164 disabled={busy || !permission}
165 onClick={() => void enable("publish")}
166 >
167 Publish current
168 </Button>
169 <Button size="sm" variant="ghost" disabled={busy} onClick={() => setSaved(null)}>
170 Cancel
171 </Button>
172 </div>
173 </div>
174 ) : null}
175 {error ? (
176 <p role="alert" className="text-sm text-destructive">
177 {error}
178 </p>
179 ) : null}
180 </div>
181 </SettingsRow>
182 </SettingsSection>
183 );
184}
apps/web/src/components/settings/settingsSearch.ts+6
...@@ -331,6 +331,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -331,6 +331,12 @@ export const SETTINGS_SEARCH_ITEMS = [
331 title: "Composer context",331 title: "Composer context",
332 to: "/settings/appearance",332 to: "/settings/appearance",
333 },333 },
334 {
335 id: "ui-settings-sync",
336 title: "UI settings sync",
337 to: "/settings/general",
338 searchTerms: ["personal profile theme fonts devices sync"],
339 },
334 {340 {
335 id: "project-grouping",341 id: "project-grouping",
336 title: "Project grouping",342 title: "Project grouping",
apps/web/src/hooks/useDefaultTheme.test.ts+4
...@@ -8,9 +8,13 @@ const BASE = {...@@ -8,9 +8,13 @@ const BASE = {
8 defaultThemeSetAt: "2026-08-28T00:00:00.000Z",8 defaultThemeSetAt: "2026-08-28T00:00:00.000Z",
9 appliedGeneration: null,9 appliedGeneration: null,
10 resolves: true,10 resolves: true,
11 profileEnabled: false,
11} as const;12} as const;
1213
13describe("default theme adoption", () => {14describe("default theme adoption", () => {
15 it("keeps a personal profile ahead of unseen environment defaults", () => {
16 expect(defaultThemeToApply({ ...BASE, profileEnabled: true })).toBe(null);
17 });
14 it("applies a set this client has not seen", () => {18 it("applies a set this client has not seen", () => {
15 expect(defaultThemeToApply(BASE)).toBe(19 expect(defaultThemeToApply(BASE)).toBe(
16 defaultThemeGeneration(BASE.defaultTheme, BASE.defaultThemeSetAt),20 defaultThemeGeneration(BASE.defaultTheme, BASE.defaultThemeSetAt),
apps/web/src/hooks/useDefaultTheme.ts+11
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { personalUiSettingsSyncEnabled, useUiSettingsSync } from "../state/uiSettingsSync";
1import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
2import { useEffect } from "react";3import { useEffect } from "react";
34
...@@ -34,7 +35,9 @@ export function defaultThemeToApply(input: {...@@ -34,7 +35,9 @@ export function defaultThemeToApply(input: {
34 readonly defaultThemeSetAt: string;35 readonly defaultThemeSetAt: string;
35 readonly appliedGeneration: string | null;36 readonly appliedGeneration: string | null;
36 readonly resolves: boolean;37 readonly resolves: boolean;
38 readonly profileEnabled: boolean;
37}): string | null {39}): string | null {
40 if (input.profileEnabled) return null;
38 if (input.environmentId === null || input.defaultTheme.length === 0) return null;41 if (input.environmentId === null || input.defaultTheme.length === 0) return null;
39 const generation = defaultThemeGeneration(input.defaultTheme, input.defaultThemeSetAt);42 const generation = defaultThemeGeneration(input.defaultTheme, input.defaultThemeSetAt);
40 if (input.appliedGeneration === generation) return null;43 if (input.appliedGeneration === generation) return null;
...@@ -71,6 +74,8 @@ function writeAppliedGeneration(storageKey: string, generation: string): void {...@@ -71,6 +74,8 @@ function writeAppliedGeneration(storageKey: string, generation: string): void {
71 * resolve yet — the setting and the palette it names arrive independently.74 * resolve yet — the setting and the palette it names arrive independently.
72 */75 */
73export function useDefaultThemeAdoption(): void {76export function useDefaultThemeAdoption(): void {
77 const sync = useUiSettingsSync();
78 const profileEnabled = sync.controller !== null || personalUiSettingsSyncEnabled();
74 const environmentId = useAtomValue(primaryEnvironmentIdAtom);79 const environmentId = useAtomValue(primaryEnvironmentIdAtom);
75 const settings = useAtomValue(primaryServerSettingsAtom);80 const settings = useAtomValue(primaryServerSettingsAtom);
76 const { defaultTheme, defaultThemeSetAt } = settings;81 const { defaultTheme, defaultThemeSetAt } = settings;
...@@ -82,6 +87,10 @@ export function useDefaultThemeAdoption(): void {...@@ -82,6 +87,10 @@ export function useDefaultThemeAdoption(): void {
82 useEffect(() => {87 useEffect(() => {
83 if (typeof window === "undefined" || environmentId === null) return;88 if (typeof window === "undefined" || environmentId === null) return;
84 const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`;89 const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`;
90 if (profileEnabled) {
91 if (defaultTheme.length > 0)
92 writeAppliedGeneration(storageKey, defaultThemeGeneration(defaultTheme, defaultThemeSetAt));
93 }
85 const definition = getThemeDefinition(defaultTheme);94 const definition = getThemeDefinition(defaultTheme);
86 const generation = defaultThemeToApply({95 const generation = defaultThemeToApply({
87 environmentId,96 environmentId,
...@@ -89,6 +98,7 @@ export function useDefaultThemeAdoption(): void {...@@ -89,6 +98,7 @@ export function useDefaultThemeAdoption(): void {
89 defaultThemeSetAt,98 defaultThemeSetAt,
90 appliedGeneration: readAppliedGeneration(storageKey),99 appliedGeneration: readAppliedGeneration(storageKey),
91 resolves: definition !== null,100 resolves: definition !== null,
101 profileEnabled,
92 });102 });
93 if (generation === null || definition === null) return;103 if (generation === null || definition === null) return;
94104
...@@ -105,6 +115,7 @@ export function useDefaultThemeAdoption(): void {...@@ -105,6 +115,7 @@ export function useDefaultThemeAdoption(): void {
105 if (half !== null && !setAppearanceMode(half)) return;115 if (half !== null && !setAppearanceMode(half)) return;
106 writeAppliedGeneration(storageKey, generation);116 writeAppliedGeneration(storageKey, generation);
107 }, [117 }, [
118 profileEnabled,
108 environmentId,119 environmentId,
109 defaultTheme,120 defaultTheme,
110 defaultThemeSetAt,121 defaultThemeSetAt,
apps/web/src/hooks/useSettings.ts+14
...@@ -67,6 +67,15 @@ const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";...@@ -67,6 +67,15 @@ const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
6767
68type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;68type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;
6969
70const clientSettingsEditListeners = new Set<(patch: ClientSettingsPatch) => void>();
71
72export function subscribeClientSettingsEdits(listener: (patch: ClientSettingsPatch) => void) {
73 clientSettingsEditListeners.add(listener);
74 return () => {
75 clientSettingsEditListeners.delete(listener);
76 };
77}
78
70const clientSettingsListeners = new Set<() => void>();79const clientSettingsListeners = new Set<() => void>();
71const clientSettingsHydrationListeners = new Set<() => void>();80const clientSettingsHydrationListeners = new Set<() => void>();
72type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying";81type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying";
...@@ -191,7 +200,11 @@ function enqueueClientSettingsPersistence<A>(work: () => Promise<A>): Promise<A>...@@ -191,7 +200,11 @@ function enqueueClientSettingsPersistence<A>(work: () => Promise<A>): Promise<A>
191export function persistClientSettingsPatch(200export function persistClientSettingsPatch(
192 patch: ClientSettingsPatch,201 patch: ClientSettingsPatch,
193 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,202 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,
203 source: "local" | "sync" = "local",
194): Promise<void> {204): Promise<void> {
205 if (source === "local") {
206 for (const listener of clientSettingsEditListeners) listener(patch);
207 }
195 // Patches queued before hydration must publish before newer optimistic patches.208 // Patches queued before hydration must publish before newer optimistic patches.
196 const deferPatch =209 const deferPatch =
197 clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0;210 clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0;
...@@ -217,6 +230,7 @@ export function persistClientSettingsPatch(...@@ -217,6 +230,7 @@ export function persistClientSettingsPatch(
217 operation: "persist",230 operation: "persist",
218 ...safeErrorLogAttributes(error),231 ...safeErrorLogAttributes(error),
219 });232 });
233 if (source === "sync") throw error;
220 });234 });
221}235}
222236
apps/web/src/hooks/useTheme.ts+42
...@@ -21,9 +21,47 @@ import {...@@ -21,9 +21,47 @@ import {
21 type ThemeAppearance,21 type ThemeAppearance,
22 type ThemeHalves,22 type ThemeHalves,
23 type ThemePreferenceMode,23 type ThemePreferenceMode,
24 getCustomThemePreferences,
25 replaceCustomThemePreferences,
24} from "../themePalette";26} from "../themePalette";
2527
28import type { UiThemePreferences } from "@t3tools/contracts";
29
26type Theme = ThemePreference;30type Theme = ThemePreference;
31const themeEditListeners = new Set<() => void>();
32export function subscribeThemeEdits(listener: () => void) {
33 themeEditListeners.add(listener);
34 return () => {
35 themeEditListeners.delete(listener);
36 };
37}
38function emitThemeEdit() {
39 for (const listener of themeEditListeners) listener();
40}
41
42export function readUiThemePreferences(): UiThemePreferences {
43 const selection = readThemePreference();
44 const halves = readThemeHalvesRaw();
45 const themeIds = [selection, ...Object.values(halves)];
46 return {
47 selection,
48 mode: readAppearanceModePreference(selection),
49 halves,
50 customThemes: getCustomThemePreferences(themeIds),
51 };
52}
53
54export function applyUiThemePreferences(preferences: UiThemePreferences) {
55 replaceCustomThemePreferences(preferences.customThemes);
56 window.localStorage.setItem(STORAGE_KEY, preferences.selection);
57 writeAppearanceModePreference(preferences.mode);
58 window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, JSON.stringify(preferences.halves));
59 themeStorageReadFailure = null;
60 lastAppliedTheme = null;
61 applyTheme(preferences.selection, { suppressTransitions: true });
62 emitChange();
63}
64
27type ThemeSnapshot = {65type ThemeSnapshot = {
28 theme: Theme;66 theme: Theme;
29 resolvedTheme: ThemeAppearance;67 resolvedTheme: ThemeAppearance;
...@@ -555,6 +593,7 @@ export function useTheme() {...@@ -555,6 +593,7 @@ export function useTheme() {
555 }593 }
556 applyTheme(next, { suppressTransitions: true });594 applyTheme(next, { suppressTransitions: true });
557 emitChange();595 emitChange();
596 emitThemeEdit();
558 return true;597 return true;
559 }, []);598 }, []);
560599
...@@ -580,6 +619,7 @@ export function useTheme() {...@@ -580,6 +619,7 @@ export function useTheme() {
580 themeStorageReadFailure = null;619 themeStorageReadFailure = null;
581 applyTheme(getStored(), { suppressTransitions: true });620 applyTheme(getStored(), { suppressTransitions: true });
582 emitChange();621 emitChange();
622 emitThemeEdit();
583 return true;623 return true;
584 }, []);624 }, []);
585625
...@@ -624,6 +664,7 @@ export function useTheme() {...@@ -624,6 +664,7 @@ export function useTheme() {
624 }664 }
625 applyTheme(getStored(), { suppressTransitions: true });665 applyTheme(getStored(), { suppressTransitions: true });
626 emitChange();666 emitChange();
667 emitThemeEdit();
627 return true;668 return true;
628 },669 },
629 [],670 [],
...@@ -648,6 +689,7 @@ export function useTheme() {...@@ -648,6 +689,7 @@ export function useTheme() {
648 }689 }
649 applyTheme(getStored(), { suppressTransitions: true });690 applyTheme(getStored(), { suppressTransitions: true });
650 emitChange();691 emitChange();
692 emitThemeEdit();
651 return true;693 return true;
652 }, []);694 }, []);
653695
apps/web/src/routes/__root.tsx+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { usePersonalUiSettingsSync } from "../state/uiSettingsSync";
1import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice";2import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice";
2import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";3import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
3import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";4import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
...@@ -143,6 +144,7 @@ function RootRouteNotFoundView() {...@@ -143,6 +144,7 @@ function RootRouteNotFoundView() {
143}144}
144145
145function RootRouteView() {146function RootRouteView() {
147 usePersonalUiSettingsSync();
146 useEffect(() => installDesktopPasteAsText(window.desktopBridge, window), []);148 useEffect(() => installDesktopPasteAsText(window.desktopBridge, window), []);
147 const pathname = useLocation({ select: (location) => location.pathname });149 const pathname = useLocation({ select: (location) => location.pathname });
148 const { authGateState } = Route.useRouteContext();150 const { authGateState } = Route.useRouteContext();
apps/web/src/state/uiSettingsSync.test.tsx created+210
...@@ -0,0 +1,210 @@
1import { RegistryContext } from "@effect/atom-react";
2import { EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts";
3import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings";
4import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
5import { act, useEffect } from "react";
6import { create, type ReactTestRenderer } from "react-test-renderer";
7import { afterEach, expect, it, vi } from "vite-plus/test";
8
9const mocks = vi.hoisted(() => ({
10 persist: vi.fn<(settings: ClientSettings) => Promise<void>>().mockResolvedValue(undefined),
11 load: vi.fn<() => Promise<ClientSettings | null>>().mockResolvedValue(null),
12 rpc: vi.fn(),
13 changes: null as Atom.Writable<AsyncResult.AsyncResult<UiSettingsProfile>> | null,
14 supported: false,
15 read: Symbol("read"),
16 patch: Symbol("patch"),
17}));
18vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: undefined }));
19vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: undefined }));
20vi.mock("../localApi", () => ({
21 ensureLocalApi: () => ({
22 persistence: { getClientSettings: mocks.load, setClientSettings: mocks.persist },
23 }),
24}));
25vi.mock("./session", () => ({
26 environmentSession: {},
27 readEnvironmentScope: () => false,
28 useEnvironmentScope: () => false,
29}));
30vi.mock("./server", () => ({ serverEnvironment: {}, primaryServerSettingsAtom: undefined }));
31vi.mock("./environments", () => ({
32 useEnvironments: () => ({
33 environments: [
34 {
35 environmentId: "personal",
36 label: "Personal",
37 connection: { phase: "connected" },
38 serverConfig: { environment: { capabilities: { uiSettingsSync: mocks.supported } } },
39 },
40 ],
41 }),
42}));
43vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({
44 ...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()),
45 runAtomCommand: mocks.rpc,
46}));
47vi.mock("@t3tools/client-runtime/state/ui-settings-sync", async (original) => ({
48 ...(await original<typeof import("@t3tools/client-runtime/state/ui-settings-sync")>()),
49 createUiSettingsSyncAtoms: () => ({
50 changes: () => mocks.changes,
51 patch: mocks.patch,
52 read: mocks.read,
53 }),
54}));
55
56import {
57 ensureClientSettingsHydrated,
58 getClientSettings,
59 persistClientSettingsPatch,
60 subscribeClientSettingsEdits,
61 useClientSettings,
62 __resetClientSettingsPersistenceForTests,
63} from "../hooks/useSettings";
64import { readUiThemePreferences, useTheme } from "../hooks/useTheme";
65import { getStandardThemeColors, getCustomThemes } from "../themePalette";
66import {
67 enableUiSettingsSync,
68 disableUiSettingsSync,
69 usePersonalUiSettingsSync,
70} from "./uiSettingsSync";
71
72let renderer: ReactTestRenderer | undefined;
73const registry = AtomRegistry.make();
74afterEach(async () => {
75 await act(async () => renderer?.unmount());
76 renderer = undefined;
77 await disableUiSettingsSync();
78 __resetClientSettingsPersistenceForTests();
79 vi.unstubAllGlobals();
80});
81
82it("adopts and follows a second client's theme and fonts through the existing stores without echoing", async () => {
83 const storage = new Map<string, string>();
84 const desktopTheme = vi.fn().mockResolvedValue(undefined);
85 vi.stubGlobal("window", {
86 localStorage: {
87 getItem: (key: string) => storage.get(key) ?? null,
88 setItem: (key: string, value: string) => storage.set(key, value),
89 removeItem: (key: string) => storage.delete(key),
90 },
91 matchMedia: () => ({
92 matches: false,
93 addEventListener: () => {},
94 removeEventListener: () => {},
95 }),
96 addEventListener: () => {},
97 removeEventListener: () => {},
98 desktopBridge: { setTheme: desktopTheme },
99 });
100 const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
101 const root = {
102 style,
103 dataset: {} as Record<string, string>,
104 classList: { add: vi.fn(), toggle: vi.fn(), remove: vi.fn() },
105 };
106 vi.stubGlobal("document", { documentElement: root });
107 vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
108 callback();
109 return 1;
110 });
111 mocks.changes = Atom.make<AsyncResult.AsyncResult<UiSettingsProfile>>(AsyncResult.initial());
112 mocks.supported = false;
113 mocks.rpc.mockReset();
114 await ensureClientSettingsHydrated();
115 let observed = DEFAULT_CLIENT_SETTINGS;
116 function Client() {
117 usePersonalUiSettingsSync();
118 const settings = useClientSettings();
119 useEffect(() => {
120 observed = settings;
121 }, [settings]);
122 useTheme();
123 return null;
124 }
125 await act(async () => {
126 renderer = create(
127 <RegistryContext.Provider value={registry}>
128 <Client />
129 </RegistryContext.Provider>,
130 );
131 });
132 const theme = {
133 selection: "personal-dark",
134 mode: "dark" as const,
135 halves: {},
136 customThemes: [
137 {
138 id: "personal-dark",
139 name: "Personal dark",
140 appearance: "dark" as const,
141 colors: getStandardThemeColors("dark"),
142 },
143 ],
144 };
145 const saved: UiSettingsProfile = {
146 revision: 1,
147 values: { theme, fontFamilyCode: "Saved Mono", fontSizeCode: 17, fontSizeTerminal: 15 },
148 fieldRevisions: { theme: 1, fontFamilyCode: 1, fontSizeCode: 1, fontSizeTerminal: 1 },
149 };
150 const edit = vi.fn();
151 const unsubscribe = subscribeClientSettingsEdits(edit);
152 await act(async () => {
153 await enableUiSettingsSync(EnvironmentId.make("personal"), saved, "adopt");
154 });
155 expect(observed.fontFamilyCode).toBe("Saved Mono");
156 expect(getClientSettings().fontSizeTerminal).toBe(15);
157 expect(readUiThemePreferences().selection).toBe("personal-dark");
158 expect(getCustomThemes()[0]?.id).toBe("personal-dark");
159 expect(root.dataset.themeId).toBe("personal-dark");
160 expect(desktopTheme).toHaveBeenCalledWith("dark");
161 expect(style.setProperty).toHaveBeenCalled();
162 expect(edit).not.toHaveBeenCalled();
163 expect(mocks.rpc).not.toHaveBeenCalled();
164
165 mocks.supported = true;
166 const remote = {
167 ...saved,
168 revision: 2,
169 values: { ...saved.values, fontFamilyCode: "Second Mono", fontSizeTerminal: 18 },
170 fieldRevisions: { ...saved.fieldRevisions, fontFamilyCode: 2, fontSizeTerminal: 2 },
171 };
172 mocks.rpc.mockResolvedValue(AsyncResult.success(remote));
173 await act(async () => {
174 registry.set(mocks.changes!, AsyncResult.success(remote));
175 renderer?.update(
176 <RegistryContext.Provider value={registry}>
177 <Client />
178 </RegistryContext.Provider>,
179 );
180 });
181 expect(observed.fontFamilyCode).toBe("Second Mono");
182 expect(observed.fontSizeTerminal).toBe(18);
183 expect(edit).not.toHaveBeenCalled();
184 expect(mocks.rpc).toHaveBeenCalledTimes(1);
185 await act(async () => {
186 registry.set(
187 mocks.changes!,
188 AsyncResult.waiting(
189 AsyncResult.success({
190 ...remote,
191 revision: 3,
192 values: { ...remote.values, fontFamilyCode: "Live Mono", fontSizeTerminal: 20 },
193 fieldRevisions: { ...remote.fieldRevisions, fontFamilyCode: 3, fontSizeTerminal: 3 },
194 }),
195 ),
196 );
197 });
198 expect(observed.fontFamilyCode).toBe("Live Mono");
199 expect(observed.fontSizeTerminal).toBe(20);
200 expect(edit).not.toHaveBeenCalled();
201 expect(mocks.rpc).toHaveBeenCalledTimes(1);
202 await act(async () => {
203 await disableUiSettingsSync();
204 await persistClientSettingsPatch({ fontSizeCode: 19 });
205 });
206 expect(edit).toHaveBeenCalledWith({ fontSizeCode: 19 });
207 expect(getClientSettings().fontSizeCode).toBe(19);
208 expect(mocks.rpc).toHaveBeenCalledTimes(1);
209 unsubscribe();
210});
apps/web/src/state/uiSettingsSync.ts created+220
...@@ -0,0 +1,220 @@
1import { RegistryContext } from "@effect/atom-react";
2import {
3 UiSettingsSync,
4 UiSettingsSyncCache,
5 createUiSettingsSyncAtoms,
6 type UiSettingsSyncCache as SyncCache,
7} from "@t3tools/client-runtime/state/ui-settings-sync";
8import { UiSettingsValues, type EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import * as Equal from "effect/Equal";
11import * as Schema from "effect/Schema";
12import * as Struct from "effect/Struct";
13import { AsyncResult } from "effect/reactivity";
14import { use, useEffect, useSyncExternalStore } from "react";
15import { connectionAtomRuntime } from "../connection/runtime";
16import {
17 ensureClientSettingsHydrated,
18 getClientSettings,
19 persistClientSettingsPatch,
20 subscribeClientSettingsEdits,
21} from "../hooks/useSettings";
22import {
23 applyUiThemePreferences,
24 readUiThemePreferences,
25 subscribeThemeEdits,
26} from "../hooks/useTheme";
27import { subscribeToCustomThemeEdits } from "../themePalette";
28import { appAtomRegistry } from "../rpc/atomRegistry";
29import { useEnvironments } from "./environments";
30import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
31
32export const uiSettingsSyncAtoms = createUiSettingsSyncAtoms(connectionAtomRuntime);
33const STORAGE_KEY = "knife:ui-settings-sync:v1";
34const json = Schema.fromJsonString(UiSettingsSyncCache);
35const decodeCache = Schema.decodeUnknownSync(json);
36const decodeValues = Schema.decodeUnknownSync(UiSettingsValues);
37const listeners = new Set<() => void>();
38let state: {
39 controller: UiSettingsSync | null;
40 environmentId: EnvironmentId | null;
41 status: "disabled" | UiSettingsSync["snapshot"]["status"];
42 conflicts: UiSettingsSync["snapshot"]["conflicts"];
43} = { controller: null, environmentId: null, status: "disabled", conflicts: [] };
44
45function notify() {
46 for (const listener of listeners) listener();
47}
48
49export function personalUiSettingsSyncEnabled() {
50 if (state.controller) return true;
51 try {
52 return window.localStorage.getItem(STORAGE_KEY) !== null;
53 } catch {
54 return true;
55 }
56}
57
58export function useUiSettingsSync() {
59 return useSyncExternalStore(
60 (listener) => {
61 listeners.add(listener);
62 return () => {
63 listeners.delete(listener);
64 };
65 },
66 () => state,
67 );
68}
69
70async function apply(values: UiSettingsValues) {
71 if (values.theme && !Equal.equals(readUiThemePreferences(), values.theme)) {
72 applyUiThemePreferences(values.theme);
73 }
74 const patch = Struct.omit(values, ["theme"]);
75 const local = getClientSettings();
76 for (const key of Object.keys(patch) as Array<keyof typeof patch>) {
77 if (Equal.equals(local[key], patch[key])) delete patch[key];
78 }
79 if (Object.keys(patch).length > 0) await persistClientSettingsPatch(patch, undefined, "sync");
80}
81
82function install(cache: SyncCache) {
83 const next = new UiSettingsSync(cache, {
84 save: async (value) => {
85 window.localStorage.setItem(STORAGE_KEY, Schema.encodeSync(json)(value));
86 },
87 apply,
88 patch: async (input) => {
89 const result = await runAtomCommand(
90 appAtomRegistry,
91 uiSettingsSyncAtoms.patch,
92 {
93 environmentId: cache.environmentId,
94 input,
95 },
96 { reportFailure: false },
97 );
98 if (AsyncResult.isFailure(result)) throw Cause.squash(result.cause);
99 return result.value;
100 },
101 });
102 state = { ...state, controller: next };
103 const update = () => {
104 if (state.controller !== next) return;
105 state = { controller: next, environmentId: cache.environmentId, ...next.snapshot };
106 notify();
107 };
108 next.subscribe(update);
109 update();
110 return next;
111}
112
113export async function enableUiSettingsSync(
114 environmentId: EnvironmentId,
115 profile: UiSettingsProfile,
116 mode: "adopt" | "publish",
117) {
118 await ensureClientSettingsHydrated();
119 const local =
120 mode === "publish"
121 ? { ...decodeValues(getClientSettings()), theme: readUiThemePreferences() }
122 : null;
123 const cache: SyncCache = { environmentId, profile, pending: { patch: {}, baseRevisions: {} } };
124 // Persist consent before installing a controller that can write to the environment.
125 await state.controller?.stop();
126 window.localStorage.setItem(STORAGE_KEY, Schema.encodeSync(json)(cache));
127 const next = install(cache);
128 if (local) next.edit(local);
129 await next.receive(profile);
130}
131
132export async function disableUiSettingsSync() {
133 const previous = state.controller;
134 state = { ...state, controller: null };
135 await previous?.stop();
136 window.localStorage.removeItem(STORAGE_KEY);
137 state = { controller: null, environmentId: null, status: "disabled", conflicts: [] };
138 notify();
139}
140
141/** Mounted once for the app; individual settings views never own the sync lifecycle. */
142export function usePersonalUiSettingsSync() {
143 const registry = use(RegistryContext);
144 const sync = useUiSettingsSync();
145 const { environments } = useEnvironments();
146 const environment = environments.find((entry) => entry.environmentId === sync.environmentId);
147 const activeController = sync.controller;
148 const connected =
149 environment?.connection.phase === "connected" &&
150 environment.serverConfig?.environment.capabilities.uiSettingsSync === true;
151
152 useEffect(() => {
153 let disposed = false;
154 const unlisten = subscribeClientSettingsEdits((patch) => {
155 state.controller?.edit(decodeValues(patch));
156 });
157 const themeChanged = () => {
158 if (!state.controller) return;
159 try {
160 state.controller.edit({ theme: readUiThemePreferences() });
161 } catch {
162 state.controller.disconnect();
163 }
164 };
165 const untheme = subscribeThemeEdits(themeChanged);
166 const uncustom = subscribeToCustomThemeEdits(themeChanged);
167 void ensureClientSettingsHydrated()
168 .then(() => {
169 if (disposed || state.controller) return;
170 const raw = window.localStorage.getItem(STORAGE_KEY);
171 if (raw) return install(decodeCache(raw)).restore();
172 })
173 .catch(() => {
174 state = { ...state, status: "error" };
175 notify();
176 });
177 return () => {
178 disposed = true;
179 unlisten();
180 untheme();
181 uncustom();
182 };
183 }, []);
184
185 useEffect(() => {
186 const current = activeController;
187 if (!current || !sync.environmentId) return;
188 if (!connected) {
189 current.disconnect();
190 return;
191 }
192 let disposed = false;
193 let unsubscribe = () => {};
194 const target = { environmentId: sync.environmentId, input: {} };
195 void runAtomCommand(registry, uiSettingsSyncAtoms.read, target, { reportFailure: false }).then(
196 async (result) => {
197 if (disposed) return;
198 if (AsyncResult.isFailure(result)) {
199 current.disconnect();
200 return;
201 }
202 await current.receive(result.value);
203 if (disposed) return;
204 unsubscribe = registry.subscribe(
205 uiSettingsSyncAtoms.changes(target),
206 (update) => {
207 if (AsyncResult.isSuccess(update)) void current.receive(update.value);
208 else if (AsyncResult.isFailure(update)) current.disconnect();
209 },
210 { immediate: true },
211 );
212 },
213 );
214 return () => {
215 disposed = true;
216 unsubscribe();
217 current.disconnect();
218 };
219 }, [activeController, connected, registry, sync.environmentId]);
220}
apps/web/src/themePalette.ts+48
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import type { UiThemePreferences } from "@t3tools/contracts";
1import * as Equal from "effect/Equal";2import * as Equal from "effect/Equal";
2import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
3import "culori/css";4import "culori/css";
...@@ -242,6 +243,51 @@ export function invalidateCustomThemes() {...@@ -242,6 +243,51 @@ export function invalidateCustomThemes() {
242243
243const EMPTY_CUSTOM_THEMES: ReadonlyArray<ThemeDefinition> = [];244const EMPTY_CUSTOM_THEMES: ReadonlyArray<ThemeDefinition> = [];
244245
246const customThemeEditListeners = new Set<() => void>();
247export function subscribeToCustomThemeEdits(listener: () => void) {
248 customThemeEditListeners.add(listener);
249 return () => {
250 customThemeEditListeners.delete(listener);
251 };
252}
253
254export function getCustomThemePreferences(
255 selected: ReadonlyArray<string>,
256): UiThemePreferences["customThemes"] {
257 const library = getWritableCustomThemeLibrary();
258 const selectedIds = new Set(selected.map(themeIdFromPreference));
259 const published = environmentThemeDefinitions.filter(
260 (theme) =>
261 selectedIds.has(theme.id) && !library.themes.some((custom) => custom.id === theme.id),
262 );
263 return [...library.themes, ...published].map(
264 ({ id, label, appearance, colors, variants, collection, managed }) => ({
265 id,
266 name: label,
267 appearance,
268 colors,
269 ...(variants ? { variants } : {}),
270 ...(collection ? { collection } : {}),
271 ...(managed === undefined ? {} : { managed }),
272 }),
273 );
274}
275
276export function replaceCustomThemePreferences(themes: UiThemePreferences["customThemes"]) {
277 const definitions = themes.map(({ name, ...theme }) => ({ ...theme, label: name }));
278 const canonical = parseStoredThemes(definitions);
279 if (canonical.length !== definitions.length) throw new Error("Unable to apply saved themes.");
280 const library = getWritableCustomThemeLibrary();
281 const stored = canonical.filter(
282 (theme) =>
283 library.themes.some((custom) => custom.id === theme.id) ||
284 !environmentThemeDefinitions.some((published) =>
285 Equal.equals(canonicalizeThemeDefinition(published), theme),
286 ),
287 );
288 saveCustomThemes(stored, stored, "sync");
289}
290
245export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {291export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {
246 const snapshot = getCustomThemeLibrarySnapshot();292 const snapshot = getCustomThemeLibrarySnapshot();
247 return snapshot.status === "ready" ? snapshot.themes : EMPTY_CUSTOM_THEMES;293 return snapshot.status === "ready" ? snapshot.themes : EMPTY_CUSTOM_THEMES;
...@@ -1169,6 +1215,7 @@ export class ThemeLibraryStorageError extends Schema.TaggedError<ThemeLibrarySto...@@ -1169,6 +1215,7 @@ export class ThemeLibraryStorageError extends Schema.TaggedError<ThemeLibrarySto
1169function saveCustomThemes(1215function saveCustomThemes(
1170 storedThemes: ReadonlyArray<unknown>,1216 storedThemes: ReadonlyArray<unknown>,
1171 themes: ReadonlyArray<ThemeDefinition>,1217 themes: ReadonlyArray<ThemeDefinition>,
1218 source: "local" | "sync" = "local",
1172): void {1219): void {
1173 if (typeof window === "undefined") return;1220 if (typeof window === "undefined") return;
1174 try {1221 try {
...@@ -1183,6 +1230,7 @@ function saveCustomThemes(...@@ -1183,6 +1230,7 @@ function saveCustomThemes(
1183 });1230 });
1184 }1231 }
1185 notifyCustomThemeListeners();1232 notifyCustomThemeListeners();
1233 if (source === "local") for (const listener of customThemeEditListeners) listener();
1186}1234}
11871235
1188function requireWritableCustomThemeLibrary(1236function requireWritableCustomThemeLibrary(
docs/user/appearance.md+18-2
...@@ -2,8 +2,7 @@...@@ -2,8 +2,7 @@
22
3On web and desktop, open **Settings → Appearance** to choose a theme and follow the system3On web and desktop, open **Settings → Appearance** to choose a theme and follow the system
4appearance or stay in light or dark mode. To use different themes for light and dark mode, select4appearance or stay in light or dark mode. To use different themes for light and dark mode, select
5the corresponding preview within each theme. Appearance preferences are saved separately on each5the corresponding preview within each theme. Appearance preferences stay on each device or browser unless you enable UI settings sync.
6device or browser.
76
8On web and desktop, use **Change theme** in the command palette to select a theme without leaving chat.7On web and desktop, use **Change theme** in the command palette to select a theme without leaving chat.
9Press **Cmd+Option+A** on macOS or **Ctrl+Alt+A** on Windows/Linux to open the theme picker directly.8Press **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...@@ -21,6 +20,23 @@ Android uses **Material You Layout** by default unless you have turned it off in
21It changes shapes, spacing, and controls independently20It changes shapes, spacing, and controls independently
22of the selected theme.21of the selected theme.
2322
23## Sync across devices
24
25On web or desktop, open **Settings → General → UI settings sync** and choose a private connected
26environment. Read its profile, then choose **Publish current** to save this device’s preferences
27or **Use saved** to adopt the existing profile. Repeat on each participating device using the same
28environment. Themes, custom palettes, fonts, and chat presentation follow the profile; window
29layout, credentials, permissions, and native integrations stay local. The personal profile takes
30precedence over environment theme defaults.
31
32Mobile joins from **Settings → Appearance → UI settings sync**. It shares follow-up behavior,
33project grouping, and the working shelf. Native themes and text sizes stay on the device.
34
35Changes made offline stay usable locally and sync when the profile environment reconnects. When
36two devices change the same preference from an older saved version, the newer saved choice wins.
37Change it again to use your choice. **Disable sync** keeps the current preferences on this device;
38choosing another environment starts the same publish-or-adopt flow.
39
24## Composer context40## Composer context
2541
26Git-backed projects show branch and worktree controls below the composer while you create a thread.42Git-backed projects show branch and worktree controls below the composer while you create a thread.
packages/client-runtime/package.json+4
...@@ -422,6 +422,10 @@...@@ -422,6 +422,10 @@
422 "./state/script-progress": {422 "./state/script-progress": {
423 "types": "./src/state/scriptProgress.ts",423 "types": "./src/state/scriptProgress.ts",
424 "default": "./src/state/scriptProgress.ts"424 "default": "./src/state/scriptProgress.ts"
425 },
426 "./state/ui-settings-sync": {
427 "types": "./src/state/uiSettingsSync.ts",
428 "default": "./src/state/uiSettingsSync.ts"
425 }429 }
426 },430 },
427 "scripts": {431 "scripts": {
packages/client-runtime/src/rpc/client.ts+1
...@@ -48,6 +48,7 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string;...@@ -48,6 +48,7 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string;
48type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];48type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];
4949
50export type EnvironmentSubscriptionRpcTag =50export type EnvironmentSubscriptionRpcTag =
51 | typeof WS_METHODS.uiSettingsSubscribe
51 | typeof WS_METHODS.subscribeScriptProgress52 | typeof WS_METHODS.subscribeScriptProgress
52 | typeof WS_METHODS.codexAuthCallbackSubscribe53 | typeof WS_METHODS.codexAuthCallbackSubscribe
53 | typeof WS_METHODS.providerAuthSubscribe54 | typeof WS_METHODS.providerAuthSubscribe
packages/client-runtime/src/state/commandPermissions.test.ts+24
...@@ -59,6 +59,30 @@ const setup = Effect.gen(function* () {...@@ -59,6 +59,30 @@ const setup = Effect.gen(function* () {
59});59});
6060
61describe("command permissions", () => {61describe("command permissions", () => {
62 it.effect("requires settings access on the selected UI profile environment", () =>
63 Effect.scoped(
64 Effect.gen(function* () {
65 const registry = yield* setup;
66 registry.set(
67 sessions(env),
68 AsyncResult.success({
69 ...grant(false),
70 scopes: [AuthSettingsWriteScope],
71 permissions: [AuthSettingsWriteScope],
72 }),
73 );
74 registry.set(sessions(other), AsyncResult.success(grant(true)));
75 const command = createCommandPermissions(runtime, WS_METHODS.uiSettingsPatch);
76 expect(registry.get(command.permissionAtom(env))).toBe(true);
77 yield* command.authorize(registry, env);
78 expect(registry.get(command.permissionAtom(other))).toBe(false);
79 expect((yield* command.authorize(registry, other).pipe(Effect.flip))._tag).toBe(
80 "EnvironmentAuthorizationError",
81 );
82 }),
83 ),
84 );
85
62 it.effect("requires the destination settings grant to run storage cleanup", () =>86 it.effect("requires the destination settings grant to run storage cleanup", () =>
63 Effect.scoped(87 Effect.scoped(
64 Effect.gen(function* () {88 Effect.gen(function* () {
packages/client-runtime/src/state/uiSettingsSync.test.ts created+237
...@@ -0,0 +1,237 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2import {
3 EnvironmentId,
4 type UiSettingsProfile,
5 type UiSettingsValues,
6 type UiSettingsPatchResult,
7} from "@t3tools/contracts";
8import { UiSettingsSync, type UiSettingsSyncCache } from "./uiSettingsSync.ts";
9
10const host = EnvironmentId.make("personal");
11const empty: UiSettingsProfile = { revision: 0, values: {}, fieldRevisions: {} };
12const profile: UiSettingsProfile = {
13 revision: 2,
14 values: { fontSizeCode: 16, chatWidth: "wide" },
15 fieldRevisions: { fontSizeCode: 1, chatWidth: 2 },
16};
17
18function setup(saved = profile) {
19 let cache: UiSettingsSyncCache = {
20 environmentId: host,
21 profile: saved,
22 pending: { patch: {}, baseRevisions: {} },
23 };
24 const apply = vi.fn<(values: UiSettingsValues) => Promise<void>>().mockResolvedValue(undefined);
25 const patch = vi.fn<(input: UiSettingsSyncCache["pending"]) => Promise<UiSettingsPatchResult>>();
26 const save = vi.fn(async (value: UiSettingsSyncCache) => {
27 cache = value;
28 });
29 const controller = new UiSettingsSync(cache, { save, apply, patch });
30 return { controller, patch, save, apply, cache: () => cache };
31}
32
33function deferred<A>() {
34 let resolve!: (value: A) => void;
35 let reject!: (reason: unknown) => void;
36 const promise = new Promise<A>((done, fail) => {
37 resolve = done;
38 reject = fail;
39 });
40 return { promise, resolve, reject };
41}
42
43describe("UI preference sync", () => {
44 it("adopts the saved profile without publishing bootstrap defaults or echoing", async () => {
45 const { controller, apply, patch } = setup();
46 await controller.receive(profile);
47 expect(apply).toHaveBeenLastCalledWith(profile.values);
48 controller.edit({ fontSizeCode: 16 });
49 expect(patch).not.toHaveBeenCalled();
50 expect(controller.snapshot.status).toBe("synced");
51 });
52
53 it("restores cached preferences while offline without a bootstrap write", async () => {
54 const { controller, apply, patch } = setup();
55 controller.edit({ fontSizeCode: 18 });
56 await controller.restore();
57 expect(apply).toHaveBeenLastCalledWith({ fontSizeCode: 18, chatWidth: "wide" });
58 expect(patch).not.toHaveBeenCalled();
59 expect(controller.snapshot.status).toBe("offline");
60 });
61
62 it("keeps deliberate offline edits across a restart and reads before sending", async () => {
63 const first = setup();
64 first.controller.edit({ fontSizeCode: 18 });
65 await first.controller.stop();
66 expect(first.patch).not.toHaveBeenCalled();
67 const applied: UiSettingsValues[] = [];
68 const send = vi.fn(
69 async () =>
70 ({
71 profile: {
72 revision: 4,
73 values: { fontSizeCode: 18, chatWidth: "full" },
74 fieldRevisions: { fontSizeCode: 4, chatWidth: 3 },
75 },
76 conflicts: [],
77 }) satisfies UiSettingsPatchResult,
78 );
79 const restarted = new UiSettingsSync(first.cache(), {
80 save: async () => {},
81 apply: async (value) => {
82 applied.push(value);
83 },
84 patch: send,
85 });
86 expect(send).not.toHaveBeenCalled();
87 await restarted.receive({
88 ...profile,
89 revision: 3,
90 values: { ...profile.values, chatWidth: "full" },
91 fieldRevisions: { ...profile.fieldRevisions, chatWidth: 3 },
92 });
93 expect(send).toHaveBeenCalledWith({
94 patch: { fontSizeCode: 18 },
95 baseRevisions: { fontSizeCode: 1 },
96 });
97 expect(applied.at(-1)).toEqual({ fontSizeCode: 18, chatWidth: "full" });
98 });
99
100 it("preserves an edit made while an earlier edit is in flight", async () => {
101 const { controller, patch, cache } = setup(empty);
102 const started = deferred<void>();
103 const response = deferred<UiSettingsPatchResult>();
104 patch.mockImplementationOnce(() => {
105 started.resolve();
106 return response.promise;
107 });
108 patch.mockResolvedValueOnce({
109 profile: { revision: 2, values: { fontSizeCode: 18 }, fieldRevisions: { fontSizeCode: 2 } },
110 conflicts: [],
111 });
112 controller.edit({ fontSizeCode: 16 });
113 const connected = controller.receive(empty);
114 await started.promise;
115 controller.edit({ fontSizeCode: 18 });
116 response.resolve({
117 profile: { revision: 1, values: { fontSizeCode: 16 }, fieldRevisions: { fontSizeCode: 1 } },
118 conflicts: [],
119 });
120 await connected;
121 expect(patch.mock.calls[1]?.[0]).toEqual({
122 patch: { fontSizeCode: 18 },
123 baseRevisions: { fontSizeCode: 1 },
124 });
125 expect(cache().pending.patch).toEqual({});
126 expect(cache().profile.values.fontSizeCode).toBe(18);
127 });
128
129 it("adopts newer saved values on a same-field conflict while retaining independent edits", async () => {
130 const { controller, patch, apply } = setup();
131 controller.edit({ fontSizeCode: 18, timestampFormat: "12-hour" });
132 patch.mockResolvedValue({
133 profile: {
134 revision: 4,
135 values: { fontSizeCode: 20, timestampFormat: "12-hour" },
136 fieldRevisions: { fontSizeCode: 3, timestampFormat: 4 },
137 },
138 conflicts: ["fontSizeCode"],
139 });
140 await controller.receive(profile);
141 expect(apply).toHaveBeenLastCalledWith({ fontSizeCode: 20, timestampFormat: "12-hour" });
142 expect(controller.snapshot.conflicts).toEqual(["fontSizeCode"]);
143 });
144
145 it("does not revive a disconnected client while hydration is applying", async () => {
146 const { controller, patch, apply } = setup();
147 const applying = deferred<void>();
148 const ready = deferred<void>();
149 apply.mockImplementation(() => {
150 ready.resolve();
151 return applying.promise;
152 });
153 controller.edit({ fontSizeCode: 18 });
154 const receiving = controller.receive(profile);
155 await ready.promise;
156 controller.disconnect();
157 applying.resolve();
158 await receiving;
159 expect(patch).not.toHaveBeenCalled();
160 expect(controller.snapshot.status).toBe("offline");
161 });
162
163 it("keeps pending edits when sending fails and retries them after a fresh profile", async () => {
164 const { controller, patch, cache } = setup();
165 controller.edit({ fontSizeCode: 18 });
166 patch.mockRejectedValueOnce(new Error("offline"));
167 await controller.receive(profile);
168 expect(controller.snapshot.status).toBe("error");
169 expect(cache().pending.patch).toEqual({ fontSizeCode: 18 });
170 patch.mockResolvedValue({
171 profile: {
172 ...profile,
173 revision: 3,
174 values: { ...profile.values, fontSizeCode: 18 },
175 fieldRevisions: { ...profile.fieldRevisions, fontSizeCode: 3 },
176 },
177 conflicts: [],
178 });
179 await controller.receive(profile);
180 expect(patch).toHaveBeenCalledTimes(2);
181 expect(controller.snapshot.status).toBe("synced");
182 expect(cache().pending.patch).toEqual({});
183 });
184
185 it("flushes a reconnected client after the old connection's pending request fails", async () => {
186 const { controller, patch, cache } = setup();
187 const started = deferred<void>();
188 const oldResponse = deferred<UiSettingsPatchResult>();
189 patch.mockImplementationOnce(() => {
190 started.resolve();
191 return oldResponse.promise;
192 });
193 patch.mockResolvedValueOnce({
194 profile: {
195 ...profile,
196 revision: 3,
197 values: { ...profile.values, fontSizeCode: 18 },
198 fieldRevisions: { ...profile.fieldRevisions, fontSizeCode: 3 },
199 },
200 conflicts: [],
201 });
202
203 controller.edit({ fontSizeCode: 18 });
204 const firstConnection = controller.receive(profile);
205 await started.promise;
206 controller.disconnect();
207 await controller.receive(profile);
208 oldResponse.reject(new Error("old connection closed"));
209 await firstConnection;
210
211 expect(patch).toHaveBeenCalledTimes(2);
212 expect(cache().pending.patch).toEqual({});
213 expect(cache().profile.values.fontSizeCode).toBe(18);
214 expect(controller.snapshot.status).toBe("synced");
215 });
216
217 it("stops applying and sending after disabling even when an old request finishes", async () => {
218 const { controller, patch, apply } = setup();
219 const started = deferred<void>();
220 const response = deferred<UiSettingsPatchResult>();
221 patch.mockImplementation(() => {
222 started.resolve();
223 return response.promise;
224 });
225 controller.edit({ fontSizeCode: 18 });
226 const connecting = controller.receive(profile);
227 await started.promise;
228 await controller.stop();
229 const calls = apply.mock.calls.length;
230 response.resolve({ profile, conflicts: [] });
231 await connecting;
232 await controller.receive(profile);
233 controller.edit({ chatWidth: "full" });
234 expect(apply).toHaveBeenCalledTimes(calls);
235 expect(patch).toHaveBeenCalledTimes(1);
236 });
237});
packages/client-runtime/src/state/uiSettingsSync.ts created+209
...@@ -0,0 +1,209 @@
1import {
2 UiSettingsProfile,
3 UiSettingsValues,
4 UiSettingsPatchInput,
5 type UiSettingsPatchResult,
6 EnvironmentId,
7 WS_METHODS,
8} from "@t3tools/contracts";
9import * as Equal from "effect/Equal";
10import type * as EnvironmentRegistry from "../connection/registry.ts";
11import type { Atom } from "effect/reactivity";
12import * as Schema from "effect/Schema";
13import {
14 createEnvironmentRpcCommand,
15 createEnvironmentRpcSubscriptionAtomFamily,
16} from "./runtime.ts";
17
18export const UiSettingsSyncCache = Schema.Struct({
19 environmentId: EnvironmentId,
20 profile: UiSettingsProfile,
21 pending: UiSettingsPatchInput,
22});
23export type UiSettingsSyncCache = typeof UiSettingsSyncCache.Type;
24
25/** Platform adapters keep their existing preference stores and apply only supported fields. */
26export class UiSettingsSync {
27 private cache: UiSettingsSyncCache;
28 private connectionGeneration = 0;
29 private stopped = false;
30 private flushing = false;
31 private persistence: Promise<void> = Promise.resolve();
32 private listeners = new Set<() => void>();
33 private status: "offline" | "syncing" | "synced" | "error" = "offline";
34 private conflicts: ReadonlyArray<string> = [];
35
36 private readonly adapter: {
37 readonly save: (cache: UiSettingsSyncCache) => Promise<void>;
38 readonly apply: (values: UiSettingsValues) => Promise<void>;
39 readonly patch: (input: UiSettingsPatchInput) => Promise<UiSettingsPatchResult>;
40 };
41
42 constructor(cache: UiSettingsSyncCache, adapter: UiSettingsSync["adapter"]) {
43 this.cache = cache;
44 this.adapter = adapter;
45 }
46
47 get snapshot() {
48 return { status: this.status, conflicts: this.conflicts };
49 }
50
51 readonly subscribe = (listener: () => void) => {
52 this.listeners.add(listener);
53 return () => {
54 this.listeners.delete(listener);
55 };
56 };
57
58 private notify() {
59 for (const listener of this.listeners) listener();
60 }
61
62 private save() {
63 const cache = this.cache;
64 const saved = this.persistence.then(() => this.adapter.save(cache));
65 this.persistence = saved.catch(() => undefined);
66 return saved;
67 }
68
69 private get online() {
70 return this.status === "syncing" || this.status === "synced";
71 }
72
73 private get values(): UiSettingsValues {
74 return { ...this.cache.profile.values, ...this.cache.pending.patch };
75 }
76
77 /** Records deliberate edits only; hydration and remote applications never call this. */
78 edit(patch: UiSettingsValues) {
79 if (this.stopped) return;
80 const pending = { ...this.cache.pending.patch };
81 const baseRevisions = { ...this.cache.pending.baseRevisions };
82 let changed = false;
83 for (const key of Object.keys(patch) as Array<keyof UiSettingsValues>) {
84 if (Equal.equals(this.values[key], patch[key])) continue;
85 Object.assign(pending, { [key]: patch[key] });
86 baseRevisions[key] ??= this.cache.profile.fieldRevisions[key] ?? 0;
87 changed = true;
88 }
89 if (!changed) return;
90 this.cache = { ...this.cache, pending: { patch: pending, baseRevisions } };
91 this.conflicts = [];
92 this.status = this.online ? "syncing" : "offline";
93 this.notify();
94 void this.save()
95 .then(() => this.flush())
96 .catch(() => this.fail());
97 }
98
99 async restore() {
100 if (this.stopped) return;
101 try {
102 await this.adapter.apply(this.values);
103 } catch {
104 this.fail();
105 }
106 }
107
108 async receive(profile: UiSettingsProfile) {
109 if (this.stopped) return;
110 if (profile.revision < this.cache.profile.revision) return;
111 const generation = this.connectionGeneration;
112 this.cache = { ...this.cache, profile };
113 try {
114 await this.save();
115 if (this.stopped) return;
116 await this.adapter.apply(this.values);
117 if (this.stopped || generation !== this.connectionGeneration) return;
118 this.status = "syncing";
119 await this.flush();
120 } catch {
121 if (generation === this.connectionGeneration) this.fail();
122 }
123 }
124
125 disconnect() {
126 this.connectionGeneration += 1;
127 this.status = "offline";
128 this.notify();
129 }
130
131 stop() {
132 this.stopped = true;
133 this.disconnect();
134 return this.persistence;
135 }
136
137 private fail() {
138 if (this.stopped) return;
139 this.status = "error";
140 this.notify();
141 }
142
143 private async flush() {
144 if (this.flushing || !this.online || this.stopped) return;
145 const generation = this.connectionGeneration;
146 this.flushing = true;
147 try {
148 while (this.online && !this.stopped) {
149 const sent = this.cache.pending;
150 if (Object.keys(sent.patch).length === 0) break;
151 this.status = "syncing";
152 this.notify();
153 const result = await this.adapter.patch(sent);
154 if (this.stopped) return;
155 const patch = { ...this.cache.pending.patch };
156 const baseRevisions = { ...this.cache.pending.baseRevisions };
157 for (const key of Object.keys(sent.patch) as Array<keyof UiSettingsValues>) {
158 if (Equal.equals(patch[key], sent.patch[key])) {
159 delete patch[key];
160 delete baseRevisions[key];
161 } else if (
162 !result.conflicts.includes(key) &&
163 baseRevisions[key] === sent.baseRevisions[key]
164 ) {
165 baseRevisions[key] = result.profile.fieldRevisions[key] ?? 0;
166 }
167 }
168 this.conflicts = result.conflicts;
169 const profile =
170 result.profile.revision >= this.cache.profile.revision
171 ? result.profile
172 : this.cache.profile;
173 this.cache = { ...this.cache, profile, pending: { patch, baseRevisions } };
174 await this.save();
175 if (this.stopped) return;
176 await this.adapter.apply(this.values);
177 }
178 if (this.online && !this.stopped) this.status = "synced";
179 } catch {
180 if (generation === this.connectionGeneration) this.fail();
181 } finally {
182 this.flushing = false;
183 this.notify();
184 if (generation !== this.connectionGeneration && this.online && !this.stopped) {
185 await this.flush();
186 }
187 }
188 }
189}
190
191export function createUiSettingsSyncAtoms<R, E>(
192 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
193) {
194 return {
195 read: createEnvironmentRpcCommand(runtime, {
196 label: "ui-settings:read",
197 tag: WS_METHODS.uiSettingsGet,
198 }),
199 patch: createEnvironmentRpcCommand(runtime, {
200 label: "ui-settings:patch",
201 tag: WS_METHODS.uiSettingsPatch,
202 }),
203 changes: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
204 label: "ui-settings:changes",
205 tag: WS_METHODS.uiSettingsSubscribe,
206 idleTtlMs: 0,
207 }),
208 };
209}
packages/contracts/src/clientRpcPermissions.ts+1
...@@ -11,6 +11,7 @@ import { WS_METHODS } from "./rpc.ts";...@@ -11,6 +11,7 @@ import { WS_METHODS } from "./rpc.ts";
1111
12/** Incremental client enforcement; the server still authorizes every request. */12/** Incremental client enforcement; the server still authorizes every request. */
13export const CLIENT_GUARDED_RPC_SCOPES = {13export const CLIENT_GUARDED_RPC_SCOPES = {
14 [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope,
14 [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope,15 [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope,
15 // Endpoint checks exist to configure exports, so they need the same grant as saving them.16 // Endpoint checks exist to configure exports, so they need the same grant as saving them.
16 [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope,17 [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope,
packages/contracts/src/environment.ts+1
...@@ -96,6 +96,7 @@ export const ServerSelfUpdateCapability = Schema.Literals([...@@ -96,6 +96,7 @@ export const ServerSelfUpdateCapability = Schema.Literals([
96export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type;96export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type;
9797
98export const ExecutionEnvironmentCapabilities = Schema.Struct({98export const ExecutionEnvironmentCapabilities = Schema.Struct({
99 uiSettingsSync: Schema.optionalKey(Schema.Boolean),
99 repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),100 repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
100 connectionProbe: Schema.optionalKey(Schema.Boolean),101 connectionProbe: Schema.optionalKey(Schema.Boolean),
101 /** Missing on older servers, which still accept inline image attachments. */102 /** Missing on older servers, which still accept inline image attachments. */
packages/contracts/src/index.ts+2
...@@ -67,3 +67,5 @@ export * from "./secretRequest.ts";...@@ -67,3 +67,5 @@ export * from "./secretRequest.ts";
67export * from "./clientRpcPermissions.ts";67export * from "./clientRpcPermissions.ts";
68export * from "./desktopTerminal.ts";68export * from "./desktopTerminal.ts";
69export * from "./scriptProgress.ts";69export * from "./scriptProgress.ts";
70
71export * from "./uiSettings.ts";
packages/contracts/src/rpc.ts+29
...@@ -1,3 +1,9 @@...@@ -1,3 +1,9 @@
1import {
2 UiSettingsProfile,
3 UiSettingsPatchInput,
4 UiSettingsPatchResult,
5 UiSettingsError,
6} from "./uiSettings.ts";
1import {7import {
2 ScriptProgressSubscribeInput,8 ScriptProgressSubscribeInput,
3 ScriptProgressEvent,9 ScriptProgressEvent,
...@@ -490,6 +496,9 @@ export const WS_METHODS = {...@@ -490,6 +496,9 @@ export const WS_METHODS = {
490 serverRemoveKeybinding: "server.removeKeybinding",496 serverRemoveKeybinding: "server.removeKeybinding",
491 serverRunStorageCleanup: "server.runStorageCleanup",497 serverRunStorageCleanup: "server.runStorageCleanup",
492 serverGetStorageCleanupReport: "server.getStorageCleanupReport",498 serverGetStorageCleanupReport: "server.getStorageCleanupReport",
499 uiSettingsGet: "uiSettings.get",
500 uiSettingsPatch: "uiSettings.patch",
501 uiSettingsSubscribe: "uiSettings.subscribe",
493 serverGetSettings: "server.getSettings",502 serverGetSettings: "server.getSettings",
494 serverUpdateSettings: "server.updateSettings",503 serverUpdateSettings: "server.updateSettings",
495 serverDiscoverSourceControl: "server.discoverSourceControl",504 serverDiscoverSourceControl: "server.discoverSourceControl",
...@@ -769,6 +778,23 @@ const WsServerGetStorageCleanupReportRpc = Rpc.make(WS_METHODS.serverGetStorageC...@@ -769,6 +778,23 @@ const WsServerGetStorageCleanupReportRpc = Rpc.make(WS_METHODS.serverGetStorageC
769 error: EnvironmentAuthorizationError,778 error: EnvironmentAuthorizationError,
770});779});
771780
781const WsUiSettingsGetRpc = Rpc.make(WS_METHODS.uiSettingsGet, {
782 payload: Schema.Struct({}),
783 success: UiSettingsProfile,
784 error: Schema.Union([UiSettingsError, EnvironmentAuthorizationError]),
785});
786const WsUiSettingsPatchRpc = Rpc.make(WS_METHODS.uiSettingsPatch, {
787 payload: UiSettingsPatchInput,
788 success: UiSettingsPatchResult,
789 error: Schema.Union([UiSettingsError, EnvironmentAuthorizationError]),
790});
791const WsUiSettingsSubscribeRpc = Rpc.make(WS_METHODS.uiSettingsSubscribe, {
792 payload: Schema.Struct({}),
793 success: UiSettingsProfile,
794 stream: true,
795 error: Schema.Union([UiSettingsError, EnvironmentAuthorizationError]),
796});
797
772const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {798const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
773 payload: Schema.Struct({}),799 payload: Schema.Struct({}),
774 success: ServerSettings,800 success: ServerSettings,
...@@ -1936,6 +1962,9 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1936,6 +1962,9 @@ export const WsRpcGroup = RpcGroup.make(
1936 WsServerRemoveKeybindingRpc,1962 WsServerRemoveKeybindingRpc,
1937 WsServerRunStorageCleanupRpc,1963 WsServerRunStorageCleanupRpc,
1938 WsServerGetStorageCleanupReportRpc,1964 WsServerGetStorageCleanupReportRpc,
1965 WsUiSettingsGetRpc,
1966 WsUiSettingsPatchRpc,
1967 WsUiSettingsSubscribeRpc,
1939 WsServerGetSettingsRpc,1968 WsServerGetSettingsRpc,
1940 WsServerUpdateSettingsRpc,1969 WsServerUpdateSettingsRpc,
1941 WsServerDiscoverSourceControlRpc,1970 WsServerDiscoverSourceControlRpc,
packages/contracts/src/uiSettings.ts created+91
...@@ -0,0 +1,91 @@
1import * as Schema from "effect/Schema";
2import * as Struct from "effect/Struct";
3import { ClientSettingsPatch } from "./settings.ts";
4import { EnvironmentTheme } from "./server.ts";
5
6export const UiThemePreferences = Schema.Struct({
7 selection: Schema.String.check(Schema.isMaxLength(128)),
8 mode: Schema.Literals(["system", "light", "dark"]),
9 halves: Schema.Struct({
10 light: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))),
11 dark: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))),
12 }),
13 customThemes: Schema.Array(
14 Schema.Struct({
15 ...EnvironmentTheme.fields,
16 colors: Schema.requiredKey(EnvironmentTheme.fields.colors),
17 collection: Schema.optionalKey(Schema.Struct({ id: Schema.String, label: Schema.String })),
18 managed: Schema.optionalKey(Schema.Boolean),
19 }),
20 ).check(Schema.isMaxLength(64)),
21});
22export type UiThemePreferences = typeof UiThemePreferences.Type;
23
24/** Only portable presentation preferences belong in a personal profile. */
25export const UiSettingsValues = Schema.Struct({
26 ...Struct.pick(ClientSettingsPatch.fields, [
27 "diffColorScheme",
28 "chatWidth",
29 "appearanceContrast",
30 "panelAnimationDurationMs",
31 "diffFilesCollapsed",
32 "diffIgnoreWhitespace",
33 "diffLayout",
34 "environmentIdentificationMode",
35 "fontSizeInterface",
36 "fontSizePrompt",
37 "fontSizeCode",
38 "fontSizeTerminal",
39 "fontFamilyCode",
40 "fontFamilyComposer",
41 "fontFamilySans",
42 "fontFamilyTerminal",
43 "fontSmoothing",
44 "persistComposerContextStrip",
45 "contextWindowMeterEnabled",
46 "composerCollapseOnScroll",
47 "composerRichTextEnabled",
48 "sendShortcut",
49 "followUpBehavior",
50 "proactivePanelsEnabled",
51 "showSkillsInSlashMenu",
52 "sidebarWorkingShelfEnabled",
53 "sidebarProjectGroupingMode",
54 "sidebarProjectSortOrder",
55 "sidebarThreadSortOrder",
56 "sidebarThreadPreviewCount",
57 "timestampFormat",
58 "wordWrap",
59 ]),
60 theme: Schema.optionalKey(UiThemePreferences),
61});
62export type UiSettingsValues = typeof UiSettingsValues.Type;
63
64const Revision = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
65export const UiSettingsProfile = Schema.Struct({
66 revision: Revision,
67 values: UiSettingsValues,
68 fieldRevisions: Schema.Record(Schema.String, Revision),
69});
70export type UiSettingsProfile = typeof UiSettingsProfile.Type;
71
72export const UiSettingsPatchInput = Schema.Struct({
73 patch: UiSettingsValues,
74 baseRevisions: Schema.Record(Schema.String, Revision),
75});
76export type UiSettingsPatchInput = typeof UiSettingsPatchInput.Type;
77
78export const UiSettingsPatchResult = Schema.Struct({
79 profile: UiSettingsProfile,
80 conflicts: Schema.Array(Schema.String),
81});
82export type UiSettingsPatchResult = typeof UiSettingsPatchResult.Type;
83
84export class UiSettingsError extends Schema.TaggedError<UiSettingsError>()("UiSettingsError", {
85 operation: Schema.Literals(["read", "write"]),
86 cause: Schema.Defect(),
87}) {
88 override get message(): string {
89 return "Unable to sync UI settings. Reconnect to the profile environment to try again.";
90 }
91}