mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
improve(ui): remove the Context Profile presets card from Quick Settings (#102748)
* fix(control-ui): remove the Context Profile presets card from Quick Settings * fix(control-ui): regenerate i18n raw-copy baseline after Gateway Host card rebase
This commit is contained in:
committed by
GitHub
parent
1fdb0e79d3
commit
8d6c54f513
Generated
+29
-92
@@ -2472,62 +2472,6 @@
|
||||
"path": "ui/src/pages/config/mcp.ts",
|
||||
"text": "tool filter"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "description",
|
||||
"path": "ui/src/pages/config/presets.ts",
|
||||
"text": "Balanced default for daily use."
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "description",
|
||||
"path": "ui/src/pages/config/presets.ts",
|
||||
"text": "Highest context budget for repo work."
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "description",
|
||||
"path": "ui/src/pages/config/presets.ts",
|
||||
"text": "Lean follow-ups for shared bots."
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "description",
|
||||
"path": "ui/src/pages/config/presets.ts",
|
||||
"text": "Smallest context budget and lowest cost."
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "label",
|
||||
"path": "ui/src/pages/config/presets.ts",
|
||||
"text": "Code Agent"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "label",
|
||||
"path": "ui/src/pages/config/presets.ts",
|
||||
"text": "Minimal"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "label",
|
||||
"path": "ui/src/pages/config/presets.ts",
|
||||
"text": "Personal Assistant"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "label",
|
||||
"path": "ui/src/pages/config/presets.ts",
|
||||
"text": "Team Bot"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-attribute",
|
||||
@@ -2570,13 +2514,6 @@
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Browse →"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Choose how much workspace context OpenClaw injects into each run. Profiles only change bootstrap size and follow-up reinjection — never your model, tools, channels, or theme."
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
@@ -2612,20 +2549,6 @@
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Connect →"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Current"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Custom"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
@@ -2694,21 +2617,7 @@
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Pending"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Saved"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Selected"
|
||||
"text": "Save stores your changes. Apply Now also reloads the current session."
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
@@ -2745,6 +2654,13 @@
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Thinking"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Unsaved changes"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
@@ -2759,6 +2675,13 @@
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Claw"
|
||||
},
|
||||
{
|
||||
"count": 3,
|
||||
"kind": "object-property",
|
||||
"name": "label",
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "CPU"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
@@ -2766,6 +2689,13 @@
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Dash"
|
||||
},
|
||||
{
|
||||
"count": 2,
|
||||
"kind": "object-property",
|
||||
"name": "label",
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Disk"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
@@ -2787,6 +2717,13 @@
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "M"
|
||||
},
|
||||
{
|
||||
"count": 2,
|
||||
"kind": "object-property",
|
||||
"name": "label",
|
||||
"path": "ui/src/pages/config/quick.ts",
|
||||
"text": "Memory"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
resetConfigPendingChanges,
|
||||
saveConfig,
|
||||
stageDefaultAgentConfigEntry,
|
||||
stageConfigPreset,
|
||||
updateMcpServerEnabled,
|
||||
updateConfigFormValue,
|
||||
updateConfigRawValue,
|
||||
@@ -514,97 +513,6 @@ describe("updateMcpServerEnabled", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("stageConfigPreset", () => {
|
||||
it("ignores preset staging before a config snapshot is ready", () => {
|
||||
const state = createState();
|
||||
|
||||
stageConfigPreset(state, {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.configForm).toBeNull();
|
||||
expect(state.configRaw).toBe("");
|
||||
expect(state.configFormDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("stages preset changes without dropping unrelated config", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 12_000,
|
||||
bootstrapTotalMaxChars: 60_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
gateway: { mode: "local" },
|
||||
},
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: '{\n "agents": {\n "defaults": {\n "bootstrapMaxChars": 12000,\n "bootstrapTotalMaxChars": 60000,\n "contextInjection": "always"\n }\n },\n "gateway": {\n "mode": "local"\n }\n}\n',
|
||||
});
|
||||
|
||||
stageConfigPreset(state, {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.configFormDirty).toBe(true);
|
||||
expect(state.configForm).toEqual({
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
gateway: { mode: "local" },
|
||||
});
|
||||
});
|
||||
|
||||
it("stays clean when the staged preset already matches the saved config", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 20_000,
|
||||
bootstrapTotalMaxChars: 150_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
},
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: '{\n "agents": {\n "defaults": {\n "bootstrapMaxChars": 20000,\n "bootstrapTotalMaxChars": 150000,\n "contextInjection": "always"\n }\n }\n}\n',
|
||||
});
|
||||
|
||||
stageConfigPreset(state, {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 20_000,
|
||||
bootstrapTotalMaxChars: 150_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.configFormDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetConfigPendingChanges", () => {
|
||||
it("restores the original form and raw config snapshot", () => {
|
||||
const state = createState();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Control UI runtime config capability and shared config-domain mutations.
|
||||
import { applyMergePatch } from "../../../../src/config/merge-patch.ts";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../../api/types.ts";
|
||||
import { schemaType, type JsonSchema } from "../../components/config-form.shared.ts";
|
||||
@@ -63,7 +62,6 @@ export type RuntimeConfigCapability = {
|
||||
removeFormValue: (path: Array<string | number>) => void;
|
||||
setRaw: (value: string) => void;
|
||||
resetDraft: () => void;
|
||||
stagePreset: (patch: Record<string, unknown>) => void;
|
||||
save: () => Promise<boolean>;
|
||||
apply: () => Promise<boolean>;
|
||||
openFile: () => Promise<void>;
|
||||
@@ -634,20 +632,6 @@ export function updateConfigRawValue(state: ConfigState, value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function stageConfigPreset(state: ConfigState, patch: Record<string, unknown>) {
|
||||
const snapshotConfig = resolveEditableSnapshotConfig(state.configSnapshot);
|
||||
const baseSource = state.configForm ?? snapshotConfig;
|
||||
if (!baseSource || (!state.configForm && !state.configSnapshot?.hash)) {
|
||||
return;
|
||||
}
|
||||
const base = cloneConfigObject(baseSource);
|
||||
const merged = applyMergePatch(base, patch);
|
||||
if (!merged || typeof merged !== "object" || Array.isArray(merged)) {
|
||||
return;
|
||||
}
|
||||
syncConfigDraft(state, cloneConfigObject(merged as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export function resetConfigPendingChanges(state: ConfigState) {
|
||||
const editableConfig = resolveEditableSnapshotConfig(state.configSnapshot);
|
||||
state.configForm = cloneConfigObject(state.configFormOriginal ?? editableConfig ?? {});
|
||||
@@ -872,7 +856,6 @@ export function createRuntimeConfigCapability(
|
||||
removeFormValue: (path) => mutate(() => removeConfigFormValue(state, path)),
|
||||
setRaw: (value) => mutate(() => updateConfigRawValue(state, value)),
|
||||
resetDraft: () => mutate(() => resetConfigPendingChanges(state)),
|
||||
stagePreset: (patch) => mutate(() => stageConfigPreset(state, patch)),
|
||||
save: () => run(() => saveConfig(state)),
|
||||
apply: () => run(() => applyConfig(state)),
|
||||
openFile: () => run(() => openConfigFile(state)),
|
||||
|
||||
@@ -24,7 +24,6 @@ import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { isMissingOperatorReadScopeError } from "../../lib/gateway-errors.ts";
|
||||
import { renderMcp } from "./mcp.ts";
|
||||
import { getPresetById } from "./presets.ts";
|
||||
import {
|
||||
renderQuickSettings,
|
||||
type QuickSettingsChannel,
|
||||
@@ -799,21 +798,10 @@ export class ConfigPage extends LitElement {
|
||||
assistantName: appConfig.assistantIdentity.name,
|
||||
version:
|
||||
appConfig.serverVersion ?? this.context.gateway.snapshot.hello?.server?.version ?? "",
|
||||
configObject,
|
||||
savedConfigObject:
|
||||
asConfigRecord(
|
||||
runtimeConfig.state.configFormOriginal ?? runtimeConfig.state.configSnapshot?.config,
|
||||
) ?? {},
|
||||
configDirty: runtimeConfig.state.configFormDirty,
|
||||
configSaving: runtimeConfig.state.configSaving,
|
||||
configApplying: runtimeConfig.state.configApplying,
|
||||
configReady: Boolean(runtimeConfig.state.configSnapshot?.hash),
|
||||
onSelectPreset: (id) => {
|
||||
const preset = getPresetById(id);
|
||||
if (preset) {
|
||||
runtimeConfig.stagePreset(preset.patch);
|
||||
}
|
||||
},
|
||||
onResetConfig: () => runtimeConfig.resetDraft(),
|
||||
onSaveConfig: () => void runtimeConfig.save(),
|
||||
onApplyConfig: () => void runtimeConfig.apply(),
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// Control UI tests cover config presets behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OpenClawSchema } from "../../../../src/config/zod-schema.js";
|
||||
import { CONFIG_PRESETS, detectActivePreset } from "./presets.ts";
|
||||
|
||||
describe("detectActivePreset", () => {
|
||||
it("keeps every preset patch valid for the runtime config schema", () => {
|
||||
expect(
|
||||
CONFIG_PRESETS.map((preset) => ({
|
||||
id: preset.id,
|
||||
defaults: preset.patch.agents.defaults,
|
||||
})),
|
||||
).toStrictEqual([
|
||||
{
|
||||
id: "personal",
|
||||
defaults: {
|
||||
bootstrapMaxChars: 20_000,
|
||||
bootstrapTotalMaxChars: 150_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "codeAgent",
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "teamBot",
|
||||
defaults: {
|
||||
bootstrapMaxChars: 10_000,
|
||||
bootstrapTotalMaxChars: 80_000,
|
||||
contextInjection: "continuation-skip",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "minimal",
|
||||
defaults: {
|
||||
bootstrapMaxChars: 5_000,
|
||||
bootstrapTotalMaxChars: 30_000,
|
||||
contextInjection: "continuation-skip",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
for (const preset of CONFIG_PRESETS) {
|
||||
expect(OpenClawSchema.safeParse(preset.patch).success, preset.id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when bootstrap defaults are unset", () => {
|
||||
expect(detectActivePreset({})).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the matching preset when all preset fields match", () => {
|
||||
expect(
|
||||
detectActivePreset({
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("codeAgent");
|
||||
});
|
||||
|
||||
it("does not match a preset when context injection differs", () => {
|
||||
expect(
|
||||
detectActivePreset({
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "continuation-skip",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Config presets — opinionated configuration bundles that set multiple
|
||||
* settings at once. Applied via config.patch.
|
||||
*/
|
||||
|
||||
export type ConfigPresetId = "personal" | "codeAgent" | "teamBot" | "minimal";
|
||||
|
||||
type ConfigPresetPatch = {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: number;
|
||||
bootstrapTotalMaxChars: number;
|
||||
contextInjection: "always" | "continuation-skip";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type ConfigPreset = {
|
||||
id: ConfigPresetId;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
patch: ConfigPresetPatch;
|
||||
};
|
||||
|
||||
export const CONFIG_PRESETS: ConfigPreset[] = [
|
||||
{
|
||||
id: "personal",
|
||||
label: "Personal Assistant",
|
||||
description: "Balanced default for daily use.",
|
||||
icon: "✨",
|
||||
patch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 20_000,
|
||||
bootstrapTotalMaxChars: 150_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "codeAgent",
|
||||
label: "Code Agent",
|
||||
description: "Highest context budget for repo work.",
|
||||
icon: "🛠️",
|
||||
patch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "teamBot",
|
||||
label: "Team Bot",
|
||||
description: "Lean follow-ups for shared bots.",
|
||||
icon: "👥",
|
||||
patch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 10_000,
|
||||
bootstrapTotalMaxChars: 80_000,
|
||||
contextInjection: "continuation-skip",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "minimal",
|
||||
label: "Minimal",
|
||||
description: "Smallest context budget and lowest cost.",
|
||||
icon: "⚡",
|
||||
patch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 5_000,
|
||||
bootstrapTotalMaxChars: 30_000,
|
||||
contextInjection: "continuation-skip",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getPresetById(id: ConfigPresetId): ConfigPreset | undefined {
|
||||
return CONFIG_PRESETS.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which preset (if any) matches the current config values.
|
||||
*/
|
||||
export function detectActivePreset(config: Record<string, unknown>): ConfigPresetId | null {
|
||||
const agents = config.agents as Record<string, unknown> | undefined;
|
||||
const defaults = agents?.defaults as Record<string, unknown> | undefined;
|
||||
if (!defaults) {
|
||||
return null;
|
||||
}
|
||||
const maxChars = defaults.bootstrapMaxChars;
|
||||
const totalMax = defaults.bootstrapTotalMaxChars;
|
||||
const contextInjection = defaults.contextInjection;
|
||||
for (const preset of CONFIG_PRESETS) {
|
||||
const presetDefaults = (preset.patch.agents as Record<string, unknown>)?.defaults as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!presetDefaults) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
maxChars === presetDefaults.bootstrapMaxChars &&
|
||||
totalMax === presetDefaults.bootstrapTotalMaxChars &&
|
||||
contextInjection === presetDefaults.contextInjection
|
||||
) {
|
||||
return preset.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -82,8 +82,6 @@ function createProps(overrides: Partial<QuickSettingsProps> = {}): QuickSettings
|
||||
setTextScale: vi.fn(),
|
||||
userAvatar: null,
|
||||
onUserAvatarChange: vi.fn(),
|
||||
configObject: {},
|
||||
onSelectPreset: vi.fn(),
|
||||
connected: true,
|
||||
gatewayUrl: "ws://localhost:18789",
|
||||
assistantName: "OpenClaw",
|
||||
@@ -139,7 +137,7 @@ describe("renderQuickSettings", () => {
|
||||
"qs-card--personal",
|
||||
"qs-card--automations",
|
||||
]);
|
||||
expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders Gateway host identity and resources", () => {
|
||||
@@ -276,15 +274,57 @@ describe("renderQuickSettings", () => {
|
||||
expect(systemCard?.querySelector(".qs-system__address")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the current bootstrap default when config omits the explicit limit", () => {
|
||||
it("hides the pending changes bar when the config is clean", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(renderQuickSettings(createProps({ configObject: {} })), container);
|
||||
render(renderQuickSettings(createProps()), container);
|
||||
|
||||
const summary = container.querySelector(".qs-profiles__summary-values");
|
||||
expect(summary?.textContent?.replace(/\s+/g, " ").trim()).toBe(
|
||||
"20,000 chars per file · 60,000 chars total · Every turn",
|
||||
expect(container.querySelector(".qs-pending")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders pending config actions and calls their handlers", () => {
|
||||
const onResetConfig = vi.fn();
|
||||
const onSaveConfig = vi.fn();
|
||||
const onApplyConfig = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderQuickSettings(
|
||||
createProps({
|
||||
configDirty: true,
|
||||
configReady: true,
|
||||
connected: true,
|
||||
onResetConfig,
|
||||
onSaveConfig,
|
||||
onApplyConfig,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".qs-pending")).not.toBeNull();
|
||||
const discardButton = expectButtonByText(container, "Discard");
|
||||
const saveButton = expectButtonByText(container, "Save");
|
||||
const applyButton = expectButtonByText(container, "Apply Now");
|
||||
expect(saveButton.disabled).toBe(false);
|
||||
|
||||
discardButton.click();
|
||||
saveButton.click();
|
||||
applyButton.click();
|
||||
|
||||
expect(onResetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(onSaveConfig).toHaveBeenCalledTimes(1);
|
||||
expect(onApplyConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables commit actions until the config is ready", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(renderQuickSettings(createProps({ configDirty: true, configReady: false })), container);
|
||||
|
||||
expect(expectButtonByText(container, "Save").disabled).toBe(true);
|
||||
expect(expectButtonByText(container, "Apply Now").disabled).toBe(true);
|
||||
expect(expectButtonByText(container, "Discard").disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps auto as a first-class quick settings fast mode", () => {
|
||||
|
||||
+27
-172
@@ -24,12 +24,6 @@ import { formatBytes } from "../../lib/agents/display.ts";
|
||||
import { resolveAssistantTextAvatar, resolveChatAvatarRenderUrl } from "../../lib/avatar.ts";
|
||||
import { formatDurationHuman } from "../../lib/format.ts";
|
||||
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
|
||||
import {
|
||||
CONFIG_PRESETS,
|
||||
detectActivePreset,
|
||||
getPresetById,
|
||||
type ConfigPresetId,
|
||||
} from "./presets.ts";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -98,14 +92,11 @@ export type QuickSettingsProps = {
|
||||
userAvatar?: string | null;
|
||||
onUserAvatarChange?: (next: string | null) => void;
|
||||
|
||||
// Presets
|
||||
configObject?: Record<string, unknown>;
|
||||
savedConfigObject?: Record<string, unknown>;
|
||||
// Pending config changes
|
||||
configDirty?: boolean;
|
||||
configSaving?: boolean;
|
||||
configApplying?: boolean;
|
||||
configReady?: boolean;
|
||||
onSelectPreset?: (presetId: ConfigPresetId) => void;
|
||||
onResetConfig?: () => void;
|
||||
onSaveConfig?: () => void;
|
||||
onApplyConfig?: () => void;
|
||||
@@ -315,51 +306,6 @@ function handleAssistantAvatarFileSelect(e: Event, props: QuickSettingsProps) {
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
type ProfileSettings = {
|
||||
bootstrapMaxChars: number;
|
||||
bootstrapTotalMaxChars: number;
|
||||
contextInjection: "always" | "continuation-skip";
|
||||
};
|
||||
|
||||
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
|
||||
bootstrapMaxChars: 20_000,
|
||||
bootstrapTotalMaxChars: 60_000,
|
||||
contextInjection: "always",
|
||||
};
|
||||
|
||||
function resolveProfileSettings(config?: Record<string, unknown>): ProfileSettings {
|
||||
const agents = config?.agents as Record<string, unknown> | undefined;
|
||||
const defaults = agents?.defaults as Record<string, unknown> | undefined;
|
||||
const bootstrapMaxChars =
|
||||
typeof defaults?.bootstrapMaxChars === "number" && Number.isFinite(defaults.bootstrapMaxChars)
|
||||
? Math.floor(defaults.bootstrapMaxChars)
|
||||
: DEFAULT_PROFILE_SETTINGS.bootstrapMaxChars;
|
||||
const bootstrapTotalMaxChars =
|
||||
typeof defaults?.bootstrapTotalMaxChars === "number" &&
|
||||
Number.isFinite(defaults.bootstrapTotalMaxChars)
|
||||
? Math.floor(defaults.bootstrapTotalMaxChars)
|
||||
: DEFAULT_PROFILE_SETTINGS.bootstrapTotalMaxChars;
|
||||
const contextInjection =
|
||||
defaults?.contextInjection === "continuation-skip" ? "continuation-skip" : "always";
|
||||
return { bootstrapMaxChars, bootstrapTotalMaxChars, contextInjection };
|
||||
}
|
||||
|
||||
function profileSettingsEqual(a: ProfileSettings, b: ProfileSettings): boolean {
|
||||
return (
|
||||
a.bootstrapMaxChars === b.bootstrapMaxChars &&
|
||||
a.bootstrapTotalMaxChars === b.bootstrapTotalMaxChars &&
|
||||
a.contextInjection === b.contextInjection
|
||||
);
|
||||
}
|
||||
|
||||
function formatCharBudget(value: number): string {
|
||||
return `${value.toLocaleString()} chars`;
|
||||
}
|
||||
|
||||
function formatContextInjectionLabel(mode: ProfileSettings["contextInjection"]): string {
|
||||
return mode === "always" ? "Every turn" : "Skip safe follow-ups";
|
||||
}
|
||||
|
||||
// ── Card renderers ──
|
||||
|
||||
function renderCardHeader(icon: TemplateResult, title: string, action?: TemplateResult) {
|
||||
@@ -984,129 +930,38 @@ function renderPersonalCard(props: QuickSettingsProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPresetsCard(props: QuickSettingsProps) {
|
||||
const draftConfig = props.configObject ?? props.savedConfigObject ?? {};
|
||||
const savedConfig = props.savedConfigObject ?? {};
|
||||
const selectedPresetId = detectActivePreset(draftConfig);
|
||||
const savedPresetId = detectActivePreset(savedConfig);
|
||||
const selectedPreset = selectedPresetId ? getPresetById(selectedPresetId) : undefined;
|
||||
const savedPreset = savedPresetId ? getPresetById(savedPresetId) : undefined;
|
||||
const draftSettings = resolveProfileSettings(draftConfig);
|
||||
const savedSettings = resolveProfileSettings(savedConfig);
|
||||
const hasPendingProfileChange = !profileSettingsEqual(draftSettings, savedSettings);
|
||||
const hasPendingConfigChange = props.configDirty === true;
|
||||
function renderPendingChangesBar(props: QuickSettingsProps) {
|
||||
if (props.configDirty !== true) {
|
||||
return nothing;
|
||||
}
|
||||
const canCommit =
|
||||
props.connected &&
|
||||
props.configReady === true &&
|
||||
props.configSaving !== true &&
|
||||
props.configApplying !== true;
|
||||
const commitHint = hasPendingProfileChange
|
||||
? "Save writes this profile as the default. Apply Now also reloads the current session."
|
||||
: "Staged config edits are pending. Saving commits all staged changes.";
|
||||
|
||||
return html`
|
||||
<div class="qs-card qs-card--span-all">
|
||||
${renderCardHeader(
|
||||
icons.zap,
|
||||
"Context Profile",
|
||||
hasPendingProfileChange
|
||||
? html`<span class="qs-badge qs-badge--warn">Pending</span>`
|
||||
: savedPreset
|
||||
? html`<span class="qs-badge qs-badge--ok">Saved</span>`
|
||||
: html`<span class="qs-badge">Custom</span>`,
|
||||
)}
|
||||
<div class="qs-card__body qs-profiles">
|
||||
<p class="qs-profiles__intro">
|
||||
Choose how much workspace context OpenClaw injects into each run. Profiles only change
|
||||
bootstrap size and follow-up reinjection — never your model, tools, channels, or theme.
|
||||
</p>
|
||||
<div class="qs-presets-grid">
|
||||
${CONFIG_PRESETS.map((preset) => {
|
||||
const presetDefaults = ((preset.patch.agents as Record<string, unknown> | undefined)
|
||||
?.defaults ?? {}) as Record<string, unknown>;
|
||||
const presetContext =
|
||||
presetDefaults.contextInjection === "continuation-skip"
|
||||
? "continuation-skip"
|
||||
: "always";
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class="qs-preset ${preset.id === selectedPresetId ? "qs-preset--active" : ""}"
|
||||
aria-pressed=${preset.id === selectedPresetId}
|
||||
@click=${() => props.onSelectPreset?.(preset.id)}
|
||||
>
|
||||
<div class="qs-preset__head">
|
||||
<div class="qs-preset__identity">
|
||||
<span class="qs-preset__icon">${preset.icon}</span>
|
||||
<div class="qs-preset__identity-copy">
|
||||
<span class="qs-preset__label">${preset.label}</span>
|
||||
<span class="qs-preset__desc muted">${preset.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-preset__badges">
|
||||
${preset.id === savedPresetId
|
||||
? html`<span class="qs-badge qs-badge--ok">Current</span>`
|
||||
: nothing}
|
||||
${hasPendingProfileChange && preset.id === selectedPresetId
|
||||
? html`<span class="qs-badge qs-badge--warn">Selected</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-preset__meta">
|
||||
<span
|
||||
>${formatCharBudget(Number(presetDefaults.bootstrapMaxChars ?? 0))} per
|
||||
file</span
|
||||
>
|
||||
<span
|
||||
>${formatCharBudget(Number(presetDefaults.bootstrapTotalMaxChars ?? 0))}
|
||||
total</span
|
||||
>
|
||||
<span>${formatContextInjectionLabel(presetContext)}</span>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
<div class="qs-profiles__footer" aria-live="polite">
|
||||
<div class="qs-profiles__summary">
|
||||
<span class="qs-profiles__summary-label"
|
||||
>${selectedPreset?.label ?? "Custom values"}</span
|
||||
>
|
||||
<span class="qs-profiles__summary-values"
|
||||
>${formatCharBudget(draftSettings.bootstrapMaxChars)} per file ·
|
||||
${formatCharBudget(draftSettings.bootstrapTotalMaxChars)} total ·
|
||||
${formatContextInjectionLabel(draftSettings.contextInjection)}</span
|
||||
>
|
||||
</div>
|
||||
${hasPendingConfigChange
|
||||
? html`
|
||||
<div class="qs-profiles__actions">
|
||||
<span class="qs-profiles__hint muted">${commitHint}</span>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${props.configSaving === true || props.configApplying === true}
|
||||
@click=${props.onResetConfig}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm primary"
|
||||
?disabled=${!canCommit}
|
||||
@click=${props.onSaveConfig}
|
||||
>
|
||||
${props.configSaving === true
|
||||
? "Saving…"
|
||||
: hasPendingProfileChange
|
||||
? "Save Profile"
|
||||
: "Save Changes"}
|
||||
</button>
|
||||
<button class="btn btn--sm" ?disabled=${!canCommit} @click=${props.onApplyConfig}>
|
||||
${props.configApplying === true ? "Applying…" : "Apply Now"}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="qs-card qs-card--span-all qs-pending" aria-live="polite">
|
||||
<div class="qs-pending__copy">
|
||||
<span class="qs-pending__label">Unsaved changes</span>
|
||||
<span class="qs-pending__hint muted"
|
||||
>Save stores your changes. Apply Now also reloads the current session.</span
|
||||
>
|
||||
</div>
|
||||
<div class="qs-pending__actions">
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${props.configSaving === true || props.configApplying === true}
|
||||
@click=${props.onResetConfig}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button class="btn btn--sm primary" ?disabled=${!canCommit} @click=${props.onSaveConfig}>
|
||||
${props.configSaving === true ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn btn--sm" ?disabled=${!canCommit} @click=${props.onApplyConfig}>
|
||||
${props.configApplying === true ? "Applying…" : "Apply Now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1133,7 +988,7 @@ export function renderQuickSettings(props: QuickSettingsProps) {
|
||||
<div class="qs-grid">
|
||||
${renderModelCard(props)} ${renderChannelsCard(props)} ${renderSecurityCard(props)}
|
||||
${renderSystemCard(props)} ${renderAppearanceCard(props)} ${renderPersonalCard(props)}
|
||||
${renderAutomationsCard(props)} ${renderPresetsCard(props)}
|
||||
${renderAutomationsCard(props)} ${renderPendingChangesBar(props)}
|
||||
</div>
|
||||
|
||||
${renderConnectionFooter(props)}
|
||||
|
||||
+16
-150
@@ -955,173 +955,39 @@ openclaw-logs-page {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* ── Presets Grid ── */
|
||||
/* ── Pending Changes ── */
|
||||
|
||||
.qs-profiles {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
|
||||
.qs-profiles__intro {
|
||||
margin: 0;
|
||||
max-width: 78ch;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.qs-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(230px, 100%), 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.qs-profiles__footer {
|
||||
.qs-pending {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||
gap: 12px 16px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.qs-profiles__summary {
|
||||
.qs-pending__copy {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 10px;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.qs-profiles__summary-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
.qs-pending__label {
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.qs-profiles__summary-values {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.qs-profiles__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.qs-profiles__hint {
|
||||
.qs-pending__hint {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
max-width: 46ch;
|
||||
}
|
||||
|
||||
.qs-preset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--duration-normal) var(--ease-out),
|
||||
background var(--duration-normal) var(--ease-out),
|
||||
box-shadow var(--duration-normal) var(--ease-out),
|
||||
transform var(--duration-normal) var(--ease-out);
|
||||
text-align: left;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.qs-preset:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border) 60%);
|
||||
background: color-mix(in srgb, var(--accent-subtle) 40%, var(--card) 60%);
|
||||
}
|
||||
|
||||
.qs-preset:focus-visible {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border) 45%);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.qs-preset--active {
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--border) 50%);
|
||||
background: color-mix(in srgb, var(--accent-subtle) 60%, var(--card) 40%);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.qs-preset--active:hover {
|
||||
background: color-mix(in srgb, var(--accent-subtle) 80%, var(--card) 20%);
|
||||
}
|
||||
|
||||
.qs-preset__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qs-preset__identity {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.qs-preset__identity-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.qs-preset__badges {
|
||||
.qs-pending__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.qs-preset__icon {
|
||||
font-size: 1.45rem;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.qs-preset__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.qs-preset__desc {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.qs-preset__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.qs-preset__meta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 84%, transparent);
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@@ -1155,13 +1021,13 @@ openclaw-logs-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.qs-profiles__footer,
|
||||
.qs-profiles__actions {
|
||||
.qs-pending,
|
||||
.qs-pending__actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.qs-profiles__actions .btn {
|
||||
.qs-pending__actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user