mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
0ac69b9fe8
commit
3b2797f09b
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user