fix(ui): show a simple channel settings view with a flip to advanced (#114741)

* test(ui): make channel settings renderable in the mock dev server

* fix(ui): hide advanced channel settings behind the shared advanced tier

* fix(ui): widen the tier renderer callback to lit's nothing

* fix(ui): keep the advanced collapse control on advanced-only channel forms

* docs: note the shared advanced tier on channel settings
This commit is contained in:
Peter Steinberger
2026-07-27 16:39:23 -04:00
committed by GitHub
parent 3a39dbcdfd
commit fbb3525368
11 changed files with 353 additions and 89 deletions
+3 -1
View File
@@ -66,7 +66,9 @@ field map and defaults.
Settings show common fields first. Each section keeps its advanced fields
in a collapsed **Advanced (N)** group; use **Show advanced** to expand all
groups. Settings search always includes both tiers and opens the matching
advanced group when needed.
advanced group when needed. Per-channel settings under **Settings ->
Channels** use the same split and share the **Show advanced** preference,
with **Hide advanced** on the divider to collapse them again.
</Tab>
<Tab title="Direct edit">
Edit `~/.openclaw/openclaw.json` directly. The Gateway watches the file and applies changes automatically (see [hot reload](#config-hot-reload)).
+62 -1
View File
@@ -6,6 +6,7 @@ import qrcode from "qrcode";
import { createServer, type Plugin, type ViteDevServer } from "vite";
import type { UserProfile } from "../packages/gateway-protocol/src/index.js";
import { expectDefined } from "../packages/normalization-core/src/expect.js";
import { applyConfigTierHints, applyResolvedConfigTierHints } from "../src/config/schema.tiers.js";
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../src/gateway/control-ui-contract.js";
import {
createControlUiMockBootstrapConfig,
@@ -641,6 +642,15 @@ function buildConfigMocks(options: { swarmEnabled?: boolean } = {}) {
agents: { defaults: { thinkingDefault: "medium" } },
models: { mode: "merge" },
...(options.swarmEnabled ? { tools: { swarm: true } } : {}),
channels: {
whatsapp: {
enabled: true,
allowFrom: ["+15551234567"],
dmPolicy: "pairing",
groupPolicy: "allowlist",
selfChatMode: "off",
},
},
mcp: {
servers: {
context7: { url: "https://mcp.context7.com/mcp", transport: "streamable-http" },
@@ -727,6 +737,52 @@ function buildConfigMocks(options: { swarmEnabled?: boolean } = {}) {
},
},
},
// Channel settings are the one schema surface the channels page renders,
// so the fixture keeps both tiers represented.
channels: {
type: "object",
title: "Channels",
properties: {
whatsapp: {
type: "object",
title: "WhatsApp",
properties: {
enabled: { type: "boolean", title: "Enabled" },
allowFrom: { type: "array", title: "Allow from", items: { type: "string" } },
dmPolicy: { type: "string", title: "DM policy", enum: ["pairing", "open", "off"] },
groupPolicy: {
type: "string",
title: "Group policy",
enum: ["allowlist", "open", "off"],
},
selfChatMode: { type: "string", title: "Self chat mode", enum: ["off", "notes"] },
configWrites: { type: "boolean", title: "Config writes" },
streaming: {
type: "object",
title: "Streaming",
properties: {
progress: {
type: "object",
properties: {
maxLines: { type: "integer", title: "Progress max lines" },
toolProgress: { type: "boolean", title: "Progress tool lines" },
},
},
},
},
retry: {
type: "object",
title: "Retry",
properties: {
attempts: { type: "integer", title: "Attempts" },
minDelayMs: { type: "integer", title: "Min delay (ms)" },
maxDelayMs: { type: "integer", title: "Max delay (ms)" },
},
},
},
},
},
},
},
};
return {
@@ -742,7 +798,12 @@ function buildConfigMocks(options: { swarmEnabled?: boolean } = {}) {
},
schema: {
schema,
uiHints: {},
// Resolve tiers the way the gateway does so the mock reproduces the
// real common/advanced split instead of a flat "everything advanced".
uiHints: applyResolvedConfigTierHints(
schema,
applyConfigTierHints({}, { includePluginOwnedChannels: true }),
),
version: "mock-config-schema",
generatedAt: new Date(0).toISOString(),
},
+75 -38
View File
@@ -35,6 +35,73 @@ type ConfigFormProps = {
onPatch: (path: Array<string | number>, value: unknown) => void;
};
function renderAdvancedDivider(onHideAdvanced: (() => void) | undefined) {
return html`<div class="config-advanced-divider">
<span>${t("configForm.advancedDivider")}</span>
${onHideAdvanced
? html`<button
type="button"
class="config-advanced-divider__toggle"
@click=${() => onHideAdvanced()}
>
${t("common.hideAdvanced")}
</button>`
: nothing}
</div>`;
}
/** Common/advanced split body shared by the config page and channel forms so
* every schema surface hides advanced settings behind the same ghost row. */
export function renderConfigTierGroups(params: {
schema: JsonSchema;
path: Array<string | number>;
hints: ConfigUiHints;
revealAdvanced: boolean;
onShowAdvanced: () => void;
/** Surfaces without a toolbar toggle pass this so the divider can collapse
* the tier again; the config page omits it and uses its toolbar button. */
onHideAdvanced?: () => void;
renderTier: (node: JsonSchema) => TemplateResult | typeof nothing;
}) {
const split = splitConfigSchemaByTier({
schema: params.schema,
path: params.path.map(String),
hints: params.hints,
});
// An advanced-only schema needs no separator, but a surface whose only
// collapse control lives on the divider would otherwise strand the tier open.
const showDivider = Boolean(split.common) || Boolean(params.onHideAdvanced);
return html`
${split.common
? html`<div class="settings-group">${params.renderTier(split.common)}</div>`
: nothing}
${split.advanced && split.advancedLeafCount > 0
? params.revealAdvanced
? html`
${showDivider ? renderAdvancedDivider(params.onHideAdvanced) : nothing}
<div class="settings-group">${params.renderTier(split.advanced)}</div>
`
: html`
<button
type="button"
class="config-advanced-ghost"
@click=${() => params.onShowAdvanced()}
>
<span class="config-advanced-ghost__count">
${t(
split.advancedLeafCount === 1
? "configForm.advancedHidden"
: "configForm.advancedHiddenPlural",
{ count: String(split.advancedLeafCount) },
)}
</span>
<span class="config-advanced-ghost__action">${t("configForm.showAdvanced")}</span>
</button>
`
: nothing}
`;
}
function matchesSearch(params: {
key: string;
schema: JsonSchema;
@@ -137,11 +204,6 @@ export function renderConfigForm(props: ConfigFormProps) {
nodeValue: unknown;
path: Array<string | number>;
}) => {
const split = splitConfigSchemaByTier({
schema: params.node,
path: params.path.map(String),
hints: props.uiHints,
});
const revealAdvanced =
props.showAdvanced === true ||
props.forceAdvancedSection === params.path[0] ||
@@ -173,39 +235,14 @@ export function renderConfigForm(props: ConfigFormProps) {
${params.description
? html`<p class="settings-section__desc">${params.description}</p>`
: nothing}
${split.common
? html`<div class="settings-group">${renderTier(split.common)}</div>`
: nothing}
${split.advanced && split.advancedLeafCount > 0
? revealAdvanced
? html`
${split.common
? html`<div class="config-advanced-divider">
${t("configForm.advancedDivider")}
</div>`
: nothing}
<div class="settings-group">${renderTier(split.advanced)}</div>
`
: html`
<button
type="button"
class="config-advanced-ghost"
@click=${() => props.onShowAdvanced()}
>
<span class="config-advanced-ghost__count">
${t(
split.advancedLeafCount === 1
? "configForm.advancedHidden"
: "configForm.advancedHiddenPlural",
{ count: String(split.advancedLeafCount) },
)}
</span>
<span class="config-advanced-ghost__action">
${t("configForm.showAdvanced")}
</span>
</button>
`
: nothing}
${renderConfigTierGroups({
schema: params.node,
path: params.path,
hints: props.uiHints,
revealAdvanced,
onShowAdvanced: props.onShowAdvanced,
renderTier,
})}
</section>
`;
};
+1 -1
View File
@@ -1,5 +1,5 @@
// Control UI view renders config form screen content.
export { renderConfigForm } from "./config-form.render.ts";
export { renderConfigForm, renderConfigTierGroups } from "./config-form.render.ts";
export { analyzeConfigSchema, type ConfigSchemaAnalysis } from "./config-form.analyze.ts";
export { renderNode } from "./config-form.node.ts";
export { schemaType, type JsonSchema } from "./config-form.shared.ts";
+23
View File
@@ -11,6 +11,7 @@ import { titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { resolveControlUiAuthHeader } from "../../app/control-ui-auth.ts";
import { hasOperatorAdminAccess, hasOperatorPairingAccess } from "../../app/operator-access.ts";
import { loadSettings, patchSettings } from "../../app/settings.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { resolveChannelPairingAuthSignature } from "../../lib/channels/index.ts";
@@ -71,6 +72,9 @@ class ChannelsPage extends OpenClawLightDomElement {
@state()
private pairingNotice: string | null = null;
@state()
private showAdvancedSettings = false;
private readonly wizardHost = new ChannelWizardHost({
getContext: () => this.context,
requestUpdate: () => this.requestUpdate(),
@@ -150,6 +154,16 @@ class ChannelsPage extends OpenClawLightDomElement {
this.applyGatewaySnapshot(snapshot, false);
});
},
)
// The advanced tier is one global display pref; theme republishes every
// appearance setting, so this keeps the channel forms in sync with the
// toggle on the config pages.
.watch(
() => this.context?.theme,
(theme, notify) => theme.subscribe(notify),
() => {
this.showAdvancedSettings = loadSettings().showAdvancedSettings === true;
},
);
private applyGatewaySnapshot(
@@ -260,6 +274,13 @@ class ChannelsPage extends OpenClawLightDomElement {
super.disconnectedCallback();
}
private setShowAdvancedSettings(enabled: boolean) {
patchSettings({ showAdvancedSettings: enabled });
// Republish so the config pages and this page read the same pref without a
// reload; patchSettings alone only writes storage and the server pref.
this.context.theme.refresh();
}
private async saveChannelConfig() {
const context = this.context;
if (!context) {
@@ -662,6 +683,7 @@ class ChannelsPage extends OpenClawLightDomElement {
configUiHints: config.configUiHints,
configSaving: config.configSaving,
configFormDirty: config.configFormDirty,
showAdvancedSettings: this.showAdvancedSettings,
nostrProfileFormState: this.nostrProfileFormState,
nostrProfileAccountId: this.nostrProfileAccountId,
selectedChannel: this.selectedChannel,
@@ -696,6 +718,7 @@ class ChannelsPage extends OpenClawLightDomElement {
void context.channels.waitWhatsApp(this.wizardHost.whatsappAccountId),
onWhatsAppLogout: () =>
void context.channels.logoutWhatsApp(this.wizardHost.whatsappAccountId),
onShowAdvancedSettings: (enabled) => this.setShowAdvancedSettings(enabled),
onConfigPatch: (path, value) => context.runtimeConfig.patchForm(path, value),
onConfigSave: () => void this.saveChannelConfig(),
onConfigReload: () => void this.reloadChannelConfig(),
+23 -7
View File
@@ -3,6 +3,7 @@ import { html } from "lit";
import type { ConfigUiHints } from "../../api/types.ts";
import {
analyzeConfigSchema,
renderConfigTierGroups,
renderNode,
schemaType,
type JsonSchema,
@@ -17,6 +18,8 @@ type ChannelConfigFormProps = {
schema: unknown;
uiHints: ConfigUiHints;
disabled: boolean;
showAdvanced: boolean;
onShowAdvanced: (enabled: boolean) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
};
@@ -100,17 +103,28 @@ function renderChannelConfigForm(props: ChannelConfigFormProps) {
}
const configValue = props.configValue ?? {};
const value = resolveChannelValue(configValue, props.channelId);
const path = ["channels", props.channelId];
const unsupported = new Set(analysis.unsupportedPaths);
return html`
<div class="config-form">
${renderNode({
${renderConfigTierGroups({
schema: node,
value,
path: ["channels", props.channelId],
path,
hints: props.uiHints,
unsupported: new Set(analysis.unsupportedPaths),
disabled: props.disabled,
showLabel: false,
onPatch: props.onPatch,
revealAdvanced: props.showAdvanced,
onShowAdvanced: () => props.onShowAdvanced(true),
onHideAdvanced: () => props.onShowAdvanced(false),
renderTier: (tier) =>
renderNode({
schema: tier,
value,
path,
hints: props.uiHints,
unsupported,
disabled: props.disabled,
showLabel: false,
onPatch: props.onPatch,
}),
})}
</div>
${renderExtraChannelFields(value)}
@@ -130,6 +144,8 @@ export function renderChannelConfigSection(params: { channelId: string; props: C
schema: props.configSchema,
uiHints: props.configUiHints,
disabled,
showAdvanced: props.showAdvancedSettings,
onShowAdvanced: props.onShowAdvancedSettings,
onPatch: props.onConfigPatch,
})}
<div class="settings-row__control">
@@ -64,6 +64,7 @@ function createProps(overrides: Partial<ChannelsProps> = {}): ChannelsProps {
configUiHints: {},
configSaving: false,
configFormDirty: false,
showAdvancedSettings: false,
nostrProfileFormState: null,
nostrProfileAccountId: null,
selectedChannel: null,
@@ -88,6 +89,7 @@ function createProps(overrides: Partial<ChannelsProps> = {}): ChannelsProps {
onWhatsAppStart: () => undefined,
onWhatsAppWait: () => undefined,
onWhatsAppLogout: () => undefined,
onShowAdvancedSettings: () => undefined,
onConfigPatch: () => undefined,
onConfigSave: () => undefined,
onConfigReload: () => undefined,
+97
View File
@@ -43,6 +43,7 @@ function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps {
configUiHints: {},
configSaving: false,
configFormDirty: false,
showAdvancedSettings: false,
nostrProfileFormState: null,
nostrProfileAccountId: null,
selectedChannel: null,
@@ -67,6 +68,7 @@ function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps {
onWhatsAppStart: () => {},
onWhatsAppWait: () => {},
onWhatsAppLogout: () => {},
onShowAdvancedSettings: () => {},
onConfigPatch: () => {},
onConfigSave: () => {},
onConfigReload: () => {},
@@ -119,6 +121,101 @@ function renderWhatsAppButtons(params: {
};
}
// Mirrors the tiers the gateway materializes on every channel schema path.
const CHANNEL_TIER_SCHEMA = {
type: "object",
properties: {
channels: {
type: "object",
properties: {
whatsapp: {
type: "object",
properties: {
enabled: { type: "boolean" },
timeoutMs: { type: "integer" },
retry: {
type: "object",
properties: { attempts: { type: "integer" } },
},
},
},
},
},
},
};
const CHANNEL_TIER_HINTS = {
"channels.whatsapp.enabled": { advanced: false },
"channels.whatsapp.timeoutMs": { advanced: true },
"channels.whatsapp.retry": { advanced: true },
"channels.whatsapp.retry.attempts": { advanced: true },
};
function renderWhatsAppConfigForm(
showAdvancedSettings: boolean,
hints: Record<string, { advanced: boolean }> = CHANNEL_TIER_HINTS,
) {
const whatsapp = createWhatsAppStatus();
const props = createProps({
ts: Date.now(),
channelOrder: ["whatsapp"],
channelLabels: { whatsapp: "WhatsApp" },
channels: { whatsapp },
channelAccounts: {},
channelDefaultAccountId: {},
});
const onShowAdvancedSettings = vi.fn();
props.configSchema = CHANNEL_TIER_SCHEMA;
props.configUiHints = hints;
props.configForm = { channels: { whatsapp: { enabled: true, timeoutMs: 5000 } } };
props.showAdvancedSettings = showAdvancedSettings;
props.onShowAdvancedSettings = onShowAdvancedSettings;
const container = document.createElement("div");
render(renderWhatsAppCard({ props, whatsapp }), container);
return { container, onShowAdvancedSettings };
}
describe("channel config advanced tier", () => {
it("hides advanced channel settings behind the ghost row by default", () => {
const { container, onShowAdvancedSettings } = renderWhatsAppConfigForm(false);
expect(container.textContent).toContain("Enabled");
expect(container.textContent).not.toContain("Timeout Ms");
expect(container.querySelector(".config-advanced-divider")).toBeNull();
const ghost = container.querySelector<HTMLButtonElement>(".config-advanced-ghost");
expect(ghost?.textContent).toContain("2 advanced settings hidden");
ghost!.click();
expect(onShowAdvancedSettings).toHaveBeenCalledWith(true);
});
it("reveals advanced channel settings with a collapse affordance", () => {
const { container, onShowAdvancedSettings } = renderWhatsAppConfigForm(true);
expect(container.textContent).toContain("Enabled");
expect(container.textContent).toContain("Timeout Ms");
expect(container.querySelector(".config-advanced-ghost")).toBeNull();
const collapse = container.querySelector<HTMLButtonElement>(".config-advanced-divider__toggle");
expect(collapse).toBeInstanceOf(HTMLButtonElement);
collapse!.click();
expect(onShowAdvancedSettings).toHaveBeenCalledWith(false);
});
it("keeps the collapse control for channels whose settings are all advanced", () => {
const { container, onShowAdvancedSettings } = renderWhatsAppConfigForm(true, {
...CHANNEL_TIER_HINTS,
"channels.whatsapp.enabled": { advanced: true },
});
const collapse = container.querySelector<HTMLButtonElement>(".config-advanced-divider__toggle");
expect(collapse).toBeInstanceOf(HTMLButtonElement);
collapse!.click();
expect(onShowAdvancedSettings).toHaveBeenCalledWith(false);
});
});
describe("channel display selectors", () => {
it("returns the channel summary configured flag when present", () => {
const props = createProps({
+2
View File
@@ -54,6 +54,7 @@ export type ChannelsProps = {
configUiHints: ConfigUiHints;
configSaving: boolean;
configFormDirty: boolean;
showAdvancedSettings: boolean;
nostrProfileFormState: NostrProfileFormState | null;
nostrProfileAccountId: string | null;
selectedChannel: string | null;
@@ -80,6 +81,7 @@ export type ChannelsProps = {
onWhatsAppStart: (force: boolean) => void;
onWhatsAppWait: () => void;
onWhatsAppLogout: () => void;
onShowAdvancedSettings: (enabled: boolean) => void;
onConfigPatch: (path: Array<string | number>, value: unknown) => void;
onConfigSave: () => void;
onConfigReload: () => void;
-41
View File
@@ -86,47 +86,6 @@
background: var(--accent-subtle);
}
.config-advanced-divider {
margin-top: var(--space-5);
padding: 0 var(--space-4) var(--space-2);
color: var(--muted);
font-size: var(--control-ui-text-xs);
font-weight: 550;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.config-advanced-ghost {
width: 100%;
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
border-radius: var(--radius-lg);
background: var(--secondary);
text-align: left;
transition: background var(--duration-fast) ease;
}
.config-advanced-ghost:hover {
background: var(--bg-hover);
}
.config-advanced-ghost__count {
color: var(--muted);
font-size: var(--control-ui-text-sm);
}
.config-advanced-ghost__action {
color: var(--accent);
font-size: var(--control-ui-text-sm);
font-weight: 600;
flex: 0 0 auto;
}
/* Restart affordance shown after a successful save until apply */
.config-apply-banner {
display: flex;
+65
View File
@@ -155,6 +155,71 @@
border-color: color-mix(in srgb, var(--danger) 35%, var(--border) 65%);
}
/* ── Advanced tier ──
* Lives here rather than config.css because every schema-driven surface
* (config pages and the channel detail forms) renders the same split. */
.config-advanced-divider {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-5);
padding: 0 var(--space-4) var(--space-2);
color: var(--muted);
font-size: var(--control-ui-text-xs);
font-weight: 550;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.config-advanced-divider__toggle {
border: 0;
background: none;
padding: 0;
color: var(--accent);
font-size: var(--control-ui-text-xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.config-advanced-divider__toggle:hover {
text-decoration: underline;
}
.config-advanced-ghost {
width: 100%;
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
border-radius: var(--radius-lg);
background: var(--secondary);
text-align: left;
transition: background var(--duration-fast) ease;
}
.config-advanced-ghost:hover {
background: var(--bg-hover);
}
.config-advanced-ghost__count {
color: var(--muted);
font-size: var(--control-ui-text-sm);
}
.config-advanced-ghost__action {
color: var(--accent);
font-size: var(--control-ui-text-sm);
font-weight: 600;
flex: 0 0 auto;
}
/* ── Row ── */
.settings-row {