ui: complete contextual Settings documentation links (#115665)

* feat(config): require docs decisions for root settings

* feat(ui): link settings pages to guides

* refactor(ui): keep model providers page compact

* fix(ui): style page subtitles and carry base hints in mock dev
This commit is contained in:
Peter Steinberger
2026-07-29 03:35:28 -04:00
committed by GitHub
parent 6090386dc7
commit 6fbaa4d232
12 changed files with 108 additions and 14 deletions
+4 -1
View File
@@ -14,6 +14,7 @@ import type {
} from "../packages/gateway-protocol/src/index.js";
import { expectDefined } from "../packages/normalization-core/src/expect.js";
import { applySharedChannelFieldHelp } from "../src/config/schema.channel-field-help.js";
import { buildBaseHints } from "../src/config/schema.hints.js";
import { applyConfigTierHints, applyResolvedConfigTierHints } from "../src/config/schema.tiers.js";
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../src/gateway/control-ui-contract.js";
import {
@@ -851,7 +852,9 @@ function buildConfigMocks(options: { swarmEnabled?: boolean } = {}) {
uiHints: applySharedChannelFieldHelp(
applyResolvedConfigTierHints(
schema,
applyConfigTierHints({}, { includePluginOwnedChannels: true }),
// Seed with base hints so the mock carries the gateway's labels,
// help, and docsUrl metadata instead of bare tier scaffolding.
applyConfigTierHints(buildBaseHints(), { includePluginOwnedChannels: true }),
),
),
version: "mock-config-schema",
+15 -1
View File
@@ -8,9 +8,11 @@ import { buildSecretInputSchema } from "../plugin-sdk/secret-input-schema.js";
import { buildBaseHints, testApi } from "./schema.hints.js";
import { isSensitiveConfigPath } from "./sensitive-paths.js";
import { OpenClawSchema } from "./zod-schema.js";
import { OpenClawSchemaShape } from "./zod-schema.root-shape.js";
import { sensitive } from "./zod-schema.sensitive.js";
const { collectMatchingSchemaPaths, mapSensitivePaths, SECTION_DOCS_URLS } = testApi;
const { collectMatchingSchemaPaths, mapSensitivePaths, SECTION_DOCS_URLS, SECTIONS_WITHOUT_DOCS } =
testApi;
const BUNDLED_CHANNEL_HINT_PREFIXES = [
"channels.discord",
"channels.imessage",
@@ -23,6 +25,18 @@ const BUNDLED_CHANNEL_HINT_PREFIXES = [
] as const;
describe("section docs URLs", () => {
it("accounts for every root config section", () => {
const sectionsWithDocsDecisions = new Set([
...Object.keys(SECTION_DOCS_URLS),
...SECTIONS_WITHOUT_DOCS,
]);
const undecidedSections = Object.keys(OpenClawSchemaShape).filter(
(section) => !sectionsWithDocsDecisions.has(section),
);
expect(undecidedSections).toEqual([]);
});
it("maps every URL to an existing task-oriented docs page", () => {
const hints = buildBaseHints();
const docsOrigin = "https://docs.openclaw.ai";
+9
View File
@@ -47,6 +47,7 @@ const GROUP_HINTS = [
// docsUrl targets task-oriented or beginner pages; configuration-reference anchors are banned.
const SECTION_DOCS_URLS = {
accessGroups: "https://docs.openclaw.ai/channels/access-groups",
messages: "https://docs.openclaw.ai/concepts/messages",
tts: "https://docs.openclaw.ai/tts",
commands: "https://docs.openclaw.ai/tools/slash-commands",
@@ -85,8 +86,15 @@ const SECTION_DOCS_URLS = {
presence: "https://docs.openclaw.ai/concepts/presence",
cloudWorkers: "https://docs.openclaw.ai/gateway/cloud-workers",
worktrees: "https://docs.openclaw.ai/concepts/managed-worktrees",
proxy: "https://docs.openclaw.ai/security/network-proxy",
transcripts: "https://docs.openclaw.ai/plugins/meeting-plugins",
surfaces: "https://docs.openclaw.ai/concepts/messages",
} as const satisfies Record<string, string>;
// Root sections without beginner-worthy pages stay explicit. Adding a root config key
// requires choosing a docsUrl or listing it here.
const SECTIONS_WITHOUT_DOCS = ["$schema", "meta", "attachments"] as const;
const FIELD_PLACEHOLDERS: Record<string, string> = {
"gateway.remote.url": "ws://host:18789",
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
@@ -325,4 +333,5 @@ export const testApi = {
collectMatchingSchemaPaths,
mapSensitivePaths,
SECTION_DOCS_URLS,
SECTIONS_WITHOUT_DOCS,
};
+2
View File
@@ -1142,6 +1142,7 @@ export const en: TranslationMap = {
// keys now serve Models, General, Privacy & Security, Appearance, and Profile.
// Renaming would force retranslation of every key, so the name stays.
quickSettings: {
intro: "Settings sync to your Gateway configuration file.",
language: "Language",
model: {
title: "Model & Thinking",
@@ -1348,6 +1349,7 @@ export const en: TranslationMap = {
"Notifications are disabled for OpenClaw in macOS. Allow them in System Settings > Notifications.",
},
appearance: {
intro: "Theme, chat, and sidebar preferences for this Control UI client.",
theme: "Theme",
chooseTheme: "Choose a theme family.",
importedTheme: "Imported theme",
+25
View File
@@ -619,6 +619,31 @@ describe("MemorySettingsPage tab routing", () => {
});
describe("MemorySettingsPage dreaming support", () => {
it("links the dreaming intro to its guide", async () => {
const { element } = createPage({
configObject: {},
routeData: memoryTabRoute("settings"),
});
document.body.append(element);
try {
await waitForFast(() =>
expect(
element.querySelector(
'.settings-page__intro a[href="https://docs.openclaw.ai/concepts/dreaming"]',
),
).not.toBeNull(),
);
const link = element.querySelector<HTMLAnchorElement>(
'.settings-page__intro a[href="https://docs.openclaw.ai/concepts/dreaming"]',
);
expect(link?.textContent?.trim()).toBe("Learn more");
expect(link?.href).toBe("https://docs.openclaw.ai/concepts/dreaming");
} finally {
element.remove();
}
});
it("re-probes after reconnect and drops the abandoned capability result", async () => {
const first = deferred<unknown>();
const second = deferred<unknown>();
+6 -1
View File
@@ -9,6 +9,7 @@ import type { DoctorMemoryStatusPayload } from "../../../../src/gateway/server-m
import { pathForMemoryTab } from "../../app-route-paths.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import type { AgentSelectOption } from "../../components/agent-select.ts";
import { renderDocsLink } from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import { listSelectableAgents, normalizeAgentLabel } from "../../lib/agents/display.ts";
import { currentConfigObject } from "../../lib/config/index.ts";
@@ -55,6 +56,7 @@ const MEMORY_ADDON_PLUGINS = [
/** Explicit-off sentinel; resolveSlotSelection maps it to an `off` selection. */
const MEMORY_SLOT_OFF = "none";
const MEMORY_SLOT_PATH = ["plugins", "slots", "memory"];
const DREAMING_DOCS_URL = "https://docs.openclaw.ai/concepts/dreaming";
type GatewayClient = NonNullable<ApplicationContext["gateway"]["snapshot"]["client"]>;
@@ -455,7 +457,10 @@ class MemorySettingsPage extends OpenClawLightDomElement {
private renderDreamingControls() {
const pluginId = this.dreamingPluginId();
return html`
<p class="settings-page__intro">${t("memoryPage.dreaming.intro", { plugin: pluginId })}</p>
<p class="settings-page__intro">
${t("memoryPage.dreaming.intro", { plugin: pluginId })}
${renderDocsLink(DREAMING_DOCS_URL, t("common.learnMore"))}
</p>
${this.support === "unsupported"
? renderDreamingUnsupported(pluginId)
: renderDreamingSettings({
+5
View File
@@ -51,6 +51,11 @@ describe("renderQuickSettings", () => {
expect(container.querySelector(".config-host")).toBeNull();
expect(container.querySelectorAll(".settings-group")).toHaveLength(1);
expect(container.querySelector(".settings-group .settings-group")).toBeNull();
const intro = container.querySelector(".settings-page__intro");
expect(intro?.textContent).toContain("Settings sync to your Gateway configuration file.");
expect(intro?.querySelector<HTMLAnchorElement>("a")?.href).toBe(
"https://docs.openclaw.ai/gateway/configuration",
);
});
it("changes the Control UI language from General settings", () => {
+6
View File
@@ -8,6 +8,7 @@
import { html, nothing } from "lit";
import {
renderDocsLink,
renderSettingsNavRow,
renderSettingsPage,
renderSettingsRow,
@@ -18,6 +19,8 @@ import type { ConfigAutoSaveStatus } from "../../lib/config/index.ts";
import { renderLanguageSelect } from "./language-select.ts";
import { renderConfigApplyBanner, renderConfigAutoSaveStatus } from "./view.ts";
const GENERAL_DOCS_URL = "https://docs.openclaw.ai/gateway/configuration";
// ── Types ──
type QuickSettingsProps = {
@@ -94,6 +97,9 @@ export function renderQuickSettings(props: QuickSettingsProps) {
connected: props.connected,
onApply: () => props.onApplyConfig?.(),
})}
<p class="settings-page__intro">
${t("quickSettings.intro")} ${renderDocsLink(GENERAL_DOCS_URL, t("common.learnMore"))}
</p>
${renderGeneralSection(props)}
`);
}
+7
View File
@@ -4,6 +4,7 @@ import type { ThemeTransitionContext } from "../../app/theme-transition.ts";
import type { ThemeName } from "../../app/theme.ts";
import { icons } from "../../components/icons.ts";
import {
renderDocsLink,
renderSettingsRow,
renderSettingsStatus,
renderSettingsValue,
@@ -17,6 +18,8 @@ import {
} from "./view-appearance-preferences.ts";
import type { ConfigProps } from "./view-types.ts";
const APPEARANCE_DOCS_URL = "https://docs.openclaw.ai/web/control-ui";
const TEXT_SCALE_LABELS: Record<TextScaleStop, string> = {
90: "configView.textSizes.small",
100: "configView.textSizes.default",
@@ -125,6 +128,10 @@ export function renderAppearanceSection(
];
return html`
<div class="settings-page">
<p class="settings-page__intro">
${t("configView.appearance.intro")}
${renderDocsLink(APPEARANCE_DOCS_URL, t("common.learnMore"))}
</p>
<section id=${APPEARANCE_SETTINGS_TARGET_IDS.theme} class="settings-section">
<div class="settings-section__header">
<h2 class="settings-section__heading">${t("configView.appearance.theme")}</h2>
@@ -156,6 +156,16 @@ afterEach(() => {
});
describe("ModelProvidersPage agent scope", () => {
it("links the page subtitle to the model providers guide", async () => {
const { context } = createHarness("main");
const page = appendPage(context);
await page.updateComplete;
const link = page.querySelector<HTMLAnchorElement>(".page-subtitle a");
expect(link?.textContent?.trim()).toBe("Learn more");
expect(link?.href).toBe("https://docs.openclaw.ai/concepts/model-providers");
});
it("patches thinking and fast mode through the shared config draft", async () => {
const { context, runtimeConfig } = createHarness("main");
const page = appendPage(context);
@@ -9,6 +9,7 @@ import { titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { hasOperatorAdminAccess } from "../../app/operator-access.ts";
import { renderAgentScopeControl } from "../../components/agent-scope-control.ts";
import { renderDocsLink } from "../../components/settings-ui.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { normalizeAgentLabel } from "../../lib/agents/display.ts";
@@ -37,6 +38,8 @@ import {
} from "./mutations.ts";
import { renderModelProviders, type ModelProviderRowMessage } from "./view.ts";
const MODEL_PROVIDERS_DOCS_URL = "https://docs.openclaw.ai/concepts/model-providers";
export type ModelProvidersRouteData = {
data: ModelProvidersData;
/** Client the loader fetched from; null when it ran disconnected. */
@@ -570,12 +573,8 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
override render() {
const gatewaySnapshot = this.context.gateway.snapshot;
const agents = this.context.agents.state.agentsList?.agents ?? [];
const selectedAgent = agents.find(
(agent) => normalizeAgentId(agent.id) === this.selectedAgentId,
);
const selectedAgentLabel = selectedAgent
? normalizeAgentLabel(selectedAgent)
: this.selectedAgentId;
const selected = agents.find((agent) => normalizeAgentId(agent.id) === this.selectedAgentId);
const selectedAgentLabel = selected ? normalizeAgentLabel(selected) : this.selectedAgentId;
const data = this.data ?? EMPTY_MODEL_PROVIDERS_DATA;
const config = readModelProviderConfig(data.config);
const defaults = this.defaultsDraft ?? config.defaults;
@@ -588,11 +587,8 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
const agentsDefaults = asConfigRecord(asConfigRecord(configObject.agents)?.defaults);
const thinkingLevel =
typeof agentsDefaults?.thinkingDefault === "string" ? agentsDefaults.thinkingDefault : "off";
const configuredFastMode = agentsDefaults?.fastModeDefault;
const fastMode =
configuredFastMode === "auto" || typeof configuredFastMode === "boolean"
? configuredFastMode
: false;
const fastValue = agentsDefaults?.fastModeDefault;
const fastMode = fastValue === "auto" || typeof fastValue === "boolean" ? fastValue : false;
const update = this.context.overlays.snapshot;
// The overlay update states replace General's old configUpdating prop,
// which config-page derived from this same snapshot (isUpdateBusy); the
@@ -717,6 +713,10 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
<section class="content-header">
<div>
<div class="page-title">${titleForRoute("model-providers")}</div>
<div class="page-subtitle">
${t("modelProviders.subtitle")}
${renderDocsLink(MODEL_PROVIDERS_DOCS_URL, t("common.learnMore"))}
</div>
</div>
<div class="page-header-actions">
${renderAgentScopeControl({
+8
View File
@@ -3716,6 +3716,14 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
color: var(--accent);
}
.page-subtitle {
max-width: 52ch;
margin-top: 2px;
font-size: var(--control-ui-text-sm);
line-height: 1.45;
color: var(--muted);
}
.page-meta {
display: flex;
gap: 8px;