| author | |
| committer | |
| log | a4d2ec0fc271b30cf89a4a5153c9b6cf44a986ca |
| tree | 04856b0a706245be4e8390a41a80c275dab22a11 |
| parent | 1af2d44259972f472c2e230e50e41d841169aab4 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
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-sol40 files changed, 2087 insertions(+), 17 deletions(-)
apps/mobile/src/App.tsx+4-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { UiSettingsSyncProvider } from "./state/uiSettingsSync"; | ||
| 1 | import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice"; | 2 | import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice"; |
| 2 | import * as Linking from "expo-linking"; | 3 | import * as Linking from "expo-linking"; |
| 3 | import * as SplashScreen from "expo-splash-screen"; | 4 | import * 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 @@ |
| 1 | import { UiSettingsSyncSection } from "./UiSettingsSyncSection"; | ||
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | 2 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 3 | 4 | ||
| ... | @@ -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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { AsyncResult } from "effect/reactivity"; | ||
| 3 | import { useState } from "react"; | ||
| 4 | import { View } from "react-native"; | ||
| 5 | import type { EnvironmentId, UiSettingsProfile } from "@t3tools/contracts"; | ||
| 6 | import { AppText as Text } from "../../components/AppText"; | ||
| 7 | import { useEnvironments } from "../../state/environments"; | ||
| 8 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 9 | import { | ||
| 10 | uiSettingsSyncAtoms, | ||
| 11 | useUiSettingsSync, | ||
| 12 | useUiSettingsSyncActions, | ||
| 13 | } from "../../state/uiSettingsSync"; | ||
| 14 | import { SettingsSection } from "./components/SettingsSection"; | ||
| 15 | import { SettingsActionRow } from "./components/SettingsActionRow"; | ||
| 16 | import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; | ||
| 17 | |||
| 18 | export 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 for | 4 | * 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 | */ |
| 11 | export type FollowUpBehavior = Extract<ActiveTurnComposerAction, "queue" | "steer">; | 7 | export type FollowUpBehavior = Extract<ActiveTurnComposerAction, "queue" | "steer">; |
| 12 | 8 |
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 | }); |
| 234 | 234 | ||
| 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { portableMobileUiSettings, mobileUiSettingsPatch } from "./uiSettingsSync"; | ||
| 3 | |||
| 4 | describe("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 @@ | ||
| 1 | import type { UiSettingsValues } from "@t3tools/contracts"; | ||
| 2 | import type { Preferences } from "../persistence/mobile-preferences"; | ||
| 3 | |||
| 4 | /** Native typography and palettes have their own units and catalog; only equivalent controls travel. */ | ||
| 5 | export 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 | } | ||
| 18 | export 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 @@ |
| 1 | import { UiSettingsSyncCache } from "@t3tools/client-runtime/state/ui-settings-sync"; | ||
| 1 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| ... | @@ -49,10 +50,12 @@ const PullRequestListPreferences = Schema.Struct({ | ... | @@ -49,10 +50,12 @@ const PullRequestListPreferences = Schema.Struct({ |
| 49 | }); | 50 | }); |
| 50 | export type PullRequestListPreferences = typeof PullRequestListPreferences.Type; | 51 | export type PullRequestListPreferences = typeof PullRequestListPreferences.Type; |
| 51 | 52 | ||
| 53 | const isUiSettingsSyncCache = Schema.is(UiSettingsSyncCache); | ||
| 52 | const isThreadListFilters = Schema.is(ThreadListFilters); | 54 | const isThreadListFilters = Schema.is(ThreadListFilters); |
| 53 | const isPullRequestListPreferences = Schema.is(PullRequestListPreferences); | 55 | const isPullRequestListPreferences = Schema.is(PullRequestListPreferences); |
| 54 | 56 | ||
| 55 | export interface Preferences { | 57 | export 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< |
| 136 | 139 | ||
| 137 | function sanitizePreferences(parsed: Preferences): Preferences { | 140 | function 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 | } |
| 288 | 296 |
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 | } |
| 11 | 11 | ||
| 12 | // A bare function is interpreted by useAtomSet as an update to the command's | 12 | const editListeners = new Set<(patch: Partial<MobilePreferences.Preferences>) => void>(); |
| 13 | // AsyncResult, not as a preference transform. Keep transforms inside a payload. | 13 | export 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. | ||
| 14 | type PreferencesUpdate = | 23 | type 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 update | 79 | "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 @@ | ||
| 1 | import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | UiSettingsSync, | ||
| 4 | createUiSettingsSyncAtoms, | ||
| 5 | type UiSettingsSyncCache, | ||
| 6 | } from "@t3tools/client-runtime/state/ui-settings-sync"; | ||
| 7 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | ||
| 8 | import type { EnvironmentId, UiSettingsProfile } from "@t3tools/contracts"; | ||
| 9 | import * as Cause from "effect/Cause"; | ||
| 10 | import { AsyncResult } from "effect/reactivity"; | ||
| 11 | import { | ||
| 12 | createContext, | ||
| 13 | createElement, | ||
| 14 | use, | ||
| 15 | useEffect, | ||
| 16 | useCallback, | ||
| 17 | useRef, | ||
| 18 | useSyncExternalStore, | ||
| 19 | type PropsWithChildren, | ||
| 20 | } from "react"; | ||
| 21 | import { portableMobileUiSettings, mobileUiSettingsPatch } from "../lib/uiSettingsSync"; | ||
| 22 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 23 | import { | ||
| 24 | mobilePreferencesAtom, | ||
| 25 | updateMobilePreferencesAtom, | ||
| 26 | subscribeMobilePreferenceEdits, | ||
| 27 | } from "./preferences"; | ||
| 28 | import { useEnvironments } from "./environments"; | ||
| 29 | import { resolveMobileProjectGroupingSettings } from "./project-grouping"; | ||
| 30 | |||
| 31 | export const uiSettingsSyncAtoms = createUiSettingsSyncAtoms(connectionAtomRuntime); | ||
| 32 | const listeners = new Set<() => void>(); | ||
| 33 | let 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 | }; | ||
| 44 | function notify() { | ||
| 45 | for (const listener of listeners) listener(); | ||
| 46 | } | ||
| 47 | export function useUiSettingsSync() { | ||
| 48 | return useSyncExternalStore( | ||
| 49 | (listener) => { | ||
| 50 | listeners.add(listener); | ||
| 51 | return () => { | ||
| 52 | listeners.delete(listener); | ||
| 53 | }; | ||
| 54 | }, | ||
| 55 | () => state, | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | function 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 | |||
| 195 | const UiSettingsSyncContext = createContext<ReturnType<typeof usePersonalUiSettingsSync> | null>( | ||
| 196 | null, | ||
| 197 | ); | ||
| 198 | export function UiSettingsSyncProvider({ children }: PropsWithChildren) { | ||
| 199 | const actions = usePersonalUiSettingsSync(); | ||
| 200 | return createElement(UiSettingsSyncContext.Provider, { value: actions }, children); | ||
| 201 | } | ||
| 202 | export 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 { |
| 32 | import * as RpcAuthorization from "./RpcAuthorization.ts"; | 32 | import * as RpcAuthorization from "./RpcAuthorization.ts"; |
| 33 | 33 | ||
| 34 | describe("RPC authorization scopes", () => { | 34 | describe("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 | */ |
| 45 | export const RPC_REQUIRED_SCOPES = { | 45 | export 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 | */ |
| 17 | const RPC_AGGREGATES = { | 17 | const 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 @@ |
| 1 | import * as UiSettings from "./uiSettings.ts"; | ||
| 1 | import * as ScriptProgress from "./progress/ScriptProgress.ts"; | 2 | import * as ScriptProgress from "./progress/ScriptProgress.ts"; |
| 2 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | 3 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 3 | import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; | 4 | import 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 @@ | ||
| 1 | import { UiSettingsProfile } from "@t3tools/contracts"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { assert, it } from "@effect/vitest"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import * as ServerConfig from "./config.ts"; | ||
| 10 | import * as UiSettings from "./uiSettings.ts"; | ||
| 11 | |||
| 12 | const encodeProfile = Schema.encodeEffect(Schema.fromJsonString(UiSettingsProfile)); | ||
| 13 | const decodeProfile = Schema.decodeUnknownEffect(Schema.fromJsonString(UiSettingsProfile)); | ||
| 14 | |||
| 15 | it.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 @@ | ||
| 1 | import { | ||
| 2 | UiSettingsError, | ||
| 3 | UiSettingsProfile, | ||
| 4 | type UiSettingsPatchInput, | ||
| 5 | type UiSettingsPatchResult, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Context from "effect/Context"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Equal from "effect/Equal"; | ||
| 10 | import * as FileSystem from "effect/FileSystem"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Path from "effect/Path"; | ||
| 13 | import * as Result from "effect/Result"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import * as Semaphore from "effect/Semaphore"; | ||
| 16 | import * as Stream from "effect/Stream"; | ||
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 18 | import * as ServerConfig from "./config.ts"; | ||
| 19 | |||
| 20 | export 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 | |||
| 31 | const 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 | |||
| 98 | export const layer = Layer.effect(UiSettings, make); | ||
apps/server/src/ws.ts+5| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as UiSettings from "./uiSettings.ts"; | ||
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | 2 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; |
| 2 | import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; | 3 | import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; |
| 3 | import * as Crypto from "effect/Crypto"; | 4 | import * 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 @@ |
| 1 | import { UiSettingsSyncSection } from "./UiSettingsSyncSection"; | ||
| 1 | import { performDesktopUpdate } from "../desktopUpdate.actions"; | 2 | import { performDesktopUpdate } from "../desktopUpdate.actions"; |
| 2 | import { useTerminalPlacement } from "~/hooks/useTerminalPlacement"; | 3 | import { useTerminalPlacement } from "~/hooks/useTerminalPlacement"; |
| 3 | import { SettingsGroup } from "./SettingsGroup"; | 4 | import { SettingsGroup } from "./SettingsGroup"; |
| ... | @@ -2220,6 +2221,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2220,6 +2221,7 @@ export function GeneralSettingsPanel() { |
| 2220 | 2221 | ||
| 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 | <SettingsRow | 2227 | <SettingsRow |
apps/web/src/components/settings/UiSettingsSyncSection.tsx created+184| ... | @@ -0,0 +1,184 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { AsyncResult } from "effect/reactivity"; | ||
| 3 | import { useState } from "react"; | ||
| 4 | import { EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts"; | ||
| 5 | import { Button } from "../ui/button"; | ||
| 6 | import { Select, SelectTrigger, SelectPopup, SelectItem, SelectValue } from "../ui/select"; | ||
| 7 | import { useEnvironments } from "../../state/environments"; | ||
| 8 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 9 | import { | ||
| 10 | disableUiSettingsSync, | ||
| 11 | enableUiSettingsSync, | ||
| 12 | uiSettingsSyncAtoms, | ||
| 13 | useUiSettingsSync, | ||
| 14 | } from "../../state/uiSettingsSync"; | ||
| 15 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | ||
| 16 | import { searchableSetting } from "./settingsSearch"; | ||
| 17 | |||
| 18 | export 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; |
| 12 | 13 | ||
| 13 | describe("default theme adoption", () => { | 14 | describe("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 @@ |
| 1 | import { personalUiSettingsSyncEnabled, useUiSettingsSync } from "../state/uiSettingsSync"; | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useEffect } from "react"; | 3 | import { useEffect } from "react"; |
| 3 | 4 | ||
| ... | @@ -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 | */ |
| 73 | export function useDefaultThemeAdoption(): void { | 76 | export 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; |
| 94 | 104 | ||
| ... | @@ -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]"; |
| 67 | 67 | ||
| 68 | type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; | 68 | type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; |
| 69 | 69 | ||
| 70 | const clientSettingsEditListeners = new Set<(patch: ClientSettingsPatch) => void>(); | ||
| 71 | |||
| 72 | export function subscribeClientSettingsEdits(listener: (patch: ClientSettingsPatch) => void) { | ||
| 73 | clientSettingsEditListeners.add(listener); | ||
| 74 | return () => { | ||
| 75 | clientSettingsEditListeners.delete(listener); | ||
| 76 | }; | ||
| 77 | } | ||
| 78 | |||
| 70 | const clientSettingsListeners = new Set<() => void>(); | 79 | const clientSettingsListeners = new Set<() => void>(); |
| 71 | const clientSettingsHydrationListeners = new Set<() => void>(); | 80 | const clientSettingsHydrationListeners = new Set<() => void>(); |
| 72 | type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying"; | 81 | type 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> |
| 191 | export function persistClientSettingsPatch( | 200 | export 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 | } |
| 222 | 236 |
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"; |
| 25 | 27 | ||
| 28 | import type { UiThemePreferences } from "@t3tools/contracts"; | ||
| 29 | |||
| 26 | type Theme = ThemePreference; | 30 | type Theme = ThemePreference; |
| 31 | const themeEditListeners = new Set<() => void>(); | ||
| 32 | export function subscribeThemeEdits(listener: () => void) { | ||
| 33 | themeEditListeners.add(listener); | ||
| 34 | return () => { | ||
| 35 | themeEditListeners.delete(listener); | ||
| 36 | }; | ||
| 37 | } | ||
| 38 | function emitThemeEdit() { | ||
| 39 | for (const listener of themeEditListeners) listener(); | ||
| 40 | } | ||
| 41 | |||
| 42 | export 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 | |||
| 54 | export 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 | |||
| 27 | type ThemeSnapshot = { | 65 | type 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 | }, []); |
| 560 | 599 | ||
| ... | @@ -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 | }, []); |
| 585 | 625 | ||
| ... | @@ -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 | }, []); |
| 653 | 695 |
apps/web/src/routes/__root.tsx+2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { usePersonalUiSettingsSync } from "../state/uiSettingsSync"; | ||
| 1 | import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice"; | 2 | import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice"; |
| 2 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; | 3 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; |
| 3 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; | 4 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| ... | @@ -143,6 +144,7 @@ function RootRouteNotFoundView() { | ... | @@ -143,6 +144,7 @@ function RootRouteNotFoundView() { |
| 143 | } | 144 | } |
| 144 | 145 | ||
| 145 | function RootRouteView() { | 146 | function 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 @@ | ||
| 1 | import { RegistryContext } from "@effect/atom-react"; | ||
| 2 | import { EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts"; | ||
| 3 | import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings"; | ||
| 4 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | ||
| 5 | import { act, useEffect } from "react"; | ||
| 6 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 7 | import { afterEach, expect, it, vi } from "vite-plus/test"; | ||
| 8 | |||
| 9 | const 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 | })); | ||
| 18 | vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: undefined })); | ||
| 19 | vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: undefined })); | ||
| 20 | vi.mock("../localApi", () => ({ | ||
| 21 | ensureLocalApi: () => ({ | ||
| 22 | persistence: { getClientSettings: mocks.load, setClientSettings: mocks.persist }, | ||
| 23 | }), | ||
| 24 | })); | ||
| 25 | vi.mock("./session", () => ({ | ||
| 26 | environmentSession: {}, | ||
| 27 | readEnvironmentScope: () => false, | ||
| 28 | useEnvironmentScope: () => false, | ||
| 29 | })); | ||
| 30 | vi.mock("./server", () => ({ serverEnvironment: {}, primaryServerSettingsAtom: undefined })); | ||
| 31 | vi.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 | })); | ||
| 43 | vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({ | ||
| 44 | ...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()), | ||
| 45 | runAtomCommand: mocks.rpc, | ||
| 46 | })); | ||
| 47 | vi.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 | |||
| 56 | import { | ||
| 57 | ensureClientSettingsHydrated, | ||
| 58 | getClientSettings, | ||
| 59 | persistClientSettingsPatch, | ||
| 60 | subscribeClientSettingsEdits, | ||
| 61 | useClientSettings, | ||
| 62 | __resetClientSettingsPersistenceForTests, | ||
| 63 | } from "../hooks/useSettings"; | ||
| 64 | import { readUiThemePreferences, useTheme } from "../hooks/useTheme"; | ||
| 65 | import { getStandardThemeColors, getCustomThemes } from "../themePalette"; | ||
| 66 | import { | ||
| 67 | enableUiSettingsSync, | ||
| 68 | disableUiSettingsSync, | ||
| 69 | usePersonalUiSettingsSync, | ||
| 70 | } from "./uiSettingsSync"; | ||
| 71 | |||
| 72 | let renderer: ReactTestRenderer | undefined; | ||
| 73 | const registry = AtomRegistry.make(); | ||
| 74 | afterEach(async () => { | ||
| 75 | await act(async () => renderer?.unmount()); | ||
| 76 | renderer = undefined; | ||
| 77 | await disableUiSettingsSync(); | ||
| 78 | __resetClientSettingsPersistenceForTests(); | ||
| 79 | vi.unstubAllGlobals(); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("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 @@ | ||
| 1 | import { RegistryContext } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | UiSettingsSync, | ||
| 4 | UiSettingsSyncCache, | ||
| 5 | createUiSettingsSyncAtoms, | ||
| 6 | type UiSettingsSyncCache as SyncCache, | ||
| 7 | } from "@t3tools/client-runtime/state/ui-settings-sync"; | ||
| 8 | import { UiSettingsValues, type EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts"; | ||
| 9 | import * as Cause from "effect/Cause"; | ||
| 10 | import * as Equal from "effect/Equal"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as Struct from "effect/Struct"; | ||
| 13 | import { AsyncResult } from "effect/reactivity"; | ||
| 14 | import { use, useEffect, useSyncExternalStore } from "react"; | ||
| 15 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 16 | import { | ||
| 17 | ensureClientSettingsHydrated, | ||
| 18 | getClientSettings, | ||
| 19 | persistClientSettingsPatch, | ||
| 20 | subscribeClientSettingsEdits, | ||
| 21 | } from "../hooks/useSettings"; | ||
| 22 | import { | ||
| 23 | applyUiThemePreferences, | ||
| 24 | readUiThemePreferences, | ||
| 25 | subscribeThemeEdits, | ||
| 26 | } from "../hooks/useTheme"; | ||
| 27 | import { subscribeToCustomThemeEdits } from "../themePalette"; | ||
| 28 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 29 | import { useEnvironments } from "./environments"; | ||
| 30 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | ||
| 31 | |||
| 32 | export const uiSettingsSyncAtoms = createUiSettingsSyncAtoms(connectionAtomRuntime); | ||
| 33 | const STORAGE_KEY = "knife:ui-settings-sync:v1"; | ||
| 34 | const json = Schema.fromJsonString(UiSettingsSyncCache); | ||
| 35 | const decodeCache = Schema.decodeUnknownSync(json); | ||
| 36 | const decodeValues = Schema.decodeUnknownSync(UiSettingsValues); | ||
| 37 | const listeners = new Set<() => void>(); | ||
| 38 | let 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 | |||
| 45 | function notify() { | ||
| 46 | for (const listener of listeners) listener(); | ||
| 47 | } | ||
| 48 | |||
| 49 | export 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 | |||
| 58 | export function useUiSettingsSync() { | ||
| 59 | return useSyncExternalStore( | ||
| 60 | (listener) => { | ||
| 61 | listeners.add(listener); | ||
| 62 | return () => { | ||
| 63 | listeners.delete(listener); | ||
| 64 | }; | ||
| 65 | }, | ||
| 66 | () => state, | ||
| 67 | ); | ||
| 68 | } | ||
| 69 | |||
| 70 | async 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 | |||
| 82 | function 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 | |||
| 113 | export 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 | |||
| 132 | export 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. */ | ||
| 142 | export 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 @@ |
| 1 | import type { UiThemePreferences } from "@t3tools/contracts"; | ||
| 1 | import * as Equal from "effect/Equal"; | 2 | import * as Equal from "effect/Equal"; |
| 2 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 3 | import "culori/css"; | 4 | import "culori/css"; |
| ... | @@ -242,6 +243,51 @@ export function invalidateCustomThemes() { | ... | @@ -242,6 +243,51 @@ export function invalidateCustomThemes() { |
| 242 | 243 | ||
| 243 | const EMPTY_CUSTOM_THEMES: ReadonlyArray<ThemeDefinition> = []; | 244 | const EMPTY_CUSTOM_THEMES: ReadonlyArray<ThemeDefinition> = []; |
| 244 | 245 | ||
| 246 | const customThemeEditListeners = new Set<() => void>(); | ||
| 247 | export function subscribeToCustomThemeEdits(listener: () => void) { | ||
| 248 | customThemeEditListeners.add(listener); | ||
| 249 | return () => { | ||
| 250 | customThemeEditListeners.delete(listener); | ||
| 251 | }; | ||
| 252 | } | ||
| 253 | |||
| 254 | export 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 | |||
| 276 | export 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 | |||
| 245 | export function getCustomThemes(): ReadonlyArray<ThemeDefinition> { | 291 | export 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 |
| 1169 | function saveCustomThemes( | 1215 | function 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 | } |
| 1187 | 1235 | ||
| 1188 | function requireWritableCustomThemeLibrary( | 1236 | function requireWritableCustomThemeLibrary( |
docs/user/appearance.md+18-2| ... | @@ -2,8 +2,7 @@ | ... | @@ -2,8 +2,7 @@ |
| 2 | 2 | ||
| 3 | On web and desktop, open **Settings → Appearance** to choose a theme and follow the system | 3 | On web and desktop, open **Settings → Appearance** to choose a theme and follow the system |
| 4 | appearance or stay in light or dark mode. To use different themes for light and dark mode, select | 4 | appearance or stay in light or dark mode. To use different themes for light and dark mode, select |
| 5 | the corresponding preview within each theme. Appearance preferences are saved separately on each | 5 | the corresponding preview within each theme. Appearance preferences stay on each device or browser unless you enable UI settings sync. |
| 6 | device or browser. | ||
| 7 | 6 | ||
| 8 | On web and desktop, use **Change theme** in the command palette to select a theme without leaving chat. | 7 | On web and desktop, use **Change theme** in the command palette to select a theme without leaving chat. |
| 9 | Press **Cmd+Option+A** on macOS or **Ctrl+Alt+A** on Windows/Linux to open the theme picker directly. | 8 | Press **Cmd+Option+A** on macOS or **Ctrl+Alt+A** on Windows/Linux to open the theme picker directly. |
| ... | @@ -21,6 +20,23 @@ Android uses **Material You Layout** by default unless you have turned it off in | ... | @@ -21,6 +20,23 @@ Android uses **Material You Layout** by default unless you have turned it off in |
| 21 | It changes shapes, spacing, and controls independently | 20 | It changes shapes, spacing, and controls independently |
| 22 | of the selected theme. | 21 | of the selected theme. |
| 23 | 22 | ||
| 23 | ## Sync across devices | ||
| 24 | |||
| 25 | On web or desktop, open **Settings → General → UI settings sync** and choose a private connected | ||
| 26 | environment. Read its profile, then choose **Publish current** to save this device’s preferences | ||
| 27 | or **Use saved** to adopt the existing profile. Repeat on each participating device using the same | ||
| 28 | environment. Themes, custom palettes, fonts, and chat presentation follow the profile; window | ||
| 29 | layout, credentials, permissions, and native integrations stay local. The personal profile takes | ||
| 30 | precedence over environment theme defaults. | ||
| 31 | |||
| 32 | Mobile joins from **Settings → Appearance → UI settings sync**. It shares follow-up behavior, | ||
| 33 | project grouping, and the working shelf. Native themes and text sizes stay on the device. | ||
| 34 | |||
| 35 | Changes made offline stay usable locally and sync when the profile environment reconnects. When | ||
| 36 | two devices change the same preference from an older saved version, the newer saved choice wins. | ||
| 37 | Change it again to use your choice. **Disable sync** keeps the current preferences on this device; | ||
| 38 | choosing another environment starts the same publish-or-adopt flow. | ||
| 39 | |||
| 24 | ## Composer context | 40 | ## Composer context |
| 25 | 41 | ||
| 26 | Git-backed projects show branch and worktree controls below the composer while you create a thread. | 42 | Git-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; |
| 48 | type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag]; | 48 | type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag]; |
| 49 | 49 | ||
| 50 | export type EnvironmentSubscriptionRpcTag = | 50 | export type EnvironmentSubscriptionRpcTag = |
| 51 | | typeof WS_METHODS.uiSettingsSubscribe | ||
| 51 | | typeof WS_METHODS.subscribeScriptProgress | 52 | | typeof WS_METHODS.subscribeScriptProgress |
| 52 | | typeof WS_METHODS.codexAuthCallbackSubscribe | 53 | | typeof WS_METHODS.codexAuthCallbackSubscribe |
| 53 | | typeof WS_METHODS.providerAuthSubscribe | 54 | | 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 | }); |
| 60 | 60 | ||
| 61 | describe("command permissions", () => { | 61 | describe("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 @@ | ||
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | import { | ||
| 3 | EnvironmentId, | ||
| 4 | type UiSettingsProfile, | ||
| 5 | type UiSettingsValues, | ||
| 6 | type UiSettingsPatchResult, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { UiSettingsSync, type UiSettingsSyncCache } from "./uiSettingsSync.ts"; | ||
| 9 | |||
| 10 | const host = EnvironmentId.make("personal"); | ||
| 11 | const empty: UiSettingsProfile = { revision: 0, values: {}, fieldRevisions: {} }; | ||
| 12 | const profile: UiSettingsProfile = { | ||
| 13 | revision: 2, | ||
| 14 | values: { fontSizeCode: 16, chatWidth: "wide" }, | ||
| 15 | fieldRevisions: { fontSizeCode: 1, chatWidth: 2 }, | ||
| 16 | }; | ||
| 17 | |||
| 18 | function 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 | |||
| 33 | function 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 | |||
| 43 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | UiSettingsProfile, | ||
| 3 | UiSettingsValues, | ||
| 4 | UiSettingsPatchInput, | ||
| 5 | type UiSettingsPatchResult, | ||
| 6 | EnvironmentId, | ||
| 7 | WS_METHODS, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Equal from "effect/Equal"; | ||
| 10 | import type * as EnvironmentRegistry from "../connection/registry.ts"; | ||
| 11 | import type { Atom } from "effect/reactivity"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | import { | ||
| 14 | createEnvironmentRpcCommand, | ||
| 15 | createEnvironmentRpcSubscriptionAtomFamily, | ||
| 16 | } from "./runtime.ts"; | ||
| 17 | |||
| 18 | export const UiSettingsSyncCache = Schema.Struct({ | ||
| 19 | environmentId: EnvironmentId, | ||
| 20 | profile: UiSettingsProfile, | ||
| 21 | pending: UiSettingsPatchInput, | ||
| 22 | }); | ||
| 23 | export type UiSettingsSyncCache = typeof UiSettingsSyncCache.Type; | ||
| 24 | |||
| 25 | /** Platform adapters keep their existing preference stores and apply only supported fields. */ | ||
| 26 | export 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 | |||
| 191 | export 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"; |
| 11 | 11 | ||
| 12 | /** Incremental client enforcement; the server still authorizes every request. */ | 12 | /** Incremental client enforcement; the server still authorizes every request. */ |
| 13 | export const CLIENT_GUARDED_RPC_SCOPES = { | 13 | export 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([ |
| 96 | export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; | 96 | export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; |
| 97 | 97 | ||
| 98 | export const ExecutionEnvironmentCapabilities = Schema.Struct({ | 98 | export 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"; |
| 67 | export * from "./clientRpcPermissions.ts"; | 67 | export * from "./clientRpcPermissions.ts"; |
| 68 | export * from "./desktopTerminal.ts"; | 68 | export * from "./desktopTerminal.ts"; |
| 69 | export * from "./scriptProgress.ts"; | 69 | export * from "./scriptProgress.ts"; |
| 70 | |||
| 71 | export * from "./uiSettings.ts"; |
packages/contracts/src/rpc.ts+29| ... | @@ -1,3 +1,9 @@ | ... | @@ -1,3 +1,9 @@ |
| 1 | import { | ||
| 2 | UiSettingsProfile, | ||
| 3 | UiSettingsPatchInput, | ||
| 4 | UiSettingsPatchResult, | ||
| 5 | UiSettingsError, | ||
| 6 | } from "./uiSettings.ts"; | ||
| 1 | import { | 7 | import { |
| 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 | }); |
| 771 | 780 | ||
| 781 | const WsUiSettingsGetRpc = Rpc.make(WS_METHODS.uiSettingsGet, { | ||
| 782 | payload: Schema.Struct({}), | ||
| 783 | success: UiSettingsProfile, | ||
| 784 | error: Schema.Union([UiSettingsError, EnvironmentAuthorizationError]), | ||
| 785 | }); | ||
| 786 | const WsUiSettingsPatchRpc = Rpc.make(WS_METHODS.uiSettingsPatch, { | ||
| 787 | payload: UiSettingsPatchInput, | ||
| 788 | success: UiSettingsPatchResult, | ||
| 789 | error: Schema.Union([UiSettingsError, EnvironmentAuthorizationError]), | ||
| 790 | }); | ||
| 791 | const WsUiSettingsSubscribeRpc = Rpc.make(WS_METHODS.uiSettingsSubscribe, { | ||
| 792 | payload: Schema.Struct({}), | ||
| 793 | success: UiSettingsProfile, | ||
| 794 | stream: true, | ||
| 795 | error: Schema.Union([UiSettingsError, EnvironmentAuthorizationError]), | ||
| 796 | }); | ||
| 797 | |||
| 772 | const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { | 798 | const 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 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import * as Struct from "effect/Struct"; | ||
| 3 | import { ClientSettingsPatch } from "./settings.ts"; | ||
| 4 | import { EnvironmentTheme } from "./server.ts"; | ||
| 5 | |||
| 6 | export 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 | }); | ||
| 22 | export type UiThemePreferences = typeof UiThemePreferences.Type; | ||
| 23 | |||
| 24 | /** Only portable presentation preferences belong in a personal profile. */ | ||
| 25 | export 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 | }); | ||
| 62 | export type UiSettingsValues = typeof UiSettingsValues.Type; | ||
| 63 | |||
| 64 | const Revision = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); | ||
| 65 | export const UiSettingsProfile = Schema.Struct({ | ||
| 66 | revision: Revision, | ||
| 67 | values: UiSettingsValues, | ||
| 68 | fieldRevisions: Schema.Record(Schema.String, Revision), | ||
| 69 | }); | ||
| 70 | export type UiSettingsProfile = typeof UiSettingsProfile.Type; | ||
| 71 | |||
| 72 | export const UiSettingsPatchInput = Schema.Struct({ | ||
| 73 | patch: UiSettingsValues, | ||
| 74 | baseRevisions: Schema.Record(Schema.String, Revision), | ||
| 75 | }); | ||
| 76 | export type UiSettingsPatchInput = typeof UiSettingsPatchInput.Type; | ||
| 77 | |||
| 78 | export const UiSettingsPatchResult = Schema.Struct({ | ||
| 79 | profile: UiSettingsProfile, | ||
| 80 | conflicts: Schema.Array(Schema.String), | ||
| 81 | }); | ||
| 82 | export type UiSettingsPatchResult = typeof UiSettingsPatchResult.Type; | ||
| 83 | |||
| 84 | export 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 | } | ||