improve(ui): settings cleanup batch — universal config.changed emitter, synced chat prefs, dedupe and copy fixes (#110581)

* fix(gateway): emit config.changed from the reload committer for every accepted write

Agent config_set, CLI, and doctor writes reach the gateway through the file
watcher's reload path, which never broadcast config.changed - only direct RPC
writes did. Move the broadcast to a single onConfigCandidateCommitted hook in
the reload committer so all writers notify connected UIs, including
runtime-skipped commits (writer-intent echo suppression, reload mode off),
and delete the duplicate RPC-side broadcasts.

* feat(ui): sync chat follow-up and commentary prefs across devices

Add chatPersistCommentary and chatFollowUpMode to the ui.prefs sync surface
(schema, types, docs) with clearable null removal for the follow-up override.
Rewrite server-prefs around a descriptor table so a synced pref is one entry
instead of five hand-written code sites. Caption synced rows/sections
(chat prefs, theme, language) and refresh the stale Settings subtitle.

* refactor(ui): dedupe settings constants, drop dead composer branch, refresh copy and docs

Derive the Security tool-profile options from PROFILE_OPTIONS and General's
thinking levels from BASE_THINKING_LEVELS instead of parallel literals.
Remove the unused composer mode:"model" branch, the orphaned
.config-view-toggle CSS family, and stale quick-settings wording. Add static
settings-search entries for the Sessions and Managed Worktrees pages plus a
provenance-row test, and update docs/cli/openclaw.md to describe the current
system-agent config-write policy (denylist roots, route-backing plugin guard,
per-agent routing escalations).

* chore: refresh plugin SDK API baseline after rebase onto current main
This commit is contained in:
Peter Steinberger
2026-07-18 12:10:20 +01:00
committed by GitHub
parent 0ac69b9fe8
commit 3b2797f09b
30 changed files with 411 additions and 187 deletions
@@ -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
+19 -12
View File
@@ -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 <provider/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 <provider/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 <id>` from a terminal.
`plugins.entries.<id>.*` 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 <id>` 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.
+2
View File
@@ -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
},
},
}
+2 -2
View File
@@ -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.
+4
View File
@@ -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. */
+2
View File
@@ -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(),
})
+60
View File
@@ -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 },
+24
View File
@@ -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<void>) | 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<void>; commit?: () => void } | undefined;
-24
View File
@@ -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<ConfigWriteCommitResult, "path" | "hash">,
): 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 }) => {
+10
View File
@@ -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 }
: {}),
+1 -1
View File
@@ -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.",
+29
View File
@@ -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", () => {
+129 -85
View File
@@ -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<ThemeName> = new Set(["claw", "knot", "dash", "custom"]);
const THEME_MODES: ReadonlySet<ThemeMode> = 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<T> = {
extract: (value: unknown) => T | undefined;
local: (settings: UiSettings) => T | undefined;
canApply?: (value: T, settings: UiSettings) => boolean;
clearable?: boolean;
};
const prefSpec = <T>(specification: SyncedPrefSpec<T>) => specification;
const SYNCED_PREFS = {
theme: prefSpec<ThemeName>({
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<ThemeMode>({
extract: (value) => (THEME_MODES.has(value as ThemeMode) ? (value as ThemeMode) : undefined),
local: (settings) => settings.themeMode,
}),
textScale: prefSpec<TextScaleStop>({
extract: (value) =>
TEXT_SCALE_STOPS.includes(value as TextScaleStop) ? normalizeTextScale(value) : undefined,
local: (settings) => normalizeTextScale(settings.textScale),
}),
locale: prefSpec<string>({
extract: (value) => (typeof value === "string" && isSupportedLocale(value) ? value : undefined),
local: (settings) => settings.locale,
}),
chatShowThinking: prefSpec<boolean>({
extract: (value) => (typeof value === "boolean" ? value : undefined),
local: (settings) => settings.chatShowThinking,
}),
chatShowToolCalls: prefSpec<boolean>({
extract: (value) => (typeof value === "boolean" ? value : undefined),
local: (settings) => settings.chatShowToolCalls,
}),
chatPersistCommentary: prefSpec<boolean>({
extract: (value) => (typeof value === "boolean" ? value : undefined),
local: (settings) => settings.chatPersistCommentary ?? false,
}),
chatSendShortcut: prefSpec<ChatSendShortcut>({
extract: (value) =>
value === "enter" || value === "modifier-enter"
? normalizeChatSendShortcut(value)
: undefined,
local: (settings) => normalizeChatSendShortcut(settings.chatSendShortcut),
}),
chatFollowUpMode: prefSpec<ChatFollowUpMode>({
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<K extends SyncedPrefKey> =
ReturnType<(typeof SYNCED_PREFS)[K]["extract"]> extends (infer T) | undefined ? T : never;
type ServerUiPrefs = { [K in SyncedPrefKey]?: SyncedPrefValue<K> | 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<string, unknown>)[key] = value;
}
}
return result;
}
@@ -69,39 +119,33 @@ function serverPrefsLocalPatch(
settings: UiSettings,
): Partial<UiSettings> | null {
const patch: Partial<UiSettings> = {};
// 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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[key] = null;
}
continue;
}
(prefs as Record<string, unknown>)[key] = nextValue;
}
return Object.keys(prefs).length > 0 ? prefs : null;
}
@@ -225,6 +261,14 @@ export function applyServerUiPrefs(
(changed as Record<string, unknown>)[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<keyof ServerUiPrefs>) {
if (!(prefKey in prefs) && SYNCED_PREFS[prefKey]?.clearable) {
(changed as Record<string, unknown>)[prefKey] = null;
}
}
storeLastSeenKey(scope, key);
const patch = serverPrefsLocalPatch(changed, loadSettings());
if (!patch) {
@@ -37,7 +37,7 @@ function requestRaw(request: MockGatewayRequest): Record<string, unknown> {
return JSON.parse(String((params as Record<string, unknown>).raw)) as Record<string, unknown>;
}
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(
+5 -1
View File
@@ -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.",
+3 -1
View File
@@ -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" },
+2 -1
View File
@@ -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) {
+33
View File
@@ -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<HTMLButtonElement>("[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",
@@ -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<string, ChatModelProviderOption[]>();
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) {
+8 -2
View File
@@ -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<K extends LocalUiSetting>(key: K, value: UiSettings[K]) {
private setSetting<K extends ConfigPageSetting>(key: K, value: UiSettings[K]) {
this.applySettings({ ...this.settings, [key]: value });
}
+5 -1
View File
@@ -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),
}),
]);
+3 -3
View File
@@ -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);
+9 -6
View File
@@ -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),
}),
@@ -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({
+25
View File
@@ -187,10 +187,35 @@ const COMMUNICATION_SETTINGS_BLOCKS = {
},
} as const satisfies Record<string, StaticSettingsBlockDescriptor>;
// 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<string, StaticSettingsBlockDescriptor>;
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<string>(COMMUNICATION_SECTION_KEYS);
+6 -2
View File
@@ -1110,7 +1110,9 @@ function renderChatPreferencesSection(props: ConfigProps) {
<div class="settings-section__header">
<h2 class="settings-section__heading">${t("configView.chatPrefs.title")}</h2>
</div>
<p class="settings-section__desc">${t("configView.chatPrefs.hint")}</p>
<p class="settings-section__desc">
${t("configView.chatPrefs.hint")} ${t("configView.syncedHint")}
</p>
<div class="settings-group">
${renderSettingsSelectRow({
title: t("chat.sendShortcut"),
@@ -1287,7 +1289,9 @@ function renderAppearanceSection(props: ConfigProps) {
<div class="settings-section__header">
<h2 class="settings-section__heading">${t("configView.appearance.theme")}</h2>
</div>
<p class="settings-section__desc">${t("configView.appearance.chooseTheme")}</p>
<p class="settings-section__desc">
${t("configView.appearance.chooseTheme")} ${t("configView.syncedHint")}
</p>
<div class="settings-group">
<div class="settings-row settings-row--stacked">
<div class="settings-theme-grid">
+1 -1
View File
@@ -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 {
-25
View File
@@ -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 {
+1 -2
View File
@@ -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;
+3 -6
View File
@@ -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;
}