diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 10596e14b98a..7b67958017ea 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -94,7 +94,7 @@ d340686cf814326b5a554a32cc5ca324a9e1254a933c4d9d8d1ada5ac7109a10 module/command a821f9cc4e6f9339399d99e73f58c3b00baf139aa9d85c22d73c89d8be5702d2 module/config-contracts 20f3f8042de53e4eee61b64de9102c8c202b9299e6a29235647a4729f70145f2 module/config-mutation 316949815affe623ac63951a5db580527f02663576dffa084f02768f612c0c1c module/config-runtime -a7d72564e1766cc51fafcba150e32cac267afb2fc8c92ffce4095a1bb5152030 module/config-schema +20a92012a24deb819aad3866c6e2b3cf8fee3ca83e0135464b2188bae01278e5 module/config-schema bfe6eec12f45bc2fda6df28699681b22dfd819a2261aabe8e0c789a20a017058 module/config-types 42d15153981cfe3adc1d5f91621434c56f07a9bd48c15ce34742f72dd040c142 module/context-visibility-runtime 03636897fb99cb73e4d8620c8a0e0d72b4d52fc32bf94f525af2aa88c489c6c2 module/conversation-binding-runtime diff --git a/docs/cli/openclaw.md b/docs/cli/openclaw.md index 2b6ea94df0c3..fb3e0cf0c2c5 100644 --- a/docs/cli/openclaw.md +++ b/docs/cli/openclaw.md @@ -104,19 +104,26 @@ Doctor repairs are unavailable inside OpenClaw because they can rewrite the prov New agents inherit the live-verified default inference route. The agent ids `openclaw` and `crestodian` are reserved for the system agent and cannot be created as normal agents. The retired id remains blocked so an old config cannot claim it. -`config set` and `config set-ref` cannot change inference-route state, -including inference-provider credentials, top-level `auth.*`, model catalogs, -CLI backends, default/per-agent model routes, agent params/tools, or root -`tools.*`. Raw writes under `env.*`, `secrets.*`, `plugins.*`, and `$include` -are also refused because they can replace credential resolution or provider -activation. Gateway and channel auth remain normal config surfaces. Use typed plugin/channel workflows and -`set default model ` for an already -configured route; it live-tests the route before saving it. To configure or -repair provider/auth access, exit OpenClaw and run `openclaw onboard`. +`config set` and `config set-ref` can change any setting a user can change, +with a short human-only denylist: `$include`, `auth.*`, `env.*`, `models.*`, +and `secrets.*` stay refused because they carry credential material, +alternate-config inclusion, or the provider/catalog definitions that feed +inference routing. Inference routing itself is also protected: default model +routes (`agents.defaults` model/params/runtime fields) and the routing fields +of whichever agent backs the active default route are refused, as are agent +identity/topology fields (`id`, `agentDir`, `default`). Routing fields for +other agents remain writable behind approval. Gateway and channel auth remain +normal config surfaces. Use `set default model ` for an +already configured route; it live-tests the route before saving it. To +configure or repair provider/auth access, exit OpenClaw and run +`openclaw onboard`. -Plugin uninstall is refused inside OpenClaw because removing a provider -plugin could disable the inference route powering the session. Exit OpenClaw -and run `openclaw plugins uninstall ` from a terminal. +`plugins.entries..*` writes (enable/disable/config of installed plugins) +are allowed unless that plugin backs the active inference route. Plugin +install sources and load policy keep their trust boundary in the typed +plugin-install workflow. Plugin uninstall of the route-backing plugin is +refused for the same reason; exit OpenClaw and run +`openclaw plugins uninstall ` from a terminal. Approval is given in your own words: unambiguous replies ("yes", "sure", "go ahead", "not now") resolve from a closed deterministic list. When the configured route supports a separate completion call, other replies can be classified from only your message and the pending proposal — never by the conversation model itself, which cannot self-approve. Unclassified or ambiguous replies keep the proposal pending and the conversation asks again. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 35c280050a0d..b847e644b254 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -519,7 +519,9 @@ See [Inferred commitments](/concepts/commitments). locale: "en", chatShowThinking: true, chatShowToolCalls: true, + chatPersistCommentary: false, chatSendShortcut: "enter", // enter | modifier-enter + chatFollowUpMode: "steer", // steer | queue; omit to use the server queue mode }, }, } diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index ea1876bccaae..8c578570a3da 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -94,11 +94,11 @@ The Control UI fetches its runtime settings from `/control-ui-config.json`, reso ## Gateway host status -Open **Settings** in Simple view to see the **Gateway Host** card with the Gateway machine, LAN address, operating system, runtime, uptime, CPU load, memory, and state-volume disk space. The card refreshes every 10 seconds while visible through the `system.info` Gateway RPC, which requires the `operator.read` scope. Older Gateways and connections without that scope omit the card. +Open **Settings → General** to see the **Gateway Host** card with the Gateway machine, LAN address, operating system, runtime, uptime, CPU load, memory, and state-volume disk space. The card refreshes every 10 seconds while visible through the `system.info` Gateway RPC, which requires the `operator.read` scope. Older Gateways and connections without that scope omit the card. ## Language support -The Control UI localizes itself on first load based on your browser locale. To override it later, open **Settings -> General -> Language** (the picker lives in the General quick-settings card, not under Appearance). +The Control UI localizes itself on first load based on your browser locale. To override it later, open **Settings -> General -> Language** (the picker lives on the General page, not under Appearance). - Supported locales: `en`, `ar`, `de`, `es`, `fa`, `fr`, `hi`, `id`, `it`, `ja-JP`, `ko`, `nl`, `pl`, `pt-BR`, `ru`, `th`, `tr`, `uk`, `vi`, `zh-CN`, `zh-TW` - Non-English translations are lazy-loaded in the browser. diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index dda7d445f9c8..54e00fe0868c 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -206,8 +206,12 @@ export type OpenClawConfig = { chatShowThinking?: boolean; /** Show tool call cards in chat. */ chatShowToolCalls?: boolean; + /** Keep model commentary visible in the transcript after a run. */ + chatPersistCommentary?: boolean; /** Chat send shortcut: Enter sends, or modifier+Enter sends. */ chatSendShortcut?: "enter" | "modifier-enter"; + /** Follow-up handling while a run is active; unset uses the server queue mode. */ + chatFollowUpMode?: "steer" | "queue"; }; }; /** Terminal UI display settings. */ diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index 08dc934639b4..ed22a6f9403d 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -295,7 +295,9 @@ export const OpenClawSchemaShape = { locale: z.string().max(20).optional(), chatShowThinking: z.boolean().optional(), chatShowToolCalls: z.boolean().optional(), + chatPersistCommentary: z.boolean().optional(), chatSendShortcut: z.union([z.literal("enter"), z.literal("modifier-enter")]).optional(), + chatFollowUpMode: z.union([z.literal("steer"), z.literal("queue")]).optional(), }) .optional(), }) diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index ebd106db0698..cae8cb4c9d20 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -724,6 +724,9 @@ function createReloaderHarness( const onRestart = vi.fn( options.onRestart ?? ((_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {}), ); + const onConfigCandidateCommitted = vi.fn( + (_info: { path: string; persistedHash: string | null; changedPaths: readonly string[] }) => {}, + ); let writeListener: ((event: ConfigWriteNotification) => void) | null = null; const subscribeToWrites = vi.fn((listener: (event: ConfigWriteNotification) => void) => { writeListener = listener; @@ -762,6 +765,7 @@ function createReloaderHarness( onNoopConfigCommit, onHotReload, onRestart, + onConfigCandidateCommitted, ...(options.runTransaction ? { runTransaction: options.runTransaction } : {}), log, watchPath: "/tmp/openclaw.json", @@ -776,6 +780,7 @@ function createReloaderHarness( onNoopConfigCommit, onHotReload, onRestart, + onConfigCandidateCommitted, log, reloader, emitWrite(event: ConfigWriteNotification) { @@ -848,6 +853,61 @@ describe("startGatewayConfigReloader", () => { }, ); + it("notifies change listeners for every accepted external edit, including runtime-skipped ones", async () => { + const initialConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + }; + // ui.* is a no-op reload class: the runtime snapshot refreshes without a + // hot reload or restart — exactly the agent-changes-theme case. + const nextConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + ui: { prefs: { themeMode: "dark" } }, + }; + const readSnapshot = vi.fn(async () => + makeSnapshot({ config: nextConfig, hash: "external-prefs-write" }), + ); + const harness = createReloaderHarness(readSnapshot, { initialConfig }); + + harness.watcher.emit("change"); + await vi.runAllTimersAsync(); + + expect(harness.onConfigCandidateCommitted).toHaveBeenCalledOnce(); + expect(harness.onConfigCandidateCommitted).toHaveBeenCalledWith({ + path: "/tmp/openclaw.json", + persistedHash: "external-prefs-write", + changedPaths: ["ui"], + }); + + // A same-content echo must not re-notify: nothing changed. + harness.onConfigCandidateCommitted.mockClear(); + harness.watcher.emit("change"); + await vi.runAllTimersAsync(); + expect(harness.onConfigCandidateCommitted).not.toHaveBeenCalled(); + await harness.reloader.stop(); + }); + + it("notifies change listeners when reload mode off skips the runtime apply", async () => { + const initialConfig: OpenClawConfig = { + gateway: { reload: { mode: "off", debounceMs: 0 } }, + }; + const nextConfig: OpenClawConfig = { + gateway: { reload: { mode: "off", debounceMs: 0 } }, + ui: { prefs: { themeMode: "light" } }, + }; + const readSnapshot = vi.fn(async () => + makeSnapshot({ config: nextConfig, hash: "mode-off-write" }), + ); + const harness = createReloaderHarness(readSnapshot, { initialConfig }); + + harness.watcher.emit("change"); + await vi.runAllTimersAsync(); + + expect(harness.onHotReload).not.toHaveBeenCalled(); + expect(harness.onRestart).not.toHaveBeenCalled(); + expect(harness.onConfigCandidateCommitted).toHaveBeenCalledOnce(); + await harness.reloader.stop(); + }); + it("notifies lifecycle owners when a persisted edit reverts to the current baseline", async () => { const initialConfig: OpenClawConfig = { gateway: { reload: { debounceMs: 0 }, port: 18789 }, diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index e3a86c7c127c..301fe04458d2 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -180,6 +180,17 @@ export function startGatewayConfigReloader(opts: { /** Runs only when this exact source publication can no longer roll back. */ commit?: () => void; }>; + /** + * Fires once per accepted candidate whose persisted content changed — + * regardless of writer (gateway RPC, agent/CLI config_set, doctor, hand + * edit) and of whether the runtime applied it. The single notification + * point for change listeners such as the config.changed broadcast. + */ + onConfigCandidateCommitted?: (info: { + path: string; + persistedHash: string | null; + changedPaths: readonly string[]; + }) => void; onNoopConfigCommit: ( plan: GatewayReloadPlan, nextConfig: OpenClawConfig, @@ -445,6 +456,17 @@ export function startGatewayConfigReloader(opts: { // a baseline-only candidate, which can discard prepared lifecycle state. await appliedRevision.flush(currentConfig); assertCurrent(); + // Persisted content changed even when the runtime skipped applying it + // (writer intent, reload mode off): change listeners still refresh. + const notifyCommitted = () => { + if (changedPaths.length > 0) { + opts.onConfigCandidateCommitted?.({ + path: opts.watchPath, + persistedHash: persistedHash ?? null, + changedPaths, + }); + } + }; let rollbackAcceptedSource: (() => Promise) | undefined; try { const acceptedSourceRollback = await opts.onConfigAccepted?.( @@ -471,6 +493,7 @@ export function startGatewayConfigReloader(opts: { lastSourceOnlyRuntimeRefresh = ownership.runtimeRefresh; lastSourceOnlyRuntimeConfig = nextConfig; lastSourceOnlySourceConfig = nextSourceConfig; + notifyCommitted(); return; } // Runtime owners publish env at their commit edge. Keep this idempotent @@ -499,6 +522,7 @@ export function startGatewayConfigReloader(opts: { await rollbackAcceptedSource?.(); throw error; } + notifyCommitted(); }; if (changedPaths.length === 0) { let publishedSource: { rollback: () => Promise; commit?: () => void } | undefined; diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index d35dc4e17ef8..bfe1e1ae5513 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -522,28 +522,6 @@ function clearConfigSchemaResponseCache() { configSchemaResponseCache = null; } -/** - * Hash-only change notice so connected operator clients can refresh their - * config snapshot (e.g. live ui.prefs sync after an agent-approved write). - * Config content never rides the event; readers fetch via config.get. - */ -function broadcastConfigChanged( - context: GatewayRequestContext | undefined, - writeResult: Pick, -): void { - // Test doubles and embedded hosts may omit broadcast; the notice is - // best-effort either way. - context?.broadcast?.( - "config.changed", - { - path: writeResult.path, - hash: writeResult.hash ?? null, - ts: Date.now(), - }, - { dropIfSlow: true }, - ); -} - async function respondWithConfigRestartWrite(params: { requestParams: unknown; kind: ConfigRestartWriteKind; @@ -583,7 +561,6 @@ async function respondWithConfigRestartWrite(params: { }, undefined, ); - broadcastConfigChanged(params.context, params.writeResult); params.writeResult.queueFollowUp(); } @@ -737,7 +714,6 @@ export const configHandlers: GatewayRequestHandlers = { }, undefined, ); - broadcastConfigChanged(context, writeResult); writeResult.queueFollowUp(); }, "config.patch": async ({ params, respond, client, context }) => { diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 5a2c1e5aaa77..59871d65074d 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -1850,6 +1850,16 @@ export function startManagedGatewayConfigReloader( const configReloader = startGatewayConfigReloader({ initialConfig: params.initialConfig, initialCompareConfig: params.initialCompareConfig, + // Single notification point for every persisted config change — gateway + // RPC writes, agent/CLI config_set, doctor, and hand edits all land here + // once the candidate is accepted. Hash-only; clients refresh via config.get. + onConfigCandidateCommitted: (info) => { + params.broadcast( + "config.changed", + { path: info.path, hash: info.persistedHash, ts: Date.now() }, + { dropIfSlow: true }, + ); + }, ...(params.prepareConfigCandidate ? { prepareConfigCandidate: params.prepareConfigCandidate } : {}), diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index 779a893ff860..7d4329762e6b 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -201,7 +201,7 @@ describe("subtitleForRoute", () => { plugins: "Install and manage optional capabilities.", "skill-workshop": "Review, refine, and apply proposals before they become live skills.", nodes: "Paired devices, pairing approvals, and exec bindings.", - config: "Edit openclaw.json.", + config: "Model defaults, language, and gateway host.", profile: "Your agent's stats, streaks, and life in the reef.", communications: "Channels, messages, and audio settings.", appearance: "Theme, UI, and setup wizard settings.", diff --git a/ui/src/app/server-prefs.test.ts b/ui/src/app/server-prefs.test.ts index 5db7ae58b8e9..e5b53162e8f0 100644 --- a/ui/src/app/server-prefs.test.ts +++ b/ui/src/app/server-prefs.test.ts @@ -131,6 +131,35 @@ describe("changedServerUiPrefs", () => { expect(changedServerUiPrefs(previous, next)).toEqual({ themeMode: "dark" }); expect(changedServerUiPrefs(previous, { ...previous })).toBeNull(); }); + + it("syncs chat behavior prefs and pushes clearable resets as null", () => { + const previous = loadSettings(); + const withOverrides = { + ...previous, + chatPersistCommentary: true, + chatFollowUpMode: "queue" as const, + }; + expect(changedServerUiPrefs(previous, withOverrides)).toEqual({ + chatPersistCommentary: true, + chatFollowUpMode: "queue", + }); + + // Clearing the follow-up override must propagate as an explicit removal. + expect( + changedServerUiPrefs(withOverrides, { ...withOverrides, chatFollowUpMode: undefined }), + ).toEqual({ chatFollowUpMode: null }); + }); +}); + +describe("clearable pref removal from the server", () => { + it("clears the local follow-up override when the server removes it", () => { + const onApplied = vi.fn(); + applyServerUiPrefs(configWithPrefs({ chatFollowUpMode: "queue" }), { onApplied }); + expect(loadSettings().chatFollowUpMode).toBe("queue"); + + expect(applyServerUiPrefs(configWithPrefs({}), { onApplied })).toBe(true); + expect(loadSettings().chatFollowUpMode).toBeUndefined(); + }); }); describe("pushServerUiPrefs", () => { diff --git a/ui/src/app/server-prefs.ts b/ui/src/app/server-prefs.ts index 927fc8a0683c..7bd04f0a3d44 100644 --- a/ui/src/app/server-prefs.ts +++ b/ui/src/app/server-prefs.ts @@ -10,55 +10,105 @@ import type { GatewayBrowserClient } from "../api/gateway.ts"; import { isSupportedLocale } from "../i18n/index.ts"; import { loadSettings, + normalizeChatFollowUpModeOverride, normalizeChatSendShortcut, normalizeTextScale, patchSettings, TEXT_SCALE_STOPS, + type ChatFollowUpMode, type ChatSendShortcut, type TextScaleStop, type UiSettings, } from "./settings.ts"; import type { ThemeMode, ThemeName } from "./theme.ts"; -type ServerUiPrefs = { - theme?: ThemeName; - themeMode?: ThemeMode; - textScale?: TextScaleStop; - locale?: string; - chatShowThinking?: boolean; - chatShowToolCalls?: boolean; - chatSendShortcut?: ChatSendShortcut; -}; - const THEMES: ReadonlySet = new Set(["claw", "knot", "dash", "custom"]); const THEME_MODES: ReadonlySet = new Set(["light", "dark", "system"]); +/** + * One descriptor per synced pref — the single source of truth for what syncs + * through config ui.prefs. Each key defines how to validate the server value, + * read the normalized local value, and (optionally) whether a server value is + * applicable on this device. `clearable` keys push an explicit JSON null when + * unset locally so the merge patch removes them server-side. + */ +type SyncedPrefSpec = { + extract: (value: unknown) => T | undefined; + local: (settings: UiSettings) => T | undefined; + canApply?: (value: T, settings: UiSettings) => boolean; + clearable?: boolean; +}; + +const prefSpec = (specification: SyncedPrefSpec) => specification; + +const SYNCED_PREFS = { + theme: prefSpec({ + extract: (value) => (THEMES.has(value as ThemeName) ? (value as ThemeName) : undefined), + local: (settings) => settings.theme, + // A server "custom" theme is only honorable once this browser imported + // one; the imported palette itself is too large to live in config. + canApply: (value, settings) => value !== "custom" || Boolean(settings.customTheme), + }), + themeMode: prefSpec({ + extract: (value) => (THEME_MODES.has(value as ThemeMode) ? (value as ThemeMode) : undefined), + local: (settings) => settings.themeMode, + }), + textScale: prefSpec({ + extract: (value) => + TEXT_SCALE_STOPS.includes(value as TextScaleStop) ? normalizeTextScale(value) : undefined, + local: (settings) => normalizeTextScale(settings.textScale), + }), + locale: prefSpec({ + extract: (value) => (typeof value === "string" && isSupportedLocale(value) ? value : undefined), + local: (settings) => settings.locale, + }), + chatShowThinking: prefSpec({ + extract: (value) => (typeof value === "boolean" ? value : undefined), + local: (settings) => settings.chatShowThinking, + }), + chatShowToolCalls: prefSpec({ + extract: (value) => (typeof value === "boolean" ? value : undefined), + local: (settings) => settings.chatShowToolCalls, + }), + chatPersistCommentary: prefSpec({ + extract: (value) => (typeof value === "boolean" ? value : undefined), + local: (settings) => settings.chatPersistCommentary ?? false, + }), + chatSendShortcut: prefSpec({ + extract: (value) => + value === "enter" || value === "modifier-enter" + ? normalizeChatSendShortcut(value) + : undefined, + local: (settings) => normalizeChatSendShortcut(settings.chatSendShortcut), + }), + chatFollowUpMode: prefSpec({ + extract: (value) => normalizeChatFollowUpModeOverride(value), + local: (settings) => normalizeChatFollowUpModeOverride(settings.chatFollowUpMode), + // Unset means "use the server-configured queue mode"; clearing must + // propagate, so the push serializes an explicit null removal. + clearable: true, + }), +} as const; + +type SyncedPrefKey = keyof typeof SYNCED_PREFS; +type SyncedPrefValue = + ReturnType<(typeof SYNCED_PREFS)[K]["extract"]> extends (infer T) | undefined ? T : never; + +type ServerUiPrefs = { [K in SyncedPrefKey]?: SyncedPrefValue | null }; + +const SYNCED_PREF_KEYS = Object.keys(SYNCED_PREFS) as SyncedPrefKey[]; + function extractServerUiPrefs(configObject: unknown): ServerUiPrefs { const prefs = asRecord(asRecord(asRecord(configObject)?.ui)?.prefs); if (!prefs) { return {}; } const result: ServerUiPrefs = {}; - if (THEMES.has(prefs.theme as ThemeName)) { - result.theme = prefs.theme as ThemeName; - } - if (THEME_MODES.has(prefs.themeMode as ThemeMode)) { - result.themeMode = prefs.themeMode as ThemeMode; - } - if (TEXT_SCALE_STOPS.includes(prefs.textScale as TextScaleStop)) { - result.textScale = normalizeTextScale(prefs.textScale); - } - if (typeof prefs.locale === "string" && isSupportedLocale(prefs.locale)) { - result.locale = prefs.locale; - } - if (typeof prefs.chatShowThinking === "boolean") { - result.chatShowThinking = prefs.chatShowThinking; - } - if (typeof prefs.chatShowToolCalls === "boolean") { - result.chatShowToolCalls = prefs.chatShowToolCalls; - } - if (prefs.chatSendShortcut === "enter" || prefs.chatSendShortcut === "modifier-enter") { - result.chatSendShortcut = normalizeChatSendShortcut(prefs.chatSendShortcut); + for (const key of SYNCED_PREF_KEYS) { + const value = SYNCED_PREFS[key].extract(prefs[key]); + if (value !== undefined) { + (result as Record)[key] = value; + } } return result; } @@ -69,39 +119,33 @@ function serverPrefsLocalPatch( settings: UiSettings, ): Partial | null { const patch: Partial = {}; - // A server "custom" theme is only honorable once this browser imported one; - // the imported palette itself is too large to live in config. - if (prefs.theme !== undefined && prefs.theme !== settings.theme) { - if (prefs.theme !== "custom" || settings.customTheme) { - patch.theme = prefs.theme; + for (const key of SYNCED_PREF_KEYS) { + const specification = SYNCED_PREFS[key]; + const serverValue = prefs[key]; + if (serverValue === undefined) { + continue; } - } - if (prefs.themeMode !== undefined && prefs.themeMode !== settings.themeMode) { - patch.themeMode = prefs.themeMode; - } - if (prefs.textScale !== undefined && prefs.textScale !== normalizeTextScale(settings.textScale)) { - patch.textScale = prefs.textScale; - } - if (prefs.locale !== undefined && prefs.locale !== settings.locale) { - patch.locale = prefs.locale; - } - if ( - prefs.chatShowThinking !== undefined && - prefs.chatShowThinking !== settings.chatShowThinking - ) { - patch.chatShowThinking = prefs.chatShowThinking; - } - if ( - prefs.chatShowToolCalls !== undefined && - prefs.chatShowToolCalls !== settings.chatShowToolCalls - ) { - patch.chatShowToolCalls = prefs.chatShowToolCalls; - } - if ( - prefs.chatSendShortcut !== undefined && - prefs.chatSendShortcut !== normalizeChatSendShortcut(settings.chatSendShortcut) - ) { - patch.chatSendShortcut = prefs.chatSendShortcut; + // Null marks a server-side removal of a clearable key: drop the local + // override so this device falls back to the server-configured behavior. + if (serverValue === null) { + if (specification.clearable && specification.local(settings) !== undefined) { + (patch as Record)[key] = undefined; + } + continue; + } + if (serverValue === specification.local(settings)) { + continue; + } + if ( + specification.canApply && + !(specification.canApply as (value: unknown, settings: UiSettings) => boolean)( + serverValue, + settings, + ) + ) { + continue; + } + (patch as Record)[key] = serverValue; } return Object.keys(patch).length > 0 ? patch : null; } @@ -109,29 +153,21 @@ function serverPrefsLocalPatch( /** Synced-key delta between two local settings snapshots, for the push path. */ export function changedServerUiPrefs(previous: UiSettings, next: UiSettings): ServerUiPrefs | null { const prefs: ServerUiPrefs = {}; - if (next.theme !== previous.theme) { - prefs.theme = next.theme; - } - if (next.themeMode !== previous.themeMode) { - prefs.themeMode = next.themeMode; - } - if (normalizeTextScale(next.textScale) !== normalizeTextScale(previous.textScale)) { - prefs.textScale = normalizeTextScale(next.textScale); - } - if (next.locale !== previous.locale && next.locale) { - prefs.locale = next.locale; - } - if (next.chatShowThinking !== previous.chatShowThinking) { - prefs.chatShowThinking = next.chatShowThinking; - } - if (next.chatShowToolCalls !== previous.chatShowToolCalls) { - prefs.chatShowToolCalls = next.chatShowToolCalls; - } - if ( - normalizeChatSendShortcut(next.chatSendShortcut) !== - normalizeChatSendShortcut(previous.chatSendShortcut) - ) { - prefs.chatSendShortcut = normalizeChatSendShortcut(next.chatSendShortcut); + for (const key of SYNCED_PREF_KEYS) { + const specification = SYNCED_PREFS[key]; + const previousValue = specification.local(previous); + const nextValue = specification.local(next); + if (previousValue === nextValue) { + continue; + } + if (nextValue === undefined) { + // JSON merge patch removes keys via explicit null. + if (specification.clearable) { + (prefs as Record)[key] = null; + } + continue; + } + (prefs as Record)[key] = nextValue; } return Object.keys(prefs).length > 0 ? prefs : null; } @@ -225,6 +261,14 @@ export function applyServerUiPrefs( (changed as Record)[prefKey] = prefs[prefKey]; } } + // A clearable key that disappeared from the server was removed by another + // writer; surface the removal as an explicit null so the local override + // clears too (non-clearable keys keep their device-local value). + for (const prefKey of Object.keys(lastSeen) as Array) { + if (!(prefKey in prefs) && SYNCED_PREFS[prefKey]?.clearable) { + (changed as Record)[prefKey] = null; + } + } storeLastSeenKey(scope, key); const patch = serverPrefsLocalPatch(changed, loadSettings()); if (!patch) { diff --git a/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts b/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts index 19165c881bbf..6ead60303039 100644 --- a/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts +++ b/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts @@ -37,7 +37,7 @@ function requestRaw(request: MockGatewayRequest): Record { return JSON.parse(String((params as Record).raw)) as Record; } -describeControlUiE2e("Control UI Quick Config thinking persistence mocked Gateway E2E", () => { +describeControlUiE2e("Control UI General settings thinking persistence mocked Gateway E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { throw new Error( diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 6cc9715ca730..b075894f1486 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1038,6 +1038,9 @@ export const en: TranslationMap = { }, }, }, + // Historical namespace from the pre-restructure "quick settings" page; its + // keys now serve General, Privacy & Security, Appearance, and Profile. + // Renaming would force retranslation of every key, so the name stays. quickSettings: { language: "Language", model: { @@ -1198,6 +1201,7 @@ export const en: TranslationMap = { xl: "XL", xxl: "XXL", }, + syncedHint: "Synced across your devices through the gateway.", notifications: { title: "Push notifications", hint: "Receive browser push notifications from your gateway.", @@ -1671,7 +1675,7 @@ export const en: TranslationMap = { nodes: "Paired devices, pairing approvals, and exec bindings.", chat: "Gateway chat for quick interventions.", custodian: "System setup and care.", - config: "Edit openclaw.json.", + config: "Model defaults, language, and gateway host.", profile: "Your agent's stats, streaks, and life in the reef.", communications: "Channels, messages, and audio settings.", appearance: "Theme, UI, and setup wizard settings.", diff --git a/ui/src/lib/agents/display.ts b/ui/src/lib/agents/display.ts index bd33e9834770..2b37c90fc346 100644 --- a/ui/src/lib/agents/display.ts +++ b/ui/src/lib/agents/display.ts @@ -202,7 +202,9 @@ const FALLBACK_TOOL_SECTIONS: FallbackToolSection[] = [ }, ]; -const PROFILE_OPTIONS = [ +// Canonical UI tool-profile list; Security and Agents surfaces share it so +// labels stay translated and consistent. +export const PROFILE_OPTIONS = [ { id: "minimal", labelKey: "agents.toolCatalog.profiles.minimal" }, { id: "coding", labelKey: "agents.toolCatalog.profiles.coding" }, { id: "messaging", labelKey: "agents.toolCatalog.profiles.messaging" }, diff --git a/ui/src/lib/chat/thinking.ts b/ui/src/lib/chat/thinking.ts index d496658e211e..a35d15a8f11f 100644 --- a/ui/src/lib/chat/thinking.ts +++ b/ui/src/lib/chat/thinking.ts @@ -15,7 +15,8 @@ type ThinkingCatalogEntry = { reasoning?: boolean; }; -const BASE_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const; +/** Canonical thinking levels; surfaces needing a subset derive it explicitly. */ +export const BASE_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const; export function normalizeThinkLevel(raw?: string | null): string | undefined { if (!raw) { diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 719d7aca2b56..8f14b7cf21c9 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -4023,6 +4023,39 @@ describe("chat model controls", () => { expect(onModelSelect).toHaveBeenCalledWith(modelOption?.dataset.chatModelOption, "main"); }); + it("marks the inherited default muted and resets an override from the provenance row", () => { + const { state } = createChatHeaderState({ + model: null, + models: [ + { id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }, + { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, + ], + }); + const container = document.createElement("div"); + render(renderChatModelControls(createChatModelControlsProps(state)), container); + + expect( + container.querySelector(".chat-controls__model-provenance-value--inherit"), + ).not.toBeNull(); + expect(container.querySelector("[data-chat-model-reset]")).toBeNull(); + + const onModelSelect = vi.fn(async () => true); + render( + renderChatModelControls({ + ...createChatModelControlsProps(state), + modelOverrides: { main: "openai/gpt-5.4" }, + onModelSelect, + }), + container, + ); + + expect(container.querySelector(".chat-controls__model-provenance-value--inherit")).toBeNull(); + const reset = container.querySelector("[data-chat-model-reset]"); + expect(reset).toBeInstanceOf(HTMLButtonElement); + reset?.click(); + expect(onModelSelect).toHaveBeenCalledWith("", "main"); + }); + it("hides model choices for locked sessions while preserving reasoning and speed", () => { const { state } = createChatHeaderState({ model: "gpt-5.5", diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index 41398cf081e9..3385c6c3a89e 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -41,7 +41,6 @@ export type ChatModelControlsProps = { modelSelectionRuntimeId?: string; modelSwitching: boolean; modelsLoading?: boolean; - mode?: "combined" | "model"; showFastMode?: boolean; sending: boolean; sessionKey: string; @@ -249,7 +248,6 @@ export function renderChatModelControls(props: ChatModelControlsProps) { disabled, fastMode, modelSelectionLocked: props.modelSelectionLocked === true, - modelOnly: props.mode === "model", modelOptions, onRequestUpdate: props.onRequestUpdate, selectedModelValue: currentOverride, @@ -357,7 +355,6 @@ function renderChatModelReasoningSelect(params: { fastMode: ChatFastModeSelectState; disabled: boolean; modelSelectionLocked: boolean; - modelOnly: boolean; modelOptions: ChatModelProviderOption[]; selectedModelValue: string; selectedThinkingValue: string; @@ -378,7 +375,6 @@ function renderChatModelReasoningSelect(params: { disabled, fastMode, modelSelectionLocked, - modelOnly, modelOptions, selectedModelValue, selectedThinkingValue, @@ -396,7 +392,7 @@ function renderChatModelReasoningSelect(params: { } = params; const triggerModel = formatCombinedPickerModelLabel(triggerModelLabel); const triggerThinking = formatCombinedPickerThinkingLabel(triggerThinkingLabel); - const triggerTitle = modelOnly ? triggerModel : `${triggerModel} · ${triggerThinking}`; + const triggerTitle = `${triggerModel} · ${triggerThinking}`; const triggerLabel = triggerTitle; const sliderStops = thinkingOptions.filter((option) => option.value !== ""); const defaultStopIndex = sliderStops.findIndex((option) => option.value === thinkingDefaultValue); @@ -487,7 +483,7 @@ function renderChatModelReasoningSelect(params: { const onlyStop = sliderStops.length === 1 ? sliderStops[0] : undefined; const effectiveThinkingValue = selectedThinkingValue || thinkingDefaultValue; const onlyStopSelected = onlyStop?.value === effectiveThinkingValue; - const showReasoningPanel = !modelOnly && (showReasoning || showFastMode); + const showReasoningPanel = showReasoning || showFastMode; const providerGroups = new Map(); for (const option of modelOptions) { const existing = providerGroups.get(option.provider); @@ -573,13 +569,13 @@ function renderChatModelReasoningSelect(params: { : ""}" data-chat-model-select="true" data-chat-model-locked=${modelSelectionLocked ? "true" : "false"} - data-chat-thinking-select=${modelOnly ? nothing : "true"} + data-chat-thinking-select="true" data-chat-select-value=${selectedModelValue} data-chat-thinking-value=${selectedThinkingValue} data-chat-thinking-disabled=${thinkingDisabled ? "true" : "false"} - aria-label=${modelOnly - ? `${t("chat.selectors.model")}: ${triggerTitle}` - : `${t("chat.selectors.model")}, ${t("chat.selectors.thinkingLevel")}: ${triggerTitle}`} + aria-label="${t("chat.selectors.model")}, ${t( + "chat.selectors.thinkingLevel", + )}: ${triggerTitle}" aria-disabled=${disabled ? "true" : "false"} @click=${(event: MouseEvent) => { if (disabled) { diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index 78f242bc3672..469219f9e7d5 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -54,7 +54,13 @@ export type { ConfigPageId } from "./config-sections.ts"; type ConfigFormMode = "form" | "raw"; type ConfigSelection = { activeSection: string | null; activeSubsection: string | null }; -type LocalUiSetting = "textScale" | "chatSendShortcut" | "chatFollowUpMode" | "catalogOpenTarget"; +// Keys settable through this page's setSetting helper. Whether a key syncs +// across devices is owned by app/server-prefs.ts, not by this type. +type ConfigPageSetting = + | "textScale" + | "chatSendShortcut" + | "chatFollowUpMode" + | "catalogOpenTarget"; const CONFIG_PAGE_I18N_KEYS = { config: "config", @@ -621,7 +627,7 @@ export class ConfigPage extends OpenClawLightDomElement { }); } - private setSetting(key: K, value: UiSettings[K]) { + private setSetting(key: K, value: UiSettings[K]) { this.applySettings({ ...this.settings, [key]: value }); } diff --git a/ui/src/pages/config/quick.ts b/ui/src/pages/config/quick.ts index 8529595e896b..fc70d2ffe90c 100644 --- a/ui/src/pages/config/quick.ts +++ b/ui/src/pages/config/quick.ts @@ -23,6 +23,7 @@ import { } from "../../components/settings-ui.ts"; import { t, type Locale } from "../../i18n/index.ts"; import { formatBytes } from "../../lib/agents/display.ts"; +import { BASE_THINKING_LEVELS } from "../../lib/chat/thinking.ts"; import type { ConfigAutoSaveStatus } from "../../lib/config/index.ts"; import { formatDurationHuman } from "../../lib/format.ts"; import { renderLanguageSelect } from "./language-select.ts"; @@ -67,7 +68,9 @@ type QuickSettingsProps = { version: string; }; -const THINKING_LEVELS = ["off", "low", "medium", "high"]; +// The compact General hub intentionally omits "minimal"; the full list stays +// available on session-level pickers. +const THINKING_LEVELS = BASE_THINKING_LEVELS.filter((level) => level !== "minimal"); /** Section wrapper that keeps the stable settings-search scroll target ids. */ function renderTargetSection( @@ -97,6 +100,7 @@ function renderGeneralSection(props: QuickSettingsProps) { return renderSettingsSection({ title: t("nav.settingsGeneral") }, [ renderSettingsRow({ title: t("quickSettings.language"), + description: t("configView.syncedHint"), control: renderLanguageSelect(props.locale, props.onLocaleChange), }), ]); diff --git a/ui/src/pages/config/security.test.ts b/ui/src/pages/config/security.test.ts index 1679f162a75d..0efa4373f265 100644 --- a/ui/src/pages/config/security.test.ts +++ b/ui/src/pages/config/security.test.ts @@ -94,9 +94,9 @@ describe("renderSecurity", () => { browserInput.dispatchEvent(new Event("change")); expect(onBrowserEnabledToggle).toHaveBeenCalledWith(true); - selectRadio(expectButtonByText(container, "full")); + selectRadio(expectButtonByText(container, "Full")); expect(onToolProfileChange).toHaveBeenCalledWith("full"); - const activeProfile = expectButtonByText(container, "messaging"); + const activeProfile = expectButtonByText(container, "Messaging"); expect(activeProfile.classList.contains("settings-segmented__btn--active")).toBe(true); }); @@ -106,7 +106,7 @@ describe("renderSecurity", () => { render(renderSecurity(createProps({ configBusy: true, onToolProfileChange })), container); - const profileButton = expectButtonByText(expectRowByTitle(container, "Tool profile"), "full"); + const profileButton = expectButtonByText(expectRowByTitle(container, "Tool profile"), "Full"); expect( (profileButton.closest("wa-radio-group") as HTMLElement & { disabled?: boolean }).disabled, ).toBe(true); diff --git a/ui/src/pages/config/security.ts b/ui/src/pages/config/security.ts index 1a1b742f74fa..44686f7095f4 100644 --- a/ui/src/pages/config/security.ts +++ b/ui/src/pages/config/security.ts @@ -12,6 +12,7 @@ import { renderSettingsValue, } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; +import { PROFILE_OPTIONS } from "../../lib/agents/display.ts"; export type SecurityOverview = { gatewayAuth: string; @@ -32,14 +33,16 @@ type SecurityViewProps = { editor: TemplateResult; }; -const TOOL_PROFILES = ["minimal", "coding", "messaging", "full"]; - function renderSecurityOverview(props: SecurityViewProps) { const { gatewayAuth, execPolicy, deviceAuth, browserEnabled, toolProfile } = props.security; const normalizedToolProfile = toolProfile.trim() || "full"; - const toolProfiles = TOOL_PROFILES.includes(normalizedToolProfile) - ? TOOL_PROFILES - : [...TOOL_PROFILES, normalizedToolProfile]; + const profileOptions = PROFILE_OPTIONS.map((profile) => ({ + value: profile.id as string, + label: t(profile.labelKey), + })); + if (!profileOptions.some((option) => option.value === normalizedToolProfile)) { + profileOptions.push({ value: normalizedToolProfile, label: normalizedToolProfile }); + } return renderSettingsSection({ title: t("quickSettings.security.title") }, [ renderSettingsRow({ title: t("quickSettings.security.gatewayAuth"), @@ -63,7 +66,7 @@ function renderSecurityOverview(props: SecurityViewProps) { stacked: true, control: renderSettingsSegmented({ value: normalizedToolProfile, - options: toolProfiles.map((profile) => ({ value: profile, label: profile })), + options: profileOptions, disabled: props.configBusy, onChange: (profile) => props.onToolProfileChange?.(profile), }), diff --git a/ui/src/pages/config/settings-search.test.ts b/ui/src/pages/config/settings-search.test.ts index 0aef30b06a67..66ba835b0134 100644 --- a/ui/src/pages/config/settings-search.test.ts +++ b/ui/src/pages/config/settings-search.test.ts @@ -201,6 +201,23 @@ describe("findSettingsSearchBlocks", () => { ]); }); + it("routes workspace queries to the sessions-hub pages", () => { + const matches = findSettingsSearchBlocks({ + query: "worktree", + schema: null, + value: null, + uiHints: {}, + }); + + expect(matches).toEqual([ + expect.objectContaining({ + routeId: "worktrees", + label: "Managed Worktrees", + hash: "", + }), + ]); + }); + it("does not create block results for an empty query", () => { expect( findSettingsSearchBlocks({ diff --git a/ui/src/pages/config/settings-search.ts b/ui/src/pages/config/settings-search.ts index c7fc87557184..05e15aa17653 100644 --- a/ui/src/pages/config/settings-search.ts +++ b/ui/src/pages/config/settings-search.ts @@ -187,10 +187,35 @@ const COMMUNICATION_SETTINGS_BLOCKS = { }, } as const satisfies Record; +// Sessions-hub workspace pages have no schema-backed config section, so they +// only surface in search through these static entries. +const WORKSPACE_SETTINGS_BLOCKS = { + sessions: { + routeId: "sessions", + labelKey: "sessionsView.title", + hash: "", + searchKeys: ["sessionsView.subtitle", "sessionsView.archivedOnly"], + aliases: "history archive overrides", + }, + worktrees: { + routeId: "worktrees", + labelKey: "worktrees.title", + hash: "", + searchKeys: [ + "worktrees.subtitle", + "worktrees.cleanupTitle", + "worktrees.cleanupMaxCount", + "worktrees.cleanupMaxSize", + ], + aliases: "git checkout branch cleanup", + }, +} as const satisfies Record; + const STATIC_SETTINGS_BLOCKS: readonly StaticSettingsBlockDescriptor[] = [ ...Object.values(GENERAL_SETTINGS_BLOCKS), ...Object.values(APPEARANCE_SETTINGS_BLOCKS), ...Object.values(COMMUNICATION_SETTINGS_BLOCKS), + ...Object.values(WORKSPACE_SETTINGS_BLOCKS), ]; const COMMUNICATION_SECTIONS = new Set(COMMUNICATION_SECTION_KEYS); diff --git a/ui/src/pages/config/view.ts b/ui/src/pages/config/view.ts index 313d9c800f55..cf18d467b2fb 100644 --- a/ui/src/pages/config/view.ts +++ b/ui/src/pages/config/view.ts @@ -1110,7 +1110,9 @@ function renderChatPreferencesSection(props: ConfigProps) {

${t("configView.chatPrefs.title")}

-

${t("configView.chatPrefs.hint")}

+

+ ${t("configView.chatPrefs.hint")} ${t("configView.syncedHint")} +

${renderSettingsSelectRow({ title: t("chat.sendShortcut"), @@ -1287,7 +1289,9 @@ function renderAppearanceSection(props: ConfigProps) {

${t("configView.appearance.theme")}

-

${t("configView.appearance.chooseTheme")}

+

+ ${t("configView.appearance.chooseTheme")} ${t("configView.syncedHint")} +

diff --git a/ui/src/pages/profile/identity-section.ts b/ui/src/pages/profile/identity-section.ts index 5de3f3f33d24..690480806730 100644 --- a/ui/src/pages/profile/identity-section.ts +++ b/ui/src/pages/profile/identity-section.ts @@ -1,5 +1,5 @@ // Profile identity section: local user avatar plus the assistant's configured -// avatar. Moved from the General quick-settings page so identity has one home. +// avatar. Moved from the General settings page so identity has one home. import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { html, nothing } from "lit"; import { diff --git a/ui/src/styles/config-quick.css b/ui/src/styles/config-quick.css index 082e8a6b3c3f..ad73e90f4d62 100644 --- a/ui/src/styles/config-quick.css +++ b/ui/src/styles/config-quick.css @@ -41,31 +41,6 @@ openclaw-logs-page { max-height: none; } -/* Persistent Simple/Advanced switch in the page header; keeps the way back - from advanced settings visible at all times. */ -.config-view-toggle { - flex: 0 0 auto; - align-self: center; -} - -/* Mobile fallback: layout.mobile.css hides .content-header at small widths, - which would strand users in advanced mode with no way back. Mirror the - switch in an in-body row that only shows when the header is hidden. */ -.config-view-toggle-row { - display: none; -} - -@media (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) { - .config-view-toggle-row { - display: flex; - justify-content: flex-end; - width: 100%; - max-width: 760px; - margin: 0 auto; - padding: var(--space-3) var(--space-4) 0; - } -} - /* ── Config Accordion Nav (Advanced Settings, rendered by view.ts) ── */ .config-accordion-nav { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 996e6cf42cc2..c95d374aca51 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -229,8 +229,7 @@ html.openclaw-native-web-chrome .shell:not(.shell--mobile-nav) .sidebar-brand .s } .shell--settings .content-header, -.shell--settings .settings-workspace, -.shell--settings .config-view-toggle-row { +.shell--settings .settings-workspace { width: 100%; max-width: 1120px; margin-inline: auto; diff --git a/ui/src/styles/settings.css b/ui/src/styles/settings.css index 36bfaaa5977c..d859c334dc45 100644 --- a/ui/src/styles/settings.css +++ b/ui/src/styles/settings.css @@ -27,8 +27,7 @@ width: 100%; } -.content-header + .settings-workspace, -.config-view-toggle-row + .settings-workspace { +.content-header + .settings-workspace { margin-top: var(--space-5); } @@ -53,14 +52,12 @@ /* The page headline adopts the column of the page below it; without this the shell caps headers at 1120px while narrow pages use a 760px column, leaving the title floating left of the content. */ -.shell--settings .content:has(.settings-page) .content-header, -.shell--settings .content:has(.settings-page) .config-view-toggle-row { +.shell--settings .content:has(.settings-page) .content-header { max-width: 760px; padding-inline: var(--space-4); } -.shell--settings .content:has(.settings-page--wide) .content-header, -.shell--settings .content:has(.settings-page--wide) .config-view-toggle-row { +.shell--settings .content:has(.settings-page--wide) .content-header { max-width: 1120px; }