diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 7edf56dcc067..8711b73b6037 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -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. Edit `~/.openclaw/openclaw.json` directly. The Gateway watches the file and applies changes automatically (see [hot reload](#config-hot-reload)). diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 6b0d8a1db3b1..d31197398e0b 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -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(), }, diff --git a/ui/src/components/config-form.render.ts b/ui/src/components/config-form.render.ts index c7ef1bad26c3..ff28497c53a7 100644 --- a/ui/src/components/config-form.render.ts +++ b/ui/src/components/config-form.render.ts @@ -35,6 +35,73 @@ type ConfigFormProps = { onPatch: (path: Array, value: unknown) => void; }; +function renderAdvancedDivider(onHideAdvanced: (() => void) | undefined) { + return html`
+ ${t("configForm.advancedDivider")} + ${onHideAdvanced + ? html`` + : nothing} +
`; +} + +/** 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; + 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`
${params.renderTier(split.common)}
` + : nothing} + ${split.advanced && split.advancedLeafCount > 0 + ? params.revealAdvanced + ? html` + ${showDivider ? renderAdvancedDivider(params.onHideAdvanced) : nothing} +
${params.renderTier(split.advanced)}
+ ` + : html` + + ` + : nothing} + `; +} + function matchesSearch(params: { key: string; schema: JsonSchema; @@ -137,11 +204,6 @@ export function renderConfigForm(props: ConfigFormProps) { nodeValue: unknown; path: Array; }) => { - 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`

${params.description}

` : nothing} - ${split.common - ? html`
${renderTier(split.common)}
` - : nothing} - ${split.advanced && split.advancedLeafCount > 0 - ? revealAdvanced - ? html` - ${split.common - ? html`
- ${t("configForm.advancedDivider")} -
` - : nothing} -
${renderTier(split.advanced)}
- ` - : html` - - ` - : nothing} + ${renderConfigTierGroups({ + schema: params.node, + path: params.path, + hints: props.uiHints, + revealAdvanced, + onShowAdvanced: props.onShowAdvanced, + renderTier, + })} `; }; diff --git a/ui/src/components/config-form.ts b/ui/src/components/config-form.ts index afe0e497a8ff..7355284bb2dd 100644 --- a/ui/src/components/config-form.ts +++ b/ui/src/components/config-form.ts @@ -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"; diff --git a/ui/src/pages/channels/channels-page.ts b/ui/src/pages/channels/channels-page.ts index a37998a294c5..edc25133f3fa 100644 --- a/ui/src/pages/channels/channels-page.ts +++ b/ui/src/pages/channels/channels-page.ts @@ -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(), diff --git a/ui/src/pages/channels/view.config.ts b/ui/src/pages/channels/view.config.ts index eb14fbfc42c5..81f5b54b1a81 100644 --- a/ui/src/pages/channels/view.config.ts +++ b/ui/src/pages/channels/view.config.ts @@ -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, 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`
- ${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, + }), })}
${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, })}
diff --git a/ui/src/pages/channels/view.pairing.test.ts b/ui/src/pages/channels/view.pairing.test.ts index 59bf53438864..37060621f553 100644 --- a/ui/src/pages/channels/view.pairing.test.ts +++ b/ui/src/pages/channels/view.pairing.test.ts @@ -64,6 +64,7 @@ function createProps(overrides: Partial = {}): ChannelsProps { configUiHints: {}, configSaving: false, configFormDirty: false, + showAdvancedSettings: false, nostrProfileFormState: null, nostrProfileAccountId: null, selectedChannel: null, @@ -88,6 +89,7 @@ function createProps(overrides: Partial = {}): ChannelsProps { onWhatsAppStart: () => undefined, onWhatsAppWait: () => undefined, onWhatsAppLogout: () => undefined, + onShowAdvancedSettings: () => undefined, onConfigPatch: () => undefined, onConfigSave: () => undefined, onConfigReload: () => undefined, diff --git a/ui/src/pages/channels/view.test.ts b/ui/src/pages/channels/view.test.ts index 657edeaa5990..53601e2a34a9 100644 --- a/ui/src/pages/channels/view.test.ts +++ b/ui/src/pages/channels/view.test.ts @@ -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 = 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(".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(".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(".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({ diff --git a/ui/src/pages/channels/view.types.ts b/ui/src/pages/channels/view.types.ts index 0041f9d8622b..41b815b4ca28 100644 --- a/ui/src/pages/channels/view.types.ts +++ b/ui/src/pages/channels/view.types.ts @@ -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, value: unknown) => void; onConfigSave: () => void; onConfigReload: () => void; diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index 996af422c791..e710a222bb7f 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -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; diff --git a/ui/src/styles/settings.css b/ui/src/styles/settings.css index c9651bd0e747..0118fbb0bd3b 100644 --- a/ui/src/styles/settings.css +++ b/ui/src/styles/settings.css @@ -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 {