diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index d655290d175c..34251dff1e93 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -821,6 +821,7 @@ enum class GatewayEvent( DevicePairResolved("device.pair.resolved"), DevicePairSetupCompleted("device.pair.setup.completed"), DevicePairSetupDeliveryUncertain("device.pair.setup.deliveryUncertain"), + UsersPrefsChanged("users.prefs.changed"), SkillsChanged("skills.changed"), VoicewakeChanged("voicewake.changed"), VoicewakeRoutingChanged("voicewake.routing.changed"), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 20eefcef556b..969e1a67de43 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -11255,6 +11255,24 @@ public struct AuditListResult: Codable, Sendable { } } +public struct UsersPrefsChangedEvent: Codable, Sendable { + public let profileid: String + public let keys: [String] + + public init( + profileid: String, + keys: [String]) + { + self.profileid = profileid + self.keys = keys + } + + private enum CodingKeys: String, CodingKey { + case profileid = "profileId" + case keys + } +} + public struct TaskSuggestion: Codable, Sendable { public let id: String public let title: String diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index d0688e503d8a..0436d1d823b0 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -3949,7 +3949,7 @@ ui/src/app/native-window-drag.ts 1 ui/src/app/overlays.ts 1 ui/src/app/question-prompt.ts 2 ui/src/app/server-prefs-state.ts 14 -ui/src/app/server-prefs.ts 17 +ui/src/app/server-prefs.ts 15 ui/src/app/settings.ts 9 ui/src/app/stale-chunk-reload.ts 1 ui/src/app/startup-settings.ts 1 diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index efe4335a8e22..614462707c88 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -1,5 +1,5 @@ { - "startupJsGzipBytes": 341333, - "reason": "Tide, Beacon, and Phosphor themes: theme-name wiring, three appearance options, locale strings, and two font stylesheet mappings", + "startupJsGzipBytes": 342930, + "reason": "per-profile appearance preferences: profile-scoped resolution, users.prefs routing, and live cross-device sync on the boot path; measured 342875 B on the tide/beacon/phosphor base, pinned with jitter headroom", "updatedAt": "2026-08-26" } diff --git a/docs/concepts/user-model.md b/docs/concepts/user-model.md index 292d61223b11..71a6c2552c9a 100644 --- a/docs/concepts/user-model.md +++ b/docs/concepts/user-model.md @@ -30,6 +30,12 @@ OpenClaw supplies exact trailers and the ordered contributor list in the model c Turning **Git co-author credit** off stops attribution for future runs. It does not rewrite commits that already contain the public trailer. +## Profile appearance preferences + +When a Control UI connection is bound to an authenticated Gateway profile, its theme, theme mode, and accent color are stored per profile in the existing `user_preferences` table in the shared state database. Those choices follow that person across devices without changing appearance for other people on the same Gateway. + +Profile theme and theme mode preferences override their gateway-wide `ui.prefs` settings and otherwise fall back to the active theme's defaults. The imported custom theme is the exception: its palette lives only in the browser that imported it, so selecting it stays browser-local and never follows the profile. Accent precedence is the profile's `ui.accent` preference, gateway-wide `ui.prefs.accent`, `ui.seamColor`, and finally the active theme's default accent. Restoring a default clears only the profile preference. Connections without an authenticated profile, including token, password, or unauthenticated connections, keep the existing gateway-wide appearance behavior unchanged. Language, chat preferences, and sidebar entries continue using gateway configuration. + ## Write directives, not observations Each entry has a metadata line followed by one imperative directive: diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 1c091b4dc31f..600c7b4f4b94 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -153,11 +153,11 @@ The Appearance panel has the built-in Claw, Knot, Dash, Absolutely, Tide, Beacon Imported themes are stored only in the current browser profile; they are not written to gateway config and do not sync across devices. Replacing the imported theme updates the one local slot; clearing it switches back to Claw if the imported theme was active. -Choose an **Accent color** preset or custom color in Appearance to override the active theme's accent. This preference takes precedence over the operator-configured `ui.seamColor`. **Restore default** clears the preference and restores `ui.seamColor` when configured, or the theme's own accent otherwise. +Choose an **Accent color** preset or custom color in Appearance to override the active theme's accent. For an authenticated Gateway profile, the accent precedence is the profile's `ui.accent` preference, the gateway-wide `ui.prefs.accent` setting, the operator-configured `ui.seamColor`, and finally the active theme's default. **Restore default** clears only that profile's preference, leaving the gateway-wide settings unchanged. Connections without an authenticated profile keep the existing gateway-wide preference behavior. Appearance also has a Text size setting. It applies to chat text, composer text, tool cards, and chat sidebars, and keeps text inputs at least 16px so mobile Safari does not auto-zoom on focus. -Theme, theme mode, accent color, language, and chat display preferences sync through the gateway config (`ui.prefs`), so they follow you across devices and agents can change them through the approval gate — connected clients apply changes live via the gateway's `config.changed` notice. Each browser keeps a local mirror for instant boot. Text size remains browser-local. An explicitly read-only connection applies preference changes only in that browser and does not attempt a config write. Changes made while offline remain queued until a later connection can write config; on a read-only reconnect, they continue to behave as browser-local preferences. See [Configuration reference](/gateway/configuration-reference#ui). +When your connection is bound to an authenticated Gateway profile, theme, theme mode, and accent color are saved to that profile instead of the gateway config. They follow you across devices without changing anyone else's appearance, override gateway-wide `ui.prefs` values, and update your connected clients live. Connections without an authenticated profile continue syncing these preferences through the gateway config exactly as before. Language and chat display preferences remain gateway-config preferences for every connection. Each browser keeps a local mirror for instant boot, and text size remains browser-local. An explicitly read-only connection applies preference changes only in that browser. Changes made while offline remain queued until a later connection can write their applicable preferences; on a read-only reconnect, they continue to behave as browser-local preferences. See [Configuration reference](/gateway/configuration-reference#ui). ## OpenClaw system care diff --git a/packages/gateway-protocol/src/public-schema.ts b/packages/gateway-protocol/src/public-schema.ts index f2b01ce9ac5c..cd1f39bf46ea 100644 --- a/packages/gateway-protocol/src/public-schema.ts +++ b/packages/gateway-protocol/src/public-schema.ts @@ -331,6 +331,8 @@ export { AuditListParamsSchema, AuditListResultSchema, GIT_COAUTHOR_PREFERENCE_KEY, + normalizeUiAppearancePreference, + UI_APPEARANCE_PREFERENCE_KEYS, UserProfileSchema, UsersLinkEmailParamsSchema, UsersLinkEmailResultSchema, @@ -338,6 +340,7 @@ export { UsersListResultSchema, UsersPrefsGetParamsSchema, UsersPrefsGetResultSchema, + UsersPrefsChangedEventSchema, UsersPrefsSetParamsSchema, UsersPrefsSetResultSchema, UsersSelfParamsSchema, diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts index f44a175b2976..91b8de061ed3 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts @@ -5,6 +5,7 @@ import * as config from "./config.js"; import * as openclaw from "./openclaw.js"; import * as taskSuggestions from "./task-suggestions.js"; import * as tasks from "./tasks.js"; +import * as users from "./users.js"; import * as wizard from "./wizard.js"; export const OperationsProtocolSchemas = { @@ -28,6 +29,7 @@ export const OperationsProtocolSchemas = { AuditEvent: audit.AuditEventSchema, AuditListParams: audit.AuditListParamsSchema, AuditListResult: audit.AuditListResultSchema, + UsersPrefsChangedEvent: users.UsersPrefsChangedEventSchema, TaskSuggestion: taskSuggestions.TaskSuggestionSchema, TaskSuggestionEvent: taskSuggestions.TaskSuggestionEventSchema, TaskSuggestionResolution: taskSuggestions.TaskSuggestionResolutionSchema, diff --git a/packages/gateway-protocol/src/schema/ui-appearance-preferences.ts b/packages/gateway-protocol/src/schema/ui-appearance-preferences.ts new file mode 100644 index 000000000000..e143a12b74ba --- /dev/null +++ b/packages/gateway-protocol/src/schema/ui-appearance-preferences.ts @@ -0,0 +1,41 @@ +export const UI_APPEARANCE_PREFERENCE_KEYS = { + theme: "ui.theme", + themeMode: "ui.themeMode", + accent: "ui.accent", +} as const; + +export type UiAppearancePreferenceKey = + (typeof UI_APPEARANCE_PREFERENCE_KEYS)[keyof typeof UI_APPEARANCE_PREFERENCE_KEYS]; + +// Wire-contract list of profile-storable theme names. The Control UI derives +// its synced-theme handling from this tuple; a theme shipped in the UI but +// missing here would silently drop that profile preference on read. +// "custom" is deliberately absent: imported palettes are browser-local, so a +// custom selection must never follow the profile to a browser that cannot +// render it — it stays device-local instead. +export const UI_APPEARANCE_THEME_VALUES = [ + "claw", + "knot", + "dash", + "absolutely", + "tide", + "beacon", + "phosphor", +] as const; +const UI_APPEARANCE_THEMES = new Set(UI_APPEARANCE_THEME_VALUES); +const UI_APPEARANCE_THEME_MODES = new Set(["light", "dark", "system"]); + +export function normalizeUiAppearancePreference( + key: UiAppearancePreferenceKey, + value: unknown, +): string | undefined { + if (typeof value !== "string") { + return undefined; + } + if (key === UI_APPEARANCE_PREFERENCE_KEYS.accent) { + return /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : undefined; + } + const allowedValues = + key === UI_APPEARANCE_PREFERENCE_KEYS.theme ? UI_APPEARANCE_THEMES : UI_APPEARANCE_THEME_MODES; + return allowedValues.has(value) ? value : undefined; +} diff --git a/packages/gateway-protocol/src/schema/users-prefs.test.ts b/packages/gateway-protocol/src/schema/users-prefs.test.ts index 7e8d65b4f448..da0c534b1e14 100644 --- a/packages/gateway-protocol/src/schema/users-prefs.test.ts +++ b/packages/gateway-protocol/src/schema/users-prefs.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest"; import { GatewayErrorDetailCodes, GatewayErrorDetailsSchema, + normalizeUiAppearancePreference, + UI_APPEARANCE_PREFERENCE_KEYS, UserPrefsLimitExceededErrorDetailsSchema, UserProfileSchema, + UsersPrefsChangedEventSchema, UsersPrefsGetResultSchema, UsersPrefsSetResultSchema, validateUsersPrefsGetParams, @@ -13,6 +16,45 @@ import { } from "../index.js"; describe("user preference protocol schemas", () => { + it("normalizes only supported profile appearance values and canonicalizes accent colors", () => { + expect(normalizeUiAppearancePreference(UI_APPEARANCE_PREFERENCE_KEYS.theme, "absolutely")).toBe( + "absolutely", + ); + expect(normalizeUiAppearancePreference(UI_APPEARANCE_PREFERENCE_KEYS.themeMode, "system")).toBe( + "system", + ); + expect(normalizeUiAppearancePreference(UI_APPEARANCE_PREFERENCE_KEYS.accent, "#A1b2C3")).toBe( + "#a1b2c3", + ); + + for (const [key, value] of [ + [UI_APPEARANCE_PREFERENCE_KEYS.theme, "unsupported"], + [UI_APPEARANCE_PREFERENCE_KEYS.themeMode, "automatic"], + [UI_APPEARANCE_PREFERENCE_KEYS.accent, "#abc"], + [UI_APPEARANCE_PREFERENCE_KEYS.accent, "#12345g"], + [UI_APPEARANCE_PREFERENCE_KEYS.accent, { color: "#123456" }], + [UI_APPEARANCE_PREFERENCE_KEYS.theme, 42], + ] as const) { + expect(normalizeUiAppearancePreference(key, value)).toBeUndefined(); + } + }); + + it("bounds profile preference change events to their owning profile and written keys", () => { + expect( + Value.Check(UsersPrefsChangedEventSchema, { + profileId: "profile-1", + keys: [UI_APPEARANCE_PREFERENCE_KEYS.accent], + }), + ).toBe(true); + expect(Value.Check(UsersPrefsChangedEventSchema, { profileId: "", keys: [] })).toBe(false); + expect( + Value.Check(UsersPrefsChangedEventSchema, { + profileId: "profile-1", + keys: Array.from({ length: 33 }, (_, index) => `key-${index}`), + }), + ).toBe(false); + }); + it("bounds self-scoped preference requests", () => { const entries = Object.fromEntries( Array.from({ length: 32 }, (_, index) => [`key-${index}`, { index }]), diff --git a/packages/gateway-protocol/src/schema/users.ts b/packages/gateway-protocol/src/schema/users.ts index 0095ac1e40c0..10f70036ce71 100644 --- a/packages/gateway-protocol/src/schema/users.ts +++ b/packages/gateway-protocol/src/schema/users.ts @@ -8,6 +8,11 @@ export const USER_PREFS_ENTRY_LIMIT = 32; export const USER_PREFS_PROFILE_KEY_LIMIT = 128; export const USER_PREFS_VALUE_BYTES = 4 * 1024; export const GIT_COAUTHOR_PREFERENCE_KEY = "git.coauthor.enabled"; +export { + normalizeUiAppearancePreference, + UI_APPEARANCE_PREFERENCE_KEYS, + type UiAppearancePreferenceKey, +} from "./ui-appearance-preferences.js"; const UserProfileIdSchema = Type.String({ minLength: 1, maxLength: 128 }); const UserProfileDisplayNameSchema = Type.String({ maxLength: 256 }); @@ -92,6 +97,13 @@ export const UsersPrefsSetResultSchema = Type.Union([ closedObject({ status: Type.Literal("ok") }), closedObject({ status: Type.Literal("no_durable_identity") }), ]); +export const UsersPrefsChangedEventSchema = closedObject({ + profileId: UserProfileIdSchema, + keys: Type.Array(UserPreferenceKeySchema, { + maxItems: USER_PREFS_ENTRY_LIMIT, + uniqueItems: true, + }), +}); export type UserProfile = Static; export type UserProfileGitHubIdentity = Static; @@ -111,3 +123,4 @@ export type UsersPrefsGetParams = Static; export type UsersPrefsGetResult = Static; export type UsersPrefsSetParams = Static; export type UsersPrefsSetResult = Static; +export type UsersPrefsChangedEvent = Static; diff --git a/scripts/protocol-event-coverage.allowlist.json b/scripts/protocol-event-coverage.allowlist.json index 575fddb20fb0..34f386ca0aa0 100644 --- a/scripts/protocol-event-coverage.allowlist.json +++ b/scripts/protocol-event-coverage.allowlist.json @@ -33,6 +33,7 @@ "terminal.exit": "Embedded terminal is a web/desktop surface; iOS has no terminal client.", "ui.command": "Web Control UI-only layout commands; iOS does not consume them.", "update.available": "Gateway self-update notices do not apply to iOS; app updates ship via the App Store.", + "users.prefs.changed": "iOS resolves the profile accent through talk.config on connect and config refresh; live per-profile appearance push is a named follow-up.", "voicewake.routing.changed": "iOS only consumes voicewake.changed trigger updates; routing changes are not surfaced." }, "android": { @@ -70,6 +71,7 @@ "terminal.data": "Embedded terminal is a web/desktop surface; Android has no terminal client.", "terminal.exit": "Embedded terminal is a web/desktop surface; Android has no terminal client.", "ui.command": "Web Control UI-only layout commands; Android does not consume them.", + "users.prefs.changed": "Android resolves the profile accent through talk.config on connect and config refresh; live per-profile appearance push is a named follow-up.", "voicewake.routing.changed": "Android reads voicewake state on demand via voicewake.get; no push consumer yet." } } diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index fc1f6a634c09..cafcfa499d51 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -36,6 +36,10 @@ type CoreGatewayMethodSpecRow = readonly [ const PROFILE_DEPENDENT_CORE_METHODS = new Set([ "agent.wait", + // talk.config projects the caller's profile accent; without this gate a + // client asking during the post-hello GitHub identity sync window would get + // the gateway-wide accent instead. Profile-less clients pass through. + "talk.config", "ui.command", "users.linkEmail", "users.setAvatar", diff --git a/src/gateway/methods/registry.test.ts b/src/gateway/methods/registry.test.ts index a7566149f5c8..e625bfd140d1 100644 --- a/src/gateway/methods/registry.test.ts +++ b/src/gateway/methods/registry.test.ts @@ -132,6 +132,9 @@ describe("gateway method registry", () => { } expect(registry.requiresAuthenticatedProfile("users.self")).toBe(false); expect(registry.requiresAuthenticatedProfile("status")).toBe(false); + // talk.config projects the caller's profile accent; a pending GitHub + // identity sync must complete before the handler runs. + expect(registry.requiresAuthenticatedProfile("talk.config")).toBe(true); for (const method of listCoreGatewayMethodNames().filter(isSessionProfileDependentMethod)) { expect(registry.requiresAuthenticatedProfile(method), method).toBe(true); } diff --git a/src/gateway/server-broadcast.board.test.ts b/src/gateway/server-broadcast.board.test.ts index 7891929ec886..dc51f7fc4a2a 100644 --- a/src/gateway/server-broadcast.board.test.ts +++ b/src/gateway/server-broadcast.board.test.ts @@ -58,24 +58,32 @@ function makeClient( }; } -describe("skills event scope guards", () => { - it("delivers skill invalidations only to read-capable operators", () => { - const pairing = makeClient("pairing", "operator", ["operator.pairing"]); - const node = makeClient("node", "node", ["operator.read"]); - const read = makeClient("read", "operator", ["operator.read"]); - const write = makeClient("write", "operator", ["operator.write"]); - const admin = makeClient("admin", "operator", ["operator.admin"]); - const clients = new Set([pairing, node, read, write, admin].map((entry) => entry.client)); - const { broadcast } = createGatewayBroadcaster({ clients }); +describe("read-capable operator event scope guards", () => { + it.each(["skills.changed", "users.prefs.changed"] as const)( + "delivers %s only to read-capable operators", + (event) => { + const pairing = makeClient("pairing", "operator", ["operator.pairing"]); + const node = makeClient("node", "node", ["operator.read"]); + const read = makeClient("read", "operator", ["operator.read"]); + const write = makeClient("write", "operator", ["operator.write"]); + const admin = makeClient("admin", "operator", ["operator.admin"]); + const clients = new Set([pairing, node, read, write, admin].map((entry) => entry.client)); + const { broadcast } = createGatewayBroadcaster({ clients }); - broadcast("skills.changed", { reason: "remote-node" }); + broadcast( + event, + event === "users.prefs.changed" + ? { profileId: "profile-1", keys: ["ui.accent"] } + : { reason: "remote-node" }, + ); - expect(pairing.socket.events).toEqual([]); - expect(node.socket.events).toEqual([]); - expect(read.socket.events).toEqual(["skills.changed"]); - expect(write.socket.events).toEqual(["skills.changed"]); - expect(admin.socket.events).toEqual(["skills.changed"]); - }); + expect(pairing.socket.events).toEqual([]); + expect(node.socket.events).toEqual([]); + expect(read.socket.events).toEqual([event]); + expect(write.socket.events).toEqual([event]); + expect(admin.socket.events).toEqual([event]); + }, + ); }); describe("device setup event scope guards", () => { diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 905809a23753..d506039d940f 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -70,6 +70,7 @@ const EVENT_SCOPE_GUARDS: Record = { // Hash-only change notice after a persisted config write; content stays // behind the operator-scoped config.get. "config.changed": [READ_SCOPE], + "users.prefs.changed": [READ_SCOPE], "skills.changed": [READ_SCOPE], "voicewake.changed": [READ_SCOPE], "voicewake.routing.changed": [READ_SCOPE], diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 8a5cf188d30a..fd883d6dd375 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -29,6 +29,10 @@ describe("GATEWAY_EVENTS", () => { expect(GATEWAY_EVENTS).toContain("skills.changed"); }); + it("advertises profile-scoped preference invalidation updates", () => { + expect(GATEWAY_EVENTS).toContain("users.prefs.changed"); + }); + it("advertises portal replace-set updates", () => { expect(GATEWAY_EVENTS).toContain("portal.changed"); }); diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 0a7ef26ac440..1cb86266f10e 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -78,6 +78,7 @@ export const GATEWAY_EVENTS = [ "device.pair.resolved", "device.pair.setup.completed", "device.pair.setup.deliveryUncertain", + "users.prefs.changed", "skills.changed", "voicewake.changed", "voicewake.routing.changed", diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 203ce6e02cde..720541ce2d5c 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -332,6 +332,7 @@ type GatewayTransportContext = { ensureSandboxHostPort?: () => Promise; broadcast: GatewayBroadcastFn; broadcastToConnIds: GatewayBroadcastToConnIdsFn; + getClientConnIds?: (filter?: (client: GatewayClient) => boolean) => ReadonlySet; nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void; nodeSendToAllSubscribed: (event: string, payload: unknown) => void; nodeSubscribe: (nodeId: string, sessionKey: string, connId?: string) => void; diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index fdfe1c550d39..e637b25941c7 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -15,7 +15,9 @@ import { talkHandlers } from "./talk.js"; const mocks = vi.hoisted(() => ({ getRuntimeConfig: vi.fn<() => OpenClawConfig>(), + getUserPreferences: vi.fn<() => Record>(() => ({})), readConfigFileSnapshot: vi.fn(), + resolveUserProfileId: vi.fn((profileId: string) => profileId), canonicalizeSpeechProviderId: vi.fn((providerId: string | undefined) => providerId), getSpeechProvider: vi.fn(), listSpeechProviders: vi.fn(() => []), @@ -95,6 +97,16 @@ vi.mock("../../config/config.js", () => ({ readConfigFileSnapshot: mocks.readConfigFileSnapshot, })); +vi.mock("../../state/user-preferences.js", async (importOriginal) => ({ + ...(await importOriginal()), + getUserPreferences: mocks.getUserPreferences, +})); + +vi.mock("../../state/user-profiles.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveUserProfileId: mocks.resolveUserProfileId, +})); + vi.mock("../../tts/provider-registry.js", () => ({ canonicalizeSpeechProviderId: mocks.canonicalizeSpeechProviderId, getSpeechProvider: mocks.getSpeechProvider, @@ -1118,10 +1130,30 @@ describe("talk.config handler", () => { }, ); - it("prefers the user accent over the operator seam color", async () => { + it.each([ + { + name: "prefers the authenticated profile accent over gateway appearance defaults", + profileId: "profile-1", + profileAccent: "#A1B2C3", + expectedAccent: "#a1b2c3", + }, + { + name: "ignores malformed authenticated profile accents", + profileId: "profile-1", + profileAccent: "not-a-color", + expectedAccent: "#52c99a", + }, + { + name: "keeps profile-less callers on their existing gateway accent path", + expectedAccent: "#52c99a", + }, + ])("$name", async ({ profileId, profileAccent, expectedAccent }) => { markTalkOwnerCold("tts"); const runtimeConfig = createTalkConfig("healthy-talk-key"); mocks.getSpeechProvider.mockReturnValue({ id: "acme" }); + mocks.getUserPreferences.mockReturnValue( + profileAccent === undefined ? {} : { "ui.accent": profileAccent }, + ); mocks.readConfigFileSnapshot.mockResolvedValue({ config: { ...runtimeConfig, ui: { seamColor: "#123456", prefs: { accent: "#52c99a" } } }, }); @@ -1129,13 +1161,21 @@ describe("talk.config handler", () => { await callTalkHandler("talk.config", { params: {}, - client: { connect: { scopes: ["operator.read"] } }, + client: { + connect: { scopes: ["operator.read"] }, + ...(profileId ? { authenticatedUserProfile: { profileId } } : {}), + }, respond, context: { getRuntimeConfig: () => runtimeConfig }, }); expect(respond.mock.calls[0]?.[0]).toBe(true); - expect(respond.mock.calls[0]?.[1]?.config?.ui).toEqual({ seamColor: "#52c99a" }); + expect(respond.mock.calls[0]?.[1]?.config?.ui).toEqual({ seamColor: expectedAccent }); + if (profileId) { + expect(mocks.getUserPreferences).toHaveBeenCalledWith(profileId, ["ui.accent"]); + } else { + expect(mocks.getUserPreferences).not.toHaveBeenCalled(); + } }); it("projects the runtime realtime transport when source config is invalid", async () => { diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 225510631018..a94f98c61cd5 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -9,7 +9,9 @@ import { ErrorCodes, errorShape, missingScopeErrorShape, + normalizeUiAppearancePreference, type TalkSpeakParams, + UI_APPEARANCE_PREFERENCE_KEYS, validateTalkCatalogParams, validateTalkConfigParams, validateTalkModeParams, @@ -35,6 +37,8 @@ import { assertSecretOwnerAvailable, isSecretOwnerAvailable, } from "../../secrets/runtime-degraded-state.js"; +import { getUserPreferences } from "../../state/user-preferences.js"; +import { resolveUserProfileId } from "../../state/user-profiles.js"; import { resolveTalkSessionAgentId } from "../../talk/agent-target.js"; import { canonicalizeRealtimeVoiceProviderId, @@ -802,9 +806,18 @@ export const talkHandlers: GatewayRequestHandlers = { configPayload.session = { mainKey: sessionMainKey }; } - // User accent wins over the operator seam color, matching Control UI - // precedence (ui.prefs.accent -> ui.seamColor -> theme default). - const seamColor = snapshot.config.ui?.prefs?.accent ?? snapshot.config.ui?.seamColor; + const profileId = client?.authenticatedUserProfile?.profileId; + const canonicalProfileId = profileId ? resolveUserProfileId(profileId) : undefined; + const accentKey = UI_APPEARANCE_PREFERENCE_KEYS.accent; + const profileAccent = canonicalProfileId + ? normalizeUiAppearancePreference( + accentKey, + getUserPreferences(canonicalProfileId, [accentKey])[accentKey], + ) + : undefined; + // Profile accent overrides gateway prefs, then the gateway seam color and theme default. + const seamColor = + profileAccent ?? snapshot.config.ui?.prefs?.accent ?? snapshot.config.ui?.seamColor; if (typeof seamColor === "string") { configPayload.ui = { seamColor }; } diff --git a/src/gateway/server-methods/users-preferences.test.ts b/src/gateway/server-methods/users-preferences.test.ts index c7279ec1cd7b..7521b7c57547 100644 --- a/src/gateway/server-methods/users-preferences.test.ts +++ b/src/gateway/server-methods/users-preferences.test.ts @@ -1,14 +1,16 @@ -import { afterEach, expect, test } from "vitest"; +import { afterEach, expect, test, vi } from "vitest"; import { GatewayErrorDetailCodes } from "../../../packages/gateway-protocol/src/index.js"; import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; import { ensureProfileForEmail, linkEmail } from "../../state/user-profiles.js"; import { createOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; +import type { GatewayClient } from "./types.js"; import { usersHandlers } from "./users.js"; async function invokePreferenceMethod( method: "users.prefs.get" | "users.prefs.set", params: Record, profileId?: string, + context: Record = {}, ) { let result: { ok: boolean; payload?: unknown; error?: unknown } | undefined; await usersHandlers[method]!({ @@ -17,7 +19,7 @@ async function invokePreferenceMethod( respond: (ok, payload, error) => { result = { ok, payload, error }; }, - context: {} as never, + context: context as never, client: { connect: { scopes: ["operator.admin"] }, ...(profileId ? { authenticatedUserProfile: { profileId } } : {}), @@ -68,16 +70,70 @@ test("users.prefs remains self-scoped across durable identities", async () => { }); test("users.prefs returns a typed result without a durable identity", async () => { - expect(await invokePreferenceMethod("users.prefs.get", {})).toMatchObject({ + const context = { broadcastToConnIds: vi.fn(), getClientConnIds: vi.fn() }; + expect(await invokePreferenceMethod("users.prefs.get", {}, undefined, context)).toMatchObject({ ok: true, payload: { status: "no_durable_identity" }, }); expect( - await invokePreferenceMethod("users.prefs.set", { entries: { theme: "claw" } }), + await invokePreferenceMethod( + "users.prefs.set", + { entries: { theme: "claw" } }, + undefined, + context, + ), ).toMatchObject({ ok: true, payload: { status: "no_durable_identity" }, }); + expect(context.getClientConnIds).not.toHaveBeenCalled(); + expect(context.broadcastToConnIds).not.toHaveBeenCalled(); +}); + +test("users.prefs.set notifies only connections belonging to the same merged profile", async () => { + const state = await createOpenClawTestState({ + layout: "state-only", + prefix: "users-prefs-events-", + }); + try { + const retired = ensureProfileForEmail("retired@example.test"); + const owner = ensureProfileForEmail("owner@example.test"); + const other = ensureProfileForEmail("other@example.test"); + linkEmail("retired@example.test", owner.id); + + const connectedClients = [ + { connId: "owner", authenticatedUserProfile: { profileId: owner.id } }, + { connId: "merged", authenticatedUserProfile: { profileId: retired.id } }, + { connId: "other", authenticatedUserProfile: { profileId: other.id } }, + { connId: "unbound" }, + ]; + const broadcastToConnIds = vi.fn(); + const context = { + broadcastToConnIds, + getClientConnIds: (filter: (client: GatewayClient) => boolean) => + new Set( + connectedClients + .filter((client) => filter(client as GatewayClient)) + .map((client) => client.connId), + ), + }; + + expect( + await invokePreferenceMethod( + "users.prefs.set", + { entries: { "ui.accent": "#A1B2C3", "ui.theme": null } }, + retired.id, + context, + ), + ).toMatchObject({ ok: true, payload: { status: "ok" } }); + expect(broadcastToConnIds).toHaveBeenCalledExactlyOnceWith( + "users.prefs.changed", + { profileId: owner.id, keys: ["ui.accent", "ui.theme"] }, + new Set(["owner", "merged"]), + ); + } finally { + await state.cleanup(); + } }); test("users.prefs.set returns typed profile quota details", async () => { diff --git a/src/gateway/server-methods/users.ts b/src/gateway/server-methods/users.ts index a1320333b1f9..e40b4edb3e7c 100644 --- a/src/gateway/server-methods/users.ts +++ b/src/gateway/server-methods/users.ts @@ -198,7 +198,7 @@ export const usersHandlers: GatewayRequestHandlers = { respond(false, undefined, profileError(error)); } }, - "users.prefs.set": ({ client, params, respond }) => { + "users.prefs.set": ({ client, context, params, respond }) => { if (!validateUsersPrefsSetParams(params)) { respond( false, @@ -254,6 +254,25 @@ export const usersHandlers: GatewayRequestHandlers = { return; } respond(true, { status: "ok" }, undefined); + const keys = Object.keys(params.entries); + if (keys.length === 0) { + return; + } + const connIds = context.getClientConnIds?.((connectedClient) => { + const connectedProfileId = connectedClient.authenticatedUserProfile?.profileId; + return Boolean( + connectedProfileId && + (connectedProfileId === canonicalProfileId || + resolveUserProfileId(connectedProfileId) === canonicalProfileId), + ); + }); + if (connIds?.size) { + context.broadcastToConnIds( + "users.prefs.changed", + { profileId: canonicalProfileId, keys }, + connIds, + ); + } } catch (error) { respond(false, undefined, profileError(error)); } diff --git a/ui/docs/design-system/color-tokens.md b/ui/docs/design-system/color-tokens.md index 8dba819cfbbd..e2f6dff49912 100644 --- a/ui/docs/design-system/color-tokens.md +++ b/ui/docs/design-system/color-tokens.md @@ -48,7 +48,7 @@ Light mode uses a warm paper palette: ivory backgrounds, warm gray borders (`#e8 | `--accent-glow` | `rgba(255,92,92,0.2)` | Focus rings, glow effects | Not as background | | `--primary` | `#d13c3c` | Filled primary buttons (white text, ~4.8:1 AA) | Not interchangeable with `--accent` | -The synced `ui.prefs.accent` preference overrides `ui.seamColor`, which overrides the active theme's default accent. Both overrides update the accent, primary, and focus token families together; `--accent-foreground` and `--primary-foreground` automatically switch between dark and white ink according to the accent's relative luminance. Clearing the preference restores the configured seam color or theme defaults. +For connections bound to an authenticated Gateway profile, the profile's `ui.accent` preference overrides the gateway-wide `ui.prefs.accent` setting, which overrides `ui.seamColor`, which overrides the active theme's default accent. Connections without an authenticated profile retain the existing gateway-wide precedence. Every override updates the accent, primary, and focus token families together; `--accent-foreground` and `--primary-foreground` automatically switch between dark and white ink according to the accent's relative luminance. Clearing a profile preference restores the gateway-wide accent, configured seam color, or theme defaults without changing anyone else's appearance. ## Accent 2 (Teal) diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 5b3b864aa777..90342633d527 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -459,6 +459,7 @@ class OpenClawShell const runtimeConfig = this.context?.runtimeConfig; if (prefs && runtimeConfig) { pushServerUiPrefs(runtimeConfig, prefs, { + profile: this.context?.gateway.snapshot, afterCommit: ({ needsRefresh, retainedLocal }) => this.reconcileCommittedServerUiPrefs(runtimeConfig, needsRefresh, retainedLocal), }); diff --git a/ui/src/app/app-shell-gateway.test.ts b/ui/src/app/app-shell-gateway.test.ts new file mode 100644 index 000000000000..1a7d9f076084 --- /dev/null +++ b/ui/src/app/app-shell-gateway.test.ts @@ -0,0 +1,165 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import { createStorageMock } from "../test-helpers/storage.ts"; +import { ShellGatewayOwner, type ShellGatewayHost } from "./app-shell-gateway.ts"; +import type { ApplicationContext, ApplicationGatewaySnapshot } from "./context.ts"; +import { resetServerUiPrefsSync } from "./server-prefs.ts"; +import { loadSettings, patchSettings } from "./settings.ts"; + +function createProfileAppearanceGateway(profileId: string | null) { + const request = vi.fn(async () => ({ + status: "ok", + entries: { "ui.accent": "#336699" }, + })); + const client = { + gatewayUrl: "ws://profile.test", + request, + } as unknown as GatewayBrowserClient; + const snapshot = { + client, + phase: "connected", + sessionKey: "", + selfUser: profileId ? { id: profileId } : null, + hello: { auth: { role: "operator", scopes: ["operator.write"] } }, + } as ApplicationGatewaySnapshot; + const refreshTheme = vi.fn(); + const context = { + gateway: { + connection: { gatewayUrl: "ws://profile.test" }, + snapshot, + }, + runtimeConfig: { + canPatch: false, + ensureLoaded: vi.fn(async () => undefined), + runExternalMutation: vi.fn(), + state: { + client, + connected: true, + configSnapshot: { config: { ui: { prefs: { accent: "#ff0000" } } } }, + }, + }, + theme: { refresh: refreshTheme, recordServerSelection: vi.fn() }, + } as unknown as ApplicationContext; + const host = { + context, + activeSessionKey: "", + agentRosterRefreshTimer: null, + agentsListClient: null, + agentsListSource: null, + criticalNoticeRuntime: null, + lastLocalePrefSignature: null, + outboxStoreImport: { load: vi.fn(async () => undefined) }, + previousGatewayPhase: null, + routeState: {}, + runtimeConfigClient: null, + runtimeConfigSource: null, + sessionKeyClient: null, + sidebarWorkboardRuntime: null, + syncSidebarWorkboard: vi.fn(), + } as unknown as ShellGatewayHost; + return { + context, + host, + owner: new ShellGatewayOwner(host), + refreshTheme, + request, + snapshot, + }; +} + +describe("ShellGatewayOwner profile appearance integration", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", createStorageMock()); + resetServerUiPrefsSync(); + }); + + afterEach(() => { + resetServerUiPrefsSync(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("never requests durable profile preferences for an identity-free connection", () => { + const { owner, request, snapshot } = createProfileAppearanceGateway(null); + + owner.synchronizeGateway(snapshot); + owner.handleGatewayEvent({ + type: "event", + event: "users.prefs.changed", + payload: { profileId: "someone-else", keys: ["ui.accent"] }, + }); + + expect(request).not.toHaveBeenCalled(); + }); + + it("loads profile appearance when authenticated presence appears on an existing connection", async () => { + const { owner, request, snapshot } = createProfileAppearanceGateway(null); + owner.synchronizeGateway(snapshot); + snapshot.selfUser = { id: "profile-owner" }; + + owner.synchronizeGateway(snapshot); + + await vi.waitFor(() => expect(loadSettings().accent).toBe("#336699")); + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith("users.prefs.get", { + keys: ["ui.theme", "ui.themeMode", "ui.accent"], + }); + }); + + it("republishes profile provenance even when its appearance matches the browser mirror", async () => { + patchSettings({ accent: "#336699" }); + const { owner, refreshTheme, snapshot } = createProfileAppearanceGateway("profile-owner"); + + owner.synchronizeGateway(snapshot); + + await vi.waitFor(() => expect(refreshTheme).toHaveBeenCalledOnce()); + expect(loadSettings().accent).toBe("#336699"); + }); + + it("reuses cached profile preferences across unrelated gateway config snapshots", async () => { + const { context, owner, request, snapshot } = createProfileAppearanceGateway("profile-owner"); + owner.synchronizeGateway(snapshot); + await vi.waitFor(() => expect(loadSettings().accent).toBe("#336699")); + request.mockClear(); + const configState = context.runtimeConfig.state as { + configSnapshot: { config: unknown }; + }; + configState.configSnapshot = { + config: { ui: { prefs: { accent: "#884422" } }, agents: { defaults: {} } }, + }; + + owner.reconcileServerUiPrefs(context.runtimeConfig); + + expect(request).not.toHaveBeenCalled(); + expect(loadSettings().accent).toBe("#336699"); + }); + + it("refreshes only matching profile-change events and republishes the resolved appearance", async () => { + const { owner, refreshTheme, request, snapshot } = + createProfileAppearanceGateway("profile-owner"); + owner.synchronizeGateway(snapshot); + await vi.waitFor(() => expect(loadSettings().accent).toBe("#336699")); + request.mockClear(); + refreshTheme.mockClear(); + request.mockResolvedValueOnce({ status: "ok", entries: { "ui.accent": "#224466" } }); + + owner.handleGatewayEvent({ + type: "event", + event: "users.prefs.changed", + payload: { profileId: "other-profile", keys: ["ui.accent"] }, + }); + expect(request).not.toHaveBeenCalled(); + + owner.handleGatewayEvent({ + type: "event", + event: "users.prefs.changed", + payload: { profileId: "profile-owner", keys: ["ui.accent"] }, + }); + + await vi.waitFor(() => expect(loadSettings().accent).toBe("#224466")); + expect(request).toHaveBeenCalledOnce(); + expect(refreshTheme).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/app/app-shell-gateway.ts b/ui/src/app/app-shell-gateway.ts index 2cd51ad14852..726081551a8a 100644 --- a/ui/src/app/app-shell-gateway.ts +++ b/ui/src/app/app-shell-gateway.ts @@ -11,9 +11,11 @@ import { import { i18n, isSupportedLocale } from "../i18n/index.ts"; import type { ShellRouteState } from "./app-host-route-state.ts"; import type { ApplicationContext } from "./context.ts"; +import { hasOperatorWriteAccess } from "./operator-access.ts"; import { applyServerUiPrefs, flushServerUiPrefs, + refreshProfileAppearancePrefs, resetServerUiPrefsSync, resolveServerUiPrefState, } from "./server-prefs.ts"; @@ -87,6 +89,12 @@ function diffAgentRoster( } export class ShellGatewayOwner { + private runtimeConfigProfileId: string | null = null; + private profileAppearanceSource: { + client: GatewayBrowserClient; + profileId: string; + } | null = null; + constructor(private readonly host: ShellGatewayHost) {} reconcileServerUiPrefs(runtimeConfig: ApplicationContext["runtimeConfig"]): void { @@ -98,6 +106,7 @@ export class ShellGatewayOwner { const scope = context.gateway.connection.gatewayUrl; applyServerUiPrefs(snapshot.config, { scope, + profileId: context.gateway.snapshot?.selfUser?.id, onThemeChanged: (theme) => context.theme.recordServerSelection(theme, scope), onApplied: (patch) => { if (patch.sidebarEntries !== undefined) { @@ -106,6 +115,7 @@ export class ShellGatewayOwner { context.theme.refresh(); }, }); + this.refreshProfileAppearancePrefs(context); const localePref = resolveServerUiPrefState(snapshot.config, "locale", scope); const localePrefSignature = JSON.stringify([scope, localePref.overridden, localePref.value]); if (localePrefSignature === this.host.lastLocalePrefSignature) { @@ -174,6 +184,22 @@ export class ShellGatewayOwner { this.scheduleAgentRosterRefresh(); return; } + if (event.event === "users.prefs.changed") { + const context = this.host.context; + const profileId = context?.gateway.snapshot.selfUser?.id; + const payload = event.payload; + if ( + context && + profileId && + payload && + typeof payload === "object" && + "profileId" in payload && + payload.profileId === profileId + ) { + this.refreshProfileAppearancePrefs(context, true); + } + return; + } if (event.event !== "ui.command" || !event.payload) { return; } @@ -264,6 +290,10 @@ export class ShellGatewayOwner { this.updateGatewaySessionKey(snapshot); this.ensureAgentsList(snapshot); this.ensureRuntimeConfig(snapshot); + const context = this.host.context; + if (context) { + this.refreshProfileAppearancePrefs(context); + } if (previousPhase !== "connected" && snapshot.phase === "connected") { i18n.retryPendingLocale(); } @@ -281,17 +311,24 @@ export class ShellGatewayOwner { // Config-gated sidebar routes require the snapshot before any settings page opens. if (snapshot.phase !== "connected" || !snapshot.client || !runtimeConfig) { this.host.runtimeConfigClient = null; + this.runtimeConfigProfileId = null; + this.profileAppearanceSource = null; return; } + const profileId = snapshot.selfUser?.id ?? null; if ( this.host.runtimeConfigClient === snapshot.client && - this.host.runtimeConfigSource === runtimeConfig + this.host.runtimeConfigSource === runtimeConfig && + this.runtimeConfigProfileId === profileId ) { return; } this.host.runtimeConfigClient = snapshot.client; this.host.runtimeConfigSource = runtimeConfig; + this.runtimeConfigProfileId = profileId; flushServerUiPrefs(runtimeConfig, { + profileId, + canWrite: hasOperatorWriteAccess(snapshot.hello?.auth ?? null), afterCommit: ({ needsRefresh, retainedLocal }) => this.reconcileCommittedServerUiPrefs(runtimeConfig, needsRefresh, retainedLocal), }); @@ -335,6 +372,57 @@ export class ShellGatewayOwner { } } + private refreshProfileAppearancePrefs(context: ApplicationContext, force = false): void { + const snapshot = context.gateway.snapshot; + const profileId = snapshot?.selfUser?.id; + if (!profileId) { + return; + } + const client = snapshot.client; + const configObject = context.runtimeConfig.state.configSnapshot?.config; + if (snapshot.phase !== "connected" || !client || !configObject) { + return; + } + const previous = this.profileAppearanceSource; + if (!force && previous?.client === client && previous.profileId === profileId) { + return; + } + const source = { client, profileId }; + this.profileAppearanceSource = source; + const scope = context.gateway.connection.gatewayUrl; + const remainsCurrent = () => + this.host.context === context && + context.gateway.snapshot.client === client && + context.gateway.snapshot.selfUser?.id === profileId && + this.profileAppearanceSource === source; + void refreshProfileAppearancePrefs({ + client, + profileId, + configObject, + scope, + onApplied: () => { + if (remainsCurrent()) { + context.theme.refresh(); + } + }, + onThemeChanged: (theme) => { + if (remainsCurrent()) { + context.theme.recordServerSelection(theme, scope); + } + }, + }) + .then((applied) => { + if (!applied && remainsCurrent()) { + context.theme.refresh(); + } + }) + .catch((error: unknown) => { + if (remainsCurrent()) { + console.error("[gateway] profile appearance preference refresh failed:", error); + } + }); + } + reset(): void { void this.host.criticalNoticeRuntime?.then((runtime) => runtime.resetCriticalObserverTracker()); this.host.agentsListClient = null; @@ -342,6 +430,8 @@ export class ShellGatewayOwner { this.host.sessionKeyClient = null; this.host.runtimeConfigClient = null; this.host.runtimeConfigSource = null; + this.runtimeConfigProfileId = null; + this.profileAppearanceSource = null; this.host.previousGatewayPhase = null; if (this.host.agentRosterRefreshTimer !== null) { globalThis.clearTimeout(this.host.agentRosterRefreshTimer); diff --git a/ui/src/app/server-prefs-profile.ts b/ui/src/app/server-prefs-profile.ts new file mode 100644 index 000000000000..6e9da71bd393 --- /dev/null +++ b/ui/src/app/server-prefs-profile.ts @@ -0,0 +1,96 @@ +import { + normalizeUiAppearancePreference, + UI_APPEARANCE_PREFERENCE_KEYS, +} from "../../../packages/gateway-protocol/src/schema/ui-appearance-preferences.ts"; +import type { + UsersPrefsGetResult, + UsersPrefsSetResult, +} from "../../../packages/gateway-protocol/src/schema/users.ts"; +import { GatewayRequestError, type GatewayBrowserClient } from "../api/gateway.ts"; +import type { RuntimeConfigCapability } from "../lib/config/runtime-config-capability.ts"; +import { isAppearancePref, type ServerUiPrefs } from "./server-prefs-state.ts"; + +type ProfileAppearancePrefs = { profileId: string; scope: string; prefs: ServerUiPrefs }; + +let profileAppearancePrefs: ProfileAppearancePrefs | null = null; +let profilePreferencesRequestId = 0; + +export function resolveProfilePreferenceScope(scope: string, profileId?: string | null): string { + return profileId ? `${scope}:profile:${profileId}` : scope; +} + +export function resolveProfileAppearancePrefs( + scope: string, + profileId?: string | null, +): ServerUiPrefs | null { + return profileId && + profileAppearancePrefs?.profileId === profileId && + profileAppearancePrefs.scope === scope + ? profileAppearancePrefs.prefs + : null; +} + +export function resolveProfileAppearanceProfileId(scope: string): string | null { + return profileAppearancePrefs?.scope === scope ? profileAppearancePrefs.profileId : null; +} + +export function resetProfileAppearancePrefs(): void { + profileAppearancePrefs = null; + profilePreferencesRequestId += 1; +} + +export async function loadProfileAppearancePrefs( + client: GatewayBrowserClient, + profileId: string, + scope: string, +): Promise { + const requestId = ++profilePreferencesRequestId; + const result = await client.request("users.prefs.get", { + keys: Object.values(UI_APPEARANCE_PREFERENCE_KEYS), + }); + if (requestId !== profilePreferencesRequestId || result.status !== "ok") { + return false; + } + const prefs: ServerUiPrefs = {}; + for (const [key, preferenceKey] of Object.entries(UI_APPEARANCE_PREFERENCE_KEYS)) { + if (!isAppearancePref(key)) { + continue; + } + const value = normalizeUiAppearancePreference(preferenceKey, result.entries[preferenceKey]); + if (value !== undefined) { + Object.assign(prefs, { [key]: value }); + } + } + profileAppearancePrefs = { profileId, scope, prefs }; + return true; +} + +export async function writeProfileAppearancePrefs( + client: GatewayBrowserClient | null, + batch: ServerUiPrefs, + canDispatch: boolean, +): Promise>> { + if (!client || !canDispatch) { + return { ok: false, reason: "unavailable", error: "Profile preferences are unavailable." }; + } + const entries = Object.fromEntries( + Object.entries(batch).flatMap(([key, value]) => + isAppearancePref(key) ? [[UI_APPEARANCE_PREFERENCE_KEYS[key], value]] : [], + ), + ); + try { + const result = await client.request("users.prefs.set", { entries }); + return result.status === "ok" + ? { ok: true, value: result, refresh: { ok: true } } + : { ok: false, reason: "rejected", error: "Profile preferences are unavailable." }; + } catch (error) { + const rejected = + error instanceof GatewayRequestError && + (error.gatewayCode === "INVALID_REQUEST" || error.gatewayCode === "FORBIDDEN"); + return { + ok: false, + reason: rejected ? "rejected" : "error", + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/ui/src/app/server-prefs-state.ts b/ui/src/app/server-prefs-state.ts index 00027ffb73f5..4913901eaca1 100644 --- a/ui/src/app/server-prefs-state.ts +++ b/ui/src/app/server-prefs-state.ts @@ -1,4 +1,5 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { UI_APPEARANCE_THEME_VALUES } from "../../../packages/gateway-protocol/src/schema/ui-appearance-preferences.ts"; import { normalizeSidebarEntries } from "../app-navigation.ts"; import { isSupportedLocale } from "../i18n/index.ts"; import { @@ -12,16 +13,19 @@ import { } from "./settings.ts"; import type { ThemeMode, ThemeName } from "./theme.ts"; -const THEMES: ReadonlySet = new Set([ - "claw", - "knot", - "dash", - "absolutely", - "tide", - "beacon", - "phosphor", +// Derived from the wire contract so a theme the profile store rejects can never +// be offered here; new Set makes an unknown protocol name a type error. +// "custom" is config-syncable (honored only by browsers with an imported +// palette) but intentionally not profile-storable, so it is appended here +// rather than added to the wire contract. +const THEMES: ReadonlySet = new Set([ + ...UI_APPEARANCE_THEME_VALUES, "custom", ]); + +export function isAppearancePref(key: string): key is "theme" | "themeMode" | "accent" { + return key === "theme" || key === "themeMode" || key === "accent"; +} const THEME_MODES: ReadonlySet = new Set(["light", "dark", "system"]); type SyncedPrefSpec = { @@ -119,7 +123,7 @@ export type ResettableServerUiPrefKey = export type SyncedPrefValue = ReturnType<(typeof SYNCED_PREFS)[K]["extract"]> extends (infer T) | undefined ? T : never; export type ServerUiPrefs = { [K in SyncedPrefKey]?: SyncedPrefValue | null }; -export type ServerUiPrefProvenance = "default" | "pending" | "synced" | "device-local"; +export type ServerUiPrefProvenance = "default" | "pending" | "synced" | "profile" | "device-local"; export type ServerUiPrefState = { overridden: boolean; provenance: ServerUiPrefProvenance; @@ -172,6 +176,7 @@ export function resolveServerUiPrefStateFromSnapshot( shadowPrefs: ServerUiPrefs | null, settings: UiSettings, canSync?: boolean | null, + profilePrefs?: ServerUiPrefs | null, ): ServerUiPrefState> { const specification = SYNCED_PREFS[key]; const localValue = specification.local(settings) as SyncedPrefValue | undefined; @@ -191,10 +196,19 @@ export function resolveServerUiPrefStateFromSnapshot( }; }; const prefs = asRecord(asRecord(asRecord(configObject)?.ui)?.prefs); - const serverValue = + const configValue = prefs && Object.hasOwn(prefs, key) ? (specification.extract(prefs[key]) as SyncedPrefValue | undefined) : undefined; + const profileValue = profilePrefs?.[key] ?? undefined; + const serverValue = profileValue ?? configValue; + const isProfileValue = profileValue !== undefined; + // With a profile active, reset deletes the profile key (even when none exists + // yet), so the reset target is what that deletion falls back to — the gateway + // value. Using the product default here misclassifies an explicit selection of + // the product default as a reset and silently drops the user's choice. + const resetsProfileKey = profilePrefs != null && isAppearancePref(key); + const resetValue = resetsProfileKey ? (configValue ?? productDefault) : productDefault; const canApplyServerValue = serverValue !== undefined && (!specification.canApply || @@ -214,16 +228,19 @@ export function resolveServerUiPrefStateFromSnapshot( } const shadowValue = shadowPrefs[key]; if (shadowValue === null) { - return { ...localState(productDefault), provenance: "pending" }; + return { ...localState(resetValue), provenance: "pending" }; } return { overridden: true, provenance: "pending", - resetValue: productDefault, + resetValue, value: shadowValue as SyncedPrefValue, }; } - if (!prefs || !Object.hasOwn(prefs, key) || serverValue === undefined) { + if ((!prefs || !Object.hasOwn(prefs, key)) && !isProfileValue) { + return localState(productDefault); + } + if (serverValue === undefined) { return localState(productDefault); } if (!canApplyServerValue) { @@ -234,16 +251,16 @@ export function resolveServerUiPrefStateFromSnapshot( // the value, so Restore default still removes the server override. return { overridden: true, - provenance: "synced", - resetValue: productDefault, + provenance: isProfileValue ? "profile" : "synced", + resetValue, value: localValue, }; } if (prefValuesEqual(localValue, serverValue)) { return { overridden: true, - provenance: "synced", - resetValue: productDefault, + provenance: isProfileValue ? "profile" : "synced", + resetValue, value: serverValue, }; } diff --git a/ui/src/app/server-prefs-storage.ts b/ui/src/app/server-prefs-storage.ts new file mode 100644 index 000000000000..4856c4958026 --- /dev/null +++ b/ui/src/app/server-prefs-storage.ts @@ -0,0 +1,93 @@ +// localStorage persistence primitives for the synced-prefs engine. Stateless: +// every helper is (root, scope)-parameterized; scope adoption, pending shadows, +// and reconcile state stay in server-prefs.ts. +import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { SYNCED_PREFS, type ServerUiPrefs, type SyncedPrefKey } from "./server-prefs-state.ts"; + +// Last server value this client reconciled against, persisted per gateway scope. Applying only on +// a server delta keeps an unpushable local edit (viewer scope) from being reverted by every later +// snapshot, including the first snapshot after reload or reconnect carrying the same old value. +export const LAST_SEEN_KEY = "openclaw.control.serverPrefs.v1"; +// Pending keys are local edits not yet acknowledged by the gateway. They shadow reconciliation so +// snapshots cannot revert unacked edits, and persist so offline edits replay after reload/reconnect. +export const PENDING_KEY = "openclaw.control.serverPrefs.pending.v1"; +// Connected read-only edits never enter the replay outbox. Retain only their keys until the next +// snapshot establishes a LAST_SEEN baseline, then normal server-delta reconciliation resumes. +const RETAINED_LOCAL_KEY = "openclaw.control.serverPrefs.retained-local.v1"; + +function readStorageState( + root: string, + scope: string, +): { available: boolean; value: string | null } { + try { + const storage = globalThis.localStorage; + if (!storage) { + return { available: false, value: null }; + } + return { available: true, value: storage.getItem(`${root}:${scope}`) }; + } catch { + return { available: false, value: null }; + } +} + +export function readStorage(root: string, scope: string): string | null { + return readStorageState(root, scope).value; +} + +export function writeStorage(root: string, scope: string, value: string | null): boolean { + try { + const storage = globalThis.localStorage; + if (!storage) { + return false; + } + const key = `${root}:${scope}`; + if (value === null) { + storage.removeItem(key); + } else { + storage.setItem(key, value); + } + return true; + } catch { + // Quota/security failures degrade to in-memory tracking for this session. + return false; + } +} + +export function parseStoredPrefs(raw: string | null): ServerUiPrefs | null { + try { + const prefs = asRecord(JSON.parse(raw ?? "null")); + // SAFETY: consumers re-validate per key against SYNCED_PREFS extractors. + return prefs && Object.keys(prefs).length ? (prefs as ServerUiPrefs) : null; + } catch { + return null; + } +} + +export function readStoredPrefs( + root: string, + scope: string, +): { available: boolean; prefs: ServerUiPrefs | null } { + const stored = readStorageState(root, scope); + return { + available: stored.available, + prefs: parseStoredPrefs(stored.value), + }; +} + +export function readRetainedLocalKeys(scope: string): Set { + const stored = parseStoredPrefs(readStorage(RETAINED_LOCAL_KEY, scope)); + if (!stored) { + return new Set(); + } + const keys = Object.keys(stored).filter((key) => Object.hasOwn(SYNCED_PREFS, key)); + // SAFETY: filtered against SYNCED_PREFS, so every key is a SyncedPrefKey. + return new Set(keys as SyncedPrefKey[]); +} + +export function writeRetainedLocalKeys(scope: string, keys: ReadonlySet): void { + writeStorage( + RETAINED_LOCAL_KEY, + scope, + keys.size ? JSON.stringify(Object.fromEntries([...keys].map((key) => [key, true]))) : null, + ); +} diff --git a/ui/src/app/server-prefs.profile.test.ts b/ui/src/app/server-prefs.profile.test.ts new file mode 100644 index 000000000000..4b9f06f824c0 --- /dev/null +++ b/ui/src/app/server-prefs.profile.test.ts @@ -0,0 +1,324 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + normalizeUiAppearancePreference, + UI_APPEARANCE_PREFERENCE_KEYS, +} from "../../../packages/gateway-protocol/src/schema/ui-appearance-preferences.ts"; +import { createStorageMock } from "../test-helpers/storage.ts"; +import { waitForFast } from "../test-helpers/wait-for.ts"; +import { resolveServerUiPrefStateFromSnapshot } from "./server-prefs-state.ts"; +import { configWithPrefs, createServerPrefsWriter } from "./server-prefs.test-support.ts"; +import { + changedServerUiPrefs, + pushServerUiPrefs, + refreshProfileAppearancePrefs, + resetServerUiPref, + resetServerUiPrefsSync, + resolveServerUiPrefState, +} from "./server-prefs.ts"; +import { loadSettings, patchSettings } from "./settings.ts"; +import type { ThemeName } from "./theme.ts"; + +const profileId = "profile-ada"; +const scope = "ws://profiles"; + +beforeEach(() => { + vi.stubGlobal("localStorage", createStorageMock()); + resetServerUiPrefsSync(); +}); + +afterEach(() => { + resetServerUiPrefsSync(); + vi.unstubAllGlobals(); +}); + +describe("profile-bound appearance preferences", () => { + it("stores every Control UI theme name the profile wire contract knows", () => { + // Record turns a theme added to the UI but missing from + // this table into a compile error, and the loop turns a wire-contract + // mismatch into a runtime failure — a mismatch silently drops profile + // themes. "custom" is the deliberate exception: its palette is + // browser-local, so the selection must never follow the profile. + const profileStorable: Record = { + claw: true, + knot: true, + dash: true, + absolutely: true, + tide: true, + beacon: true, + phosphor: true, + custom: false, + }; + for (const [theme, storable] of Object.entries(profileStorable)) { + expect(normalizeUiAppearancePreference(UI_APPEARANCE_PREFERENCE_KEYS.theme, theme)).toBe( + storable ? theme : undefined, + ); + } + }); + + it("keeps a profile-bound custom theme selection in this browser only", async () => { + const request = vi.fn(async () => ({ status: "ok" as const })); + const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false); + const afterCommit = vi.fn(); + + pushServerUiPrefs( + writer, + { theme: "custom", accent: "#123456" }, + { profileId, canWrite: true, afterCommit }, + ); + + // The accent still syncs; the custom theme is retained browser-local and + // never reaches users.prefs.set. + await waitForFast(() => + expect(request).toHaveBeenCalledExactlyOnceWith("users.prefs.set", { + entries: { "ui.accent": "#123456" }, + }), + ); + expect(afterCommit).toHaveBeenCalledWith({ needsRefresh: false, retainedLocal: true }); + }); + + it("overlays profile appearance values without changing anonymous snapshot resolution", () => { + const config = configWithPrefs({ theme: "claw" }); + const settings = { ...loadSettings(), theme: "knot" as const }; + + expect( + resolveServerUiPrefStateFromSnapshot(config, "theme", null, settings, true, { + theme: "knot", + }), + ).toEqual({ + overridden: true, + provenance: "profile", + resetValue: "claw", + value: "knot", + }); + expect(resolveServerUiPrefStateFromSnapshot(config, "theme", null, settings, true)).toEqual({ + overridden: true, + provenance: "device-local", + resetValue: "claw", + value: "knot", + }); + }); + + it("normalizes profile overrides above config while rejecting malformed stored values", async () => { + const config = configWithPrefs({ theme: "claw", themeMode: "dark", accent: "#123456" }); + const request = vi.fn(async () => ({ + status: "ok" as const, + entries: { + "ui.theme": "knot", + "ui.themeMode": { mode: "light" }, + "ui.accent": "#AbC123", + }, + })); + const writer = createServerPrefsWriter(request, scope); + const onApplied = vi.fn(); + + await refreshProfileAppearancePrefs({ + client: writer.state.client!, + profileId, + configObject: config, + scope, + onApplied, + }); + + expect(request).toHaveBeenCalledExactlyOnceWith("users.prefs.get", { + keys: ["ui.theme", "ui.themeMode", "ui.accent"], + }); + expect(onApplied).toHaveBeenCalledWith({ theme: "knot", themeMode: "dark", accent: "#abc123" }); + expect(resolveServerUiPrefState(config, "theme", scope, loadSettings(), { profileId })).toEqual( + { overridden: true, provenance: "profile", resetValue: "claw", value: "knot" }, + ); + expect( + resolveServerUiPrefState(config, "themeMode", scope, loadSettings(), { profileId }) + .provenance, + ).toBe("synced"); + expect( + resolveServerUiPrefState(config, "accent", scope, loadSettings(), { profileId }), + ).toEqual({ + overridden: true, + provenance: "profile", + resetValue: "#123456", + value: "#abc123", + }); + }); + + it("writes profile-bound appearance without requiring config-admin access", async () => { + const request = vi.fn(async () => ({ status: "ok" as const })); + const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false); + + pushServerUiPrefs(writer, { theme: "knot" }, { profileId, canWrite: true }); + + await waitForFast(() => + expect(request).toHaveBeenCalledExactlyOnceWith("users.prefs.set", { + entries: { "ui.theme": "knot" }, + }), + ); + }); + + it("keeps pending local edits above incoming profile updates", async () => { + let releaseWrite!: (value: unknown) => void; + const write = new Promise((resolve) => { + releaseWrite = resolve; + }); + let profileTheme = "knot"; + const request = vi.fn(async (method: string) => + method === "users.prefs.get" + ? { status: "ok" as const, entries: { "ui.theme": profileTheme } } + : await write, + ); + const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false); + const config = configWithPrefs({ theme: "claw" }); + const options = { + client: writer.state.client!, + profileId, + configObject: config, + scope, + onApplied: vi.fn(), + }; + await refreshProfileAppearancePrefs(options); + patchSettings({ theme: "dash" }); + pushServerUiPrefs(writer, { theme: "dash" }, { profileId, canWrite: true }); + profileTheme = "absolutely"; + + await refreshProfileAppearancePrefs(options); + + expect(loadSettings().theme).toBe("dash"); + expect( + resolveServerUiPrefState(config, "theme", scope, loadSettings(), { profileId }), + ).toMatchObject({ provenance: "pending", value: "dash" }); + releaseWrite({ status: "ok" }); + await waitForFast(() => expect(request).toHaveBeenCalledTimes(3)); + }); + + it("restores gateway defaults by deleting only the profile preference", async () => { + const config = configWithPrefs({ theme: "claw" }); + const request = vi.fn(async (method: string) => + method === "users.prefs.get" + ? { status: "ok" as const, entries: { "ui.theme": "knot" } } + : { status: "ok" as const }, + ); + const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false); + Object.assign(writer.state, { configSnapshot: { config } }); + await refreshProfileAppearancePrefs({ + client: writer.state.client!, + profileId, + configObject: config, + scope, + onApplied: vi.fn(), + }); + const previous = loadSettings(); + const state = resolveServerUiPrefState(config, "theme", scope, previous, { profileId }); + const next = resetServerUiPref("theme", state, scope); + + expect(next.theme).toBe("claw"); + expect(changedServerUiPrefs(previous, next)).toEqual({ theme: null }); + const afterCommit = vi.fn(); + pushServerUiPrefs(writer, { theme: null }, { profileId, canWrite: true, afterCommit }); + await waitForFast(() => expect(afterCommit).toHaveBeenCalledOnce()); + + expect(request).toHaveBeenLastCalledWith("users.prefs.set", { entries: { "ui.theme": null } }); + expect( + resolveServerUiPrefState(config, "theme", scope, loadSettings(), { profileId }), + ).toMatchObject({ provenance: "synced", value: "claw" }); + expect(request.mock.calls.some(([method]) => method === "config.patch")).toBe(false); + }); + + it("keeps read-only profile edits device-local without attempting a profile write", async () => { + const config = configWithPrefs({ theme: "claw" }); + const request = vi.fn(async () => ({ status: "ok" as const, entries: {} })); + const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false); + await refreshProfileAppearancePrefs({ + client: writer.state.client!, + profileId, + configObject: config, + scope, + onApplied: vi.fn(), + }); + patchSettings({ theme: "knot" }); + const afterCommit = vi.fn(); + + pushServerUiPrefs(writer, { theme: "knot" }, { profileId, canWrite: false, afterCommit }); + + expect(request).toHaveBeenCalledOnce(); + expect(afterCommit).toHaveBeenCalledWith({ needsRefresh: false, retainedLocal: true }); + expect( + resolveServerUiPrefState(config, "theme", scope, loadSettings(), { + profileId, + canSync: false, + }), + ).toMatchObject({ provenance: "device-local", value: "knot" }); + }); + + it("targets reset at the gateway value so an explicit product-default choice persists", async () => { + // With an empty profile over a gateway theme of Dash, resetValue must be the + // deletion fallback ("dash"); a product-default resetValue would classify an + // explicit Claw selection as a reset and silently drop the user's choice. + const config = configWithPrefs({ theme: "dash" }); + const request = vi.fn(async (method: string) => + method === "users.prefs.get" + ? { status: "ok" as const, entries: {} } + : { status: "ok" as const }, + ); + const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false); + await refreshProfileAppearancePrefs({ + client: writer.state.client!, + profileId, + configObject: config, + scope, + onApplied: vi.fn(), + }); + + const state = resolveServerUiPrefState(config, "theme", scope, loadSettings(), { profileId }); + expect(state).toMatchObject({ provenance: "synced", resetValue: "dash", value: "dash" }); + + // The explicit Claw choice is a profile write, never a null reset. + patchSettings({ theme: "claw" }); + pushServerUiPrefs(writer, { theme: "claw" }, { profileId, canWrite: true }); + await waitForFast(() => + expect(request).toHaveBeenLastCalledWith("users.prefs.set", { + entries: { "ui.theme": "claw" }, + }), + ); + + // Resetting from synced provenance with a profile bound lands on the + // gateway value locally, matching what the profile-key deletion resolves to. + const reset = resetServerUiPref("theme", state, scope); + expect(reset.theme).toBe("dash"); + }); + + it("reapplies the returning profile's appearance after an identity switch", async () => { + // A→B→A in one browser: per-scope last-seen state must not skip re-applying + // A's values while the DOM still shows B's. + const config = configWithPrefs({}); + const prefsByProfile: Record> = { + "profile-a": { "ui.theme": "knot" }, + "profile-b": { "ui.theme": "dash", "ui.accent": "#123456" }, + }; + let activeProfile = "profile-a"; + const request = vi.fn(async () => ({ + status: "ok" as const, + entries: prefsByProfile[activeProfile], + })); + const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false); + const refresh = (nextProfile: string) => { + activeProfile = nextProfile; + return refreshProfileAppearancePrefs({ + client: writer.state.client!, + profileId: nextProfile, + configObject: config, + scope, + onApplied: vi.fn(), + }); + }; + + await refresh("profile-a"); + expect(loadSettings().theme).toBe("knot"); + await refresh("profile-b"); + expect(loadSettings().theme).toBe("dash"); + expect(loadSettings().accent).toBe("#123456"); + await refresh("profile-a"); + expect(loadSettings().theme).toBe("knot"); + // B's accent must not linger on A even though A's scope never recorded one. + expect(loadSettings().accent).toBeUndefined(); + }); +}); diff --git a/ui/src/app/server-prefs.ts b/ui/src/app/server-prefs.ts index c5e88f64f0f3..5219713bd774 100644 --- a/ui/src/app/server-prefs.ts +++ b/ui/src/app/server-prefs.ts @@ -2,11 +2,21 @@ // the approval gate and other devices pick them up. The localStorage mirror gives instant boot and // stays authoritative when this client cannot write config (viewer scope, offline). Pending local // intent shadows server snapshots until the hash-free LWW ack; failed pushes degrade device-local. -import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import type { RuntimeConfigCapability } from "../lib/config/runtime-config-capability.ts"; +import type { ApplicationGatewaySnapshot } from "./gateway.ts"; +import { hasOperatorWriteAccess } from "./operator-access.ts"; +import { + loadProfileAppearancePrefs, + resetProfileAppearancePrefs, + resolveProfileAppearanceProfileId, + resolveProfileAppearancePrefs, + resolveProfilePreferenceScope, + writeProfileAppearancePrefs, +} from "./server-prefs-profile.ts"; import { extractServerUiPrefs, + isAppearancePref, prefValuesEqual, resolveServerUiPrefStateFromSnapshot, serverPrefsLocalPatch, @@ -18,6 +28,16 @@ import { type SyncedPrefKey, type SyncedPrefValue, } from "./server-prefs-state.ts"; +import { + LAST_SEEN_KEY, + PENDING_KEY, + parseStoredPrefs, + readRetainedLocalKeys, + readStorage, + readStoredPrefs, + writeRetainedLocalKeys, + writeStorage, +} from "./server-prefs-storage.ts"; import { loadSettings, patchSettings, type UiSettings } from "./settings.ts"; import type { ThemeName } from "./theme.ts"; @@ -25,12 +45,19 @@ type ServerUiPrefsWriter = Pick void; + profileId?: string | null; + canWrite?: boolean; + profile?: Pick | null; +}; export type { ServerUiPrefProvenance, ServerUiPrefState } from "./server-prefs-state.ts"; export function resolveServerUiPrefState( @@ -38,16 +65,20 @@ export function resolveServerUiPrefState( key: K, scope = "", settings = loadSettings(), - options: { canSync?: boolean | null } = {}, + options: { canSync?: boolean | null; profileId?: string | null } = {}, ): ServerUiPrefState> { + const effectiveScope = resolveProfilePreferenceScope(scope, options.profileId); const shadowPrefs = - scope === pendingScope ? pendingPrefs : parseStoredPrefs(readStorage(PENDING_KEY, scope)); + effectiveScope === pendingScope + ? pendingPrefs + : parseStoredPrefs(readStorage(PENDING_KEY, effectiveScope)); return resolveServerUiPrefStateFromSnapshot( configObject, key, shadowPrefs, settings, options.canSync, + resolveProfileAppearancePrefs(scope, options.profileId), ); } /** Synced-key delta between two local settings snapshots, for the push path. */ @@ -78,16 +109,6 @@ export function changedServerUiPrefs(previous: UiSettings, next: UiSettings): Se } return Object.keys(prefs).length > 0 ? prefs : null; } -// Last server value this client reconciled against, persisted per gateway scope. Applying only on -// a server delta keeps an unpushable local edit (viewer scope) from being reverted by every later -// snapshot, including the first snapshot after reload or reconnect carrying the same old value. -const LAST_SEEN_KEY = "openclaw.control.serverPrefs.v1"; -// Pending keys are local edits not yet acknowledged by the gateway. They shadow reconciliation so -// snapshots cannot revert unacked edits, and persist so offline edits replay after reload/reconnect. -const PENDING_KEY = "openclaw.control.serverPrefs.pending.v1"; -// Connected read-only edits never enter the replay outbox. Retain only their keys until the next -// snapshot establishes a LAST_SEEN baseline, then normal server-delta reconciliation resumes. -const RETAINED_LOCAL_KEY = "openclaw.control.serverPrefs.retained-local.v1"; const CONFLICT_REDRAIN_DELAY_MS = 1_000; const MAX_CONFLICT_REDRAINS = 5; const requestedServerUiPrefResets = new Set(); @@ -98,6 +119,8 @@ let pendingPrefs: ServerUiPrefs | null = null; let pendingPersistedKeys = new Set(); let pushWriter: ServerUiPrefsWriter | null = null; let pushScope = ""; +let pushProfileId: string | null = null; +let pushCanWrite = false; let pushAfterCommit: ((commit: ServerUiPrefsCommit) => void) | undefined; let pushDraining = false; let drainRequested = false; @@ -116,74 +139,6 @@ function clearConflictRedrain(): void { } consecutiveConflictRedrains = 0; } -function readStorageState( - root: string, - scope: string, -): { available: boolean; value: string | null } { - try { - const storage = globalThis.localStorage; - if (!storage) { - return { available: false, value: null }; - } - return { available: true, value: storage.getItem(`${root}:${scope}`) }; - } catch { - return { available: false, value: null }; - } -} -function readStorage(root: string, scope: string): string | null { - return readStorageState(root, scope).value; -} -function writeStorage(root: string, scope: string, value: string | null): boolean { - try { - const storage = globalThis.localStorage; - if (!storage) { - return false; - } - const key = `${root}:${scope}`; - if (value === null) { - storage.removeItem(key); - } else { - storage.setItem(key, value); - } - return true; - } catch { - // Quota/security failures degrade to in-memory tracking for this session. - return false; - } -} -function parseStoredPrefs(raw: string | null): ServerUiPrefs | null { - try { - const prefs = asRecord(JSON.parse(raw ?? "null")); - return prefs && Object.keys(prefs).length ? (prefs as ServerUiPrefs) : null; - } catch { - return null; - } -} -function readStoredPrefs( - root: string, - scope: string, -): { available: boolean; prefs: ServerUiPrefs | null } { - const stored = readStorageState(root, scope); - return { - available: stored.available, - prefs: parseStoredPrefs(stored.value), - }; -} -function readRetainedLocalKeys(scope: string): Set { - const stored = parseStoredPrefs(readStorage(RETAINED_LOCAL_KEY, scope)); - return new Set( - stored - ? (Object.keys(stored).filter((key) => Object.hasOwn(SYNCED_PREFS, key)) as SyncedPrefKey[]) - : [], - ); -} -function writeRetainedLocalKeys(scope: string, keys: ReadonlySet): void { - writeStorage( - RETAINED_LOCAL_KEY, - scope, - keys.size ? JSON.stringify(Object.fromEntries([...keys].map((key) => [key, true]))) : null, - ); -} function updateRetainedLocalKeys( scope: string, keys: readonly SyncedPrefKey[], @@ -303,8 +258,11 @@ export function resetServerUiPrefsSync() { pendingPrefs = pushWriter = null; pendingPersistedKeys.clear(); pushScope = ""; + pushProfileId = null; + pushCanWrite = false; lastReconciledScope = ""; lastReconciledConfigObject = null; + resetProfileAppearancePrefs(); requestedServerUiPrefResets.clear(); requestedDeviceLocalPrefResets.clear(); } @@ -315,6 +273,8 @@ export function resetServerUiPref( scope = pendingScope, ): UiSettings { const specification = SYNCED_PREFS[key]; + const activeProfile = isAppearancePref(key) ? resolveProfileAppearanceProfileId(scope) : null; + const effectiveScope = resolveProfilePreferenceScope(scope, activeProfile); const reset = specification.reset; if (!reset) { throw new Error(`Server UI preference is not resettable: ${key}`); @@ -326,26 +286,45 @@ export function resetServerUiPref( if (!write) { throw new Error(`Server UI preference cannot restore a retained local value: ${key}`); } - cancelPendingKeys(scope, [key]); - updateRetainedLocalKeys(scope, [key], false); + cancelPendingKeys(effectiveScope, [key]); + updateRetainedLocalKeys(effectiveScope, [key], false); requestedDeviceLocalPrefResets.add(key); return patchSettings(write(state.resetValue)); } requestedServerUiPrefResets.add(key); + // Profile-bound reset deletes the profile key and lands on the gateway value, + // so the local settings must move to state.resetValue (that fallback), not the + // product default the generic reset would apply. + if (activeProfile && state) { + // SAFETY: SYNCED_PREFS pairs each key's write() with that key's own value type. + const write = specification.write as + | ((value: SyncedPrefValue | undefined) => Partial) + | undefined; + if (write) { + return patchSettings(write(state.resetValue)); + } + } return patchSettings(reset(loadSettings())); } export function applyServerUiPrefs( configObject: unknown, hooks: { scope?: string; + profileId?: string | null; onApplied: (patch: Partial) => void; onThemeChanged?: (theme: ThemeName | null) => void; }, ): boolean { - const scope = hooks.scope ?? ""; + const gatewayScope = hooks.scope ?? ""; + const scope = resolveProfilePreferenceScope(gatewayScope, hooks.profileId); if (scope === lastReconciledScope && configObject === lastReconciledConfigObject) { return false; } + // Last-seen state is per profile scope but the rendered settings are a + // singleton: after an identity switch (A→B→A) an unchanged last-seen does not + // mean the DOM shows this profile's values, so a switch between two known + // scopes forces a full reconcile. Boot keeps the shortcut (mirror is current). + const scopeChanged = lastReconciledScope !== "" && scope !== lastReconciledScope; const recordReconciledObject = () => { lastReconciledScope = scope; lastReconciledConfigObject = configObject; @@ -353,10 +332,13 @@ export function applyServerUiPrefs( const shadowPrefs = scope === pendingScope ? pendingPrefs : parseStoredPrefs(readStorage(PENDING_KEY, scope)); const retainedLocalKeys = readRetainedLocalKeys(scope); - const prefs = extractServerUiPrefs(configObject); + const prefs = { + ...extractServerUiPrefs(configObject), + ...resolveProfileAppearancePrefs(gatewayScope, hooks.profileId), + }; const key = JSON.stringify(prefs); const lastSeenRaw = readStorage(LAST_SEEN_KEY, scope); - if (key === lastSeenRaw) { + if (!scopeChanged && key === lastSeenRaw) { if (retainedLocalKeys.size) { updateRetainedLocalKeys(scope, [...retainedLocalKeys], false); } @@ -371,7 +353,7 @@ export function applyServerUiPrefs( if ( !(shadowPrefs && prefKey in shadowPrefs) && !retainedLocalKeys.has(prefKey) && - (lastSeenRaw === null || !prefValuesEqual(prefs[prefKey], lastSeen[prefKey])) + (scopeChanged || lastSeenRaw === null || !prefValuesEqual(prefs[prefKey], lastSeen[prefKey])) ) { (changed as Record)[prefKey] = prefs[prefKey]; } @@ -386,6 +368,22 @@ export function applyServerUiPrefs( (changed as Record)[prefKey] = null; } } + if (scopeChanged) { + // The previous identity may have rendered appearance values this scope has + // never seen (absent from both prefs and this scope's last-seen); clear + // them back to defaults so the new identity never wears the old one's look. + for (const prefKey of SYNCED_PREF_KEYS) { + if ( + isAppearancePref(prefKey) && + !(prefKey in prefs) && + !(shadowPrefs && prefKey in shadowPrefs) && + !retainedLocalKeys.has(prefKey) && + SYNCED_PREFS[prefKey].clearable + ) { + (changed as Record)[prefKey] = null; + } + } + } writeStorage(LAST_SEEN_KEY, scope, key); if (retainedLocalKeys.size) { updateRetainedLocalKeys(scope, [...retainedLocalKeys], false); @@ -407,12 +405,30 @@ export function applyServerUiPrefs( hooks.onApplied(patch); return true; } + +export async function refreshProfileAppearancePrefs(options: { + client: GatewayBrowserClient; + profileId: string; + configObject: unknown; + scope?: string; + onApplied: (patch: Partial) => void; + onThemeChanged?: (theme: ThemeName | null) => void; +}): Promise { + const scope = options.scope ?? options.client.gatewayUrl; + if (!(await loadProfileAppearancePrefs(options.client, options.profileId, scope))) { + return false; + } + lastReconciledConfigObject = null; + return applyServerUiPrefs(options.configObject, { ...options, scope }); +} export function isApplyingServerUiPrefs(): boolean { return applyingServerPrefs; } -function adoptPushWriter(writer: ServerUiPrefsWriter): void { - const scope = writer.state.client?.gatewayUrl ?? ""; - if (pushWriter === writer && pushScope === scope) { +function adoptPushWriter(writer: ServerUiPrefsWriter, hooks: ServerUiPrefsPushHooks): void { + const profileId = hooks.profileId ?? hooks.profile?.selfUser?.id ?? null; + const scope = resolveProfilePreferenceScope(writer.state.client?.gatewayUrl ?? "", profileId); + pushCanWrite = hooks.canWrite ?? hasOperatorWriteAccess(hooks.profile?.hello?.auth ?? null); + if (pushWriter === writer && pushScope === scope && pushProfileId === profileId) { return; } // Reconcile the scope being left before moving pre-connection intent forward. @@ -429,6 +445,7 @@ function adoptPushWriter(writer: ServerUiPrefsWriter): void { pushEpoch += 1; pushWriter = writer; pushScope = scope; + pushProfileId = profileId; pushDraining = false; adoptPendingScope(scope, true); if (scope && unscopedPending && Object.keys(unscopedPending).length) { @@ -468,6 +485,7 @@ function scheduleConflictRedrain(writer: ServerUiPrefsWriter, epoch: number): vo } }, CONFLICT_REDRAIN_DELAY_MS); } + async function drainPendingPrefs(writer: ServerUiPrefsWriter, epoch: number): Promise { while (pendingPrefs) { if (pushWriter !== writer || pushEpoch !== epoch) { @@ -477,47 +495,93 @@ async function drainPendingPrefs(writer: ServerUiPrefsWriter, epoch: number): Pr if (!pendingPrefs) { return; } - const batch = { ...pendingPrefs }; + if (pushProfileId && pendingPrefs.theme === "custom") { + // Offline-queued custom theme reaching a profile connection: browser-local + // by contract, so retain it here instead of syncing it to the profile. + cancelPendingKeys(pendingScope, ["theme"]); + updateRetainedLocalKeys(pendingScope, ["theme"], true); + continue; + } + const profileBatch: ServerUiPrefs = {}; + if (pushProfileId && pushCanWrite) { + for (const key of SYNCED_PREF_KEYS) { + if (isAppearancePref(key) && Object.hasOwn(pendingPrefs, key)) { + Object.assign(profileBatch, { [key]: pendingPrefs[key] }); + } + } + } + const useProfile = Boolean(profileBatch && Object.keys(profileBatch).length); + const batch = useProfile ? profileBatch : { ...pendingPrefs }; const afterCommit = pushAfterCommit; for (let attempt = 0; attempt < 2; attempt += 1) { if (pushWriter !== writer || pushEpoch !== epoch) { return; } - const result = await writer.runExternalMutation( - (client) => - // ui.prefs is a deliberately narrow hashless LWW surface enforced by - // hasHashlessPatchLwwStructure in the gateway. Serialization still - // matters: a pending whole-config save must commit before this merge. - client.request("config.patch", { - raw: JSON.stringify({ ui: { prefs: batch } }), - ...(batch.sidebarEntries !== undefined - ? { replacePaths: ["ui.prefs.sidebarEntries"] } - : {}), - note: "control-ui prefs sync", - }), - { - waitForWritesResumed: true, - canDispatch: () => { - if (writer.canPatch === false) { - return false; - } - reconcilePersistedPendingPrefs(); - if (batchIsCurrent(batch)) { - return true; - } - drainRequested = Boolean(pendingPrefs); - return false; - }, - dispatchError: "Access changed before preferences could sync.", - }, - ); + const result = useProfile + ? await writeProfileAppearancePrefs( + writer.state.client, + batch, + writer.state.connected && pushCanWrite && batchIsCurrent(batch), + ) + : await writer.runExternalMutation( + (client) => + // ui.prefs is a deliberately narrow hashless LWW surface enforced by + // hasHashlessPatchLwwStructure in the gateway. Serialization still + // matters: a pending whole-config save must commit before this merge. + client.request("config.patch", { + raw: JSON.stringify({ ui: { prefs: batch } }), + ...(batch.sidebarEntries !== undefined + ? { replacePaths: ["ui.prefs.sidebarEntries"] } + : {}), + note: "control-ui prefs sync", + }), + { + waitForWritesResumed: true, + canDispatch: () => { + if (writer.canPatch === false) { + return false; + } + reconcilePersistedPendingPrefs(); + if (batchIsCurrent(batch)) { + return true; + } + drainRequested = Boolean(pendingPrefs); + return false; + }, + dispatchError: "Access changed before preferences could sync.", + }, + ); if (pushWriter !== writer || pushEpoch !== epoch) { return; } if (result.ok) { removeBatch(batch); const lastSeen = parseStoredPrefs(readStorage(LAST_SEEN_KEY, pendingScope)) ?? {}; - writeStorage(LAST_SEEN_KEY, pendingScope, JSON.stringify({ ...lastSeen, ...batch })); + const nextLastSeen = { ...lastSeen, ...batch }; + const profilePrefs = resolveProfileAppearancePrefs( + writer.state.client?.gatewayUrl ?? "", + pushProfileId, + ); + if (useProfile && profilePrefs) { + const configPrefs = extractServerUiPrefs(writer.state.configSnapshot?.config); + for (const key of SYNCED_PREF_KEYS) { + if (!Object.hasOwn(batch, key)) { + continue; + } + if (batch[key] === null) { + delete profilePrefs[key]; + if (configPrefs[key] === undefined) { + delete nextLastSeen[key]; + } else { + Object.assign(nextLastSeen, { [key]: configPrefs[key] }); + } + } else { + Object.assign(profilePrefs, { [key]: batch[key] }); + } + } + lastReconciledConfigObject = null; + } + writeStorage(LAST_SEEN_KEY, pendingScope, JSON.stringify(nextLastSeen)); settlePendingStorage(batch); clearConflictRedrain(); if (pushWriter !== writer || pushEpoch !== epoch) { @@ -570,7 +634,11 @@ function startPendingDrain(writer: ServerUiPrefsWriter): void { if (!pendingPrefs) { return; } - if (writer.state.connected && writer.canPatch === false) { + if ( + writer.state.connected && + writer.canPatch === false && + !(pushProfileId && pushCanWrite && Object.keys(pendingPrefs).some(isAppearancePref)) + ) { return; } pushDraining = true; @@ -590,30 +658,49 @@ function startPendingDrain(writer: ServerUiPrefsWriter): void { export function pushServerUiPrefs( writer: ServerUiPrefsWriter, prefs: ServerUiPrefs, - hooks: { afterCommit?: (commit: ServerUiPrefsCommit) => void } = {}, + hooks: ServerUiPrefsPushHooks = {}, ): void { - adoptPushWriter(writer); + adoptPushWriter(writer, hooks); clearConflictRedrain(); pushAfterCommit = hooks.afterCommit; - if (writer.state.connected && writer.canPatch === false) { + const keys = SYNCED_PREF_KEYS.filter((key) => Object.hasOwn(prefs, key)); + const blockedKeys = writer.state.connected + ? keys.filter((key) => { + if (pushProfileId && isAppearancePref(key)) { + // Imported custom palettes are browser-local by contract; a profile + // must never carry a theme another browser cannot render. + return !pushCanWrite || (key === "theme" && prefs.theme === "custom"); + } + return writer.canPatch === false; + }) + : []; + if (blockedKeys.length) { // A connected read-only edit is intentionally browser-local. Supersede only // same-key offline intent so a later authorization cannot replay stale input. - const keys = Object.keys(prefs) as SyncedPrefKey[]; - cancelPendingKeys(pendingScope, keys); - updateRetainedLocalKeys(pendingScope, keys, true); + cancelPendingKeys(pendingScope, blockedKeys); + updateRetainedLocalKeys(pendingScope, blockedKeys, true); hooks.afterCommit?.({ needsRefresh: false, retainedLocal: true }); - return; + if (blockedKeys.length === keys.length) { + return; + } } + const writablePrefs = blockedKeys.length + ? Object.fromEntries( + Object.entries(prefs).filter( + ([key]) => !blockedKeys.some((blockedKey) => blockedKey === key), + ), + ) + : prefs; reconcilePersistedPendingPrefs(); - pendingPrefs = { ...pendingPrefs, ...prefs }; + pendingPrefs = { ...pendingPrefs, ...writablePrefs }; mergePendingIntoStorage(); startPendingDrain(writer); } export function flushServerUiPrefs( writer: ServerUiPrefsWriter, - hooks: { afterCommit?: (commit: ServerUiPrefsCommit) => void } = {}, + hooks: ServerUiPrefsPushHooks = {}, ): void { - adoptPushWriter(writer); + adoptPushWriter(writer, hooks); clearConflictRedrain(); pushEpoch += 1; pushDraining = drainRequested = false; diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index eea9701729ca..a2e1c85425ee 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1854,6 +1854,7 @@ export const en: TranslationMap = { xl: "XL", xxl: "XXL", }, + profileSyncedHint: "Saved to your profile — follows you on every device.", syncedHint: "Synced across your devices through the gateway.", syncPendingHint: "Waiting to sync through the gateway.", notifications: { diff --git a/ui/src/pages/config/config-page.test.ts b/ui/src/pages/config/config-page.test.ts index 01ab711ed648..e72580645a9b 100644 --- a/ui/src/pages/config/config-page.test.ts +++ b/ui/src/pages/config/config-page.test.ts @@ -9,7 +9,11 @@ import type { ApplicationGateway, ApplicationGatewaySnapshot, } from "../../app/context.ts"; -import { changedServerUiPrefs, resetServerUiPrefsSync } from "../../app/server-prefs.ts"; +import { + changedServerUiPrefs, + refreshProfileAppearancePrefs, + resetServerUiPrefsSync, +} from "../../app/server-prefs.ts"; import { loadSettings } from "../../app/settings.ts"; import * as modelCatalogStore from "../../lib/model-catalog-store.ts"; import { @@ -24,6 +28,7 @@ import { configSelectionFromSearch, extractQuickSettingsSecurity, } from "./config-page.ts"; +import { serverUiPrefProvenanceHint } from "./view-appearance-preferences.ts"; import type { ConfigViewState } from "./view.ts"; const switchActiveRealtimeTalkCameras = @@ -118,6 +123,99 @@ describe("extractQuickSettingsSecurity", () => { }); describe("ConfigPage synced preference provenance", () => { + it.each([ + { + label: "lets a profile-bound operator write appearance without config admin access", + selfUser: { id: "profile-owner" }, + scopes: ["operator.write"], + canPatch: false, + appearanceCanSync: true, + localeCanSync: false, + }, + { + label: "keeps read-only profile appearance device-local even when config patching is exposed", + selfUser: { id: "profile-viewer" }, + scopes: ["operator.read"], + canPatch: true, + appearanceCanSync: false, + localeCanSync: true, + }, + { + label: "preserves config-patch authorization when no profile is bound", + selfUser: null, + scopes: ["operator.write"], + canPatch: false, + appearanceCanSync: false, + localeCanSync: false, + }, + ])("$label", ({ selfUser, scopes, canPatch, appearanceCanSync, localeCanSync }) => { + const page = new ConfigPage() as unknown as { + context: ApplicationContext; + serverUiPrefsCanSync: (key?: "theme" | "themeMode" | "accent") => boolean | null; + }; + page.context = { + gateway: { + snapshot: { selfUser, hello: { auth: { role: "operator", scopes } } }, + }, + runtimeConfig: { state: { connected: true }, canPatch }, + } as unknown as ApplicationContext; + + expect(page.serverUiPrefsCanSync("theme")).toBe(appearanceCanSync); + expect(page.serverUiPrefsCanSync("themeMode")).toBe(appearanceCanSync); + expect(page.serverUiPrefsCanSync("accent")).toBe(appearanceCanSync); + expect(page.serverUiPrefsCanSync()).toBe(localeCanSync); + }); + + it("describes profile-owned appearance without changing gateway or device-local hints", () => { + expect(serverUiPrefProvenanceHint("profile")).toBe( + "Saved to your profile — follows you on every device.", + ); + expect(serverUiPrefProvenanceHint("synced")).toBe( + "Synced across your devices through the gateway.", + ); + expect(serverUiPrefProvenanceHint("device-local")).toBe("Stored in this browser only."); + }); + + it("restores the gateway appearance default while queuing deletion of the profile override", async () => { + const configObject = { ui: { prefs: { theme: "dash" } } }; + const client = { + request: vi.fn(async () => ({ status: "ok", entries: { "ui.theme": "knot" } })), + } as unknown as GatewayBrowserClient; + await refreshProfileAppearancePrefs({ + client, + profileId: "profile-owner", + configObject, + scope: "ws://profile.test", + onApplied: vi.fn(), + }); + const page = new ConfigPage() as unknown as { + context: ApplicationContext; + settings: ReturnType; + resetSyncedAppearancePref: (key: "theme") => void; + }; + page.context = { + gateway: { + connection: { gatewayUrl: "ws://profile.test" }, + snapshot: { + selfUser: { id: "profile-owner" }, + hello: { auth: { role: "operator", scopes: ["operator.write"] } }, + }, + }, + runtimeConfig: { + state: { connected: true, configSnapshot: { config: configObject } }, + canPatch: false, + }, + theme: { refresh: vi.fn() }, + } as unknown as ApplicationContext; + const beforeReset = loadSettings(); + page.settings = beforeReset; + + page.resetSyncedAppearancePref("theme"); + + expect(page.settings.theme).toBe("dash"); + expect(changedServerUiPrefs(beforeReset, page.settings)).toEqual({ theme: null }); + }); + it("uses the committed snapshot for both display and reset while the form draft differs", () => { const page = new ConfigPage(); const committedConfig = { ui: { prefs: { theme: "claw" } } }; diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index e19e0640d96b..8643b2a7c93c 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -19,7 +19,7 @@ import { type ApplicationGatewaySnapshot, } from "../../app/context.ts"; import { importCustomThemeFromUrl } from "../../app/custom-theme.ts"; -import { hasOperatorAdminAccess } from "../../app/operator-access.ts"; +import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts"; import { resetServerUiPref, resolveServerUiPrefState, @@ -871,7 +871,10 @@ export class ConfigPage extends OpenClawLightDomElement { "theme", this.context.gateway.connection.gatewayUrl, this.settings, - { canSync: this.serverUiPrefsCanSync() }, + { + canSync: this.serverUiPrefsCanSync("theme"), + profileId: this.context.gateway.snapshot?.selfUser?.id, + }, ); } @@ -881,7 +884,10 @@ export class ConfigPage extends OpenClawLightDomElement { "themeMode", this.context.gateway.connection.gatewayUrl, this.settings, - { canSync: this.serverUiPrefsCanSync() }, + { + canSync: this.serverUiPrefsCanSync("themeMode"), + profileId: this.context.gateway.snapshot?.selfUser?.id, + }, ); } @@ -891,7 +897,10 @@ export class ConfigPage extends OpenClawLightDomElement { "accent", this.context.gateway.connection.gatewayUrl, this.settings, - { canSync: this.serverUiPrefsCanSync() }, + { + canSync: this.serverUiPrefsCanSync("accent"), + profileId: this.context.gateway.snapshot?.selfUser?.id, + }, ); } @@ -915,9 +924,15 @@ export class ConfigPage extends OpenClawLightDomElement { ); } - private serverUiPrefsCanSync(): boolean | null { + private serverUiPrefsCanSync(key?: "theme" | "themeMode" | "accent"): boolean | null { const runtimeConfig = this.context.runtimeConfig; - return runtimeConfig.state.connected ? runtimeConfig.canPatch !== false : null; + if (!runtimeConfig.state.connected) { + return null; + } + const gateway = this.context.gateway.snapshot; + return key && gateway?.selfUser + ? hasOperatorWriteAccess(gateway.hello?.auth ?? null) + : runtimeConfig.canPatch !== false; } private resetLocale() { diff --git a/ui/src/pages/config/view-appearance-preferences.ts b/ui/src/pages/config/view-appearance-preferences.ts index 7e3dbf99e49d..48690550b5f7 100644 --- a/ui/src/pages/config/view-appearance-preferences.ts +++ b/ui/src/pages/config/view-appearance-preferences.ts @@ -32,6 +32,9 @@ import { renderSettingsSelectRow } from "./settings-select-row.ts"; import type { ConfigProps } from "./view-types.ts"; export function serverUiPrefProvenanceHint(provenance: ServerUiPrefProvenance): string { + if (provenance === "profile") { + return t("configView.profileSyncedHint"); + } if (provenance === "device-local") { return t("quickSettings.personal.browserOnly"); }