From 20a7dd8a805314ee95c2a6ae9b0e4d6c639e2d7a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 01:16:58 +0000 Subject: [PATCH] feat: add config subsections in control ui --- ui/src/styles/config.css | 100 +++++++++++++++++++ ui/src/ui/app-render.ts | 7 +- ui/src/ui/app.ts | 1 + ui/src/ui/controllers/config.ts | 1 + ui/src/ui/views/config-form.render.ts | 130 ++++++++++++++++++------- ui/src/ui/views/config.browser.test.ts | 7 ++ ui/src/ui/views/config.ts | 113 +++++++++++++++++++++ 7 files changed, 325 insertions(+), 34 deletions(-) diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index 641617b30..aa41505ae 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -358,6 +358,98 @@ color: var(--ok); } +/* Section Hero */ +.config-section-hero { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.04); +} + +:root[data-theme="light"] .config-section-hero { + background: rgba(0, 0, 0, 0.015); +} + +.config-section-hero__icon { + width: 28px; + height: 28px; + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; +} + +.config-section-hero__icon svg { + width: 100%; + height: 100%; + stroke: currentColor; + fill: none; +} + +.config-section-hero__text { + display: grid; + gap: 2px; + min-width: 0; +} + +.config-section-hero__title { + font-size: 15px; + font-weight: 600; +} + +.config-section-hero__desc { + font-size: 12px; + color: var(--muted); +} + +/* Subsection Nav */ +.config-subnav { + display: flex; + gap: 8px; + padding: 10px 20px 12px; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.03); + overflow-x: auto; +} + +:root[data-theme="light"] .config-subnav { + background: rgba(0, 0, 0, 0.02); +} + +.config-subnav__item { + border: 1px solid transparent; + border-radius: 999px; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + color: var(--muted); + background: rgba(0, 0, 0, 0.12); + cursor: pointer; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease; + white-space: nowrap; +} + +:root[data-theme="light"] .config-subnav__item { + background: rgba(0, 0, 0, 0.06); +} + +.config-subnav__item:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.08); +} + +:root[data-theme="light"] .config-subnav__item:hover { + background: rgba(0, 0, 0, 0.08); +} + +.config-subnav__item.active { + color: var(--accent); + border-color: rgba(245, 159, 74, 0.4); + background: rgba(245, 159, 74, 0.12); +} + /* Content Area */ .config-content { flex: 1; @@ -1243,6 +1335,14 @@ justify-content: center; } + .config-section-hero { + padding: 12px 16px; + } + + .config-subnav { + padding: 8px 16px 10px; + } + .config-content { padding: 16px; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index f8d9e9444..cae89498c 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -485,11 +485,16 @@ export function renderApp(state: AppViewState) { originalValue: state.configFormOriginal, searchQuery: state.configSearchQuery, activeSection: state.configActiveSection, + activeSubsection: state.configActiveSubsection, onRawChange: (next) => (state.configRaw = next), onFormModeChange: (mode) => (state.configFormMode = mode), onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onSearchChange: (query) => (state.configSearchQuery = query), - onSectionChange: (section) => (state.configActiveSection = section), + onSectionChange: (section) => { + state.configActiveSection = section; + state.configActiveSubsection = null; + }, + onSubsectionChange: (section) => (state.configActiveSubsection = section), onReload: () => loadConfig(state), onSave: () => saveConfig(state), onApply: () => applyConfig(state), diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 97b4d4da2..f6e48cdd4 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -152,6 +152,7 @@ export class ClawdbotApp extends LitElement { @state() configFormMode: "form" | "raw" = "form"; @state() configSearchQuery = ""; @state() configActiveSection: string | null = null; + @state() configActiveSubsection: string | null = null; @state() channelsLoading = false; @state() channelsSnapshot: ChannelsStatusSnapshot | null = null; diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 38dc3b0fd..71dccaedd 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -33,6 +33,7 @@ export type ConfigState = { configFormMode: "form" | "raw"; configSearchQuery: string; configActiveSection: string | null; + configActiveSubsection: string | null; lastError: string | null; }; diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index 1c64c1caf..60f2feb75 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -1,6 +1,11 @@ import { html, nothing } from "lit"; import type { ConfigUiHints } from "../types"; -import { hintForPath, schemaType, type JsonSchema } from "./config-form.shared"; +import { + hintForPath, + humanize, + schemaType, + type JsonSchema, +} from "./config-form.shared"; import { renderNode } from "./config-form.node"; export type ConfigFormProps = { @@ -11,6 +16,7 @@ export type ConfigFormProps = { unsupportedPaths?: string[]; searchQuery?: string; activeSection?: string | null; + activeSubsection?: string | null; onPatch: (path: Array, value: unknown) => void; }; @@ -146,6 +152,7 @@ export function renderConfigForm(props: ConfigFormProps) { const properties = schema.properties; const searchQuery = props.searchQuery ?? ""; const activeSection = props.activeSection; + const activeSubsection = props.activeSubsection ?? null; // Filter and sort entries let entries = Object.entries(properties); @@ -168,6 +175,25 @@ export function renderConfigForm(props: ConfigFormProps) { return a[0].localeCompare(b[0]); }); + let subsectionContext: + | { sectionKey: string; subsectionKey: string; schema: JsonSchema } + | null = null; + if (activeSection && activeSubsection && entries.length === 1) { + const sectionSchema = entries[0]?.[1]; + if ( + sectionSchema && + schemaType(sectionSchema) === "object" && + sectionSchema.properties && + sectionSchema.properties[activeSubsection] + ) { + subsectionContext = { + sectionKey: activeSection, + subsectionKey: activeSubsection, + schema: sectionSchema.properties[activeSubsection], + }; + } + } + if (entries.length === 0) { return html`
@@ -183,38 +209,76 @@ export function renderConfigForm(props: ConfigFormProps) { return html`
- ${entries.map(([key, node]) => { - const meta = SECTION_META[key] ?? { - label: key.charAt(0).toUpperCase() + key.slice(1), - description: node.description ?? "" - }; - - return html` -
-
- ${getSectionIcon(key)} -
-

${meta.label}

- ${meta.description ? html` -

${meta.description}

- ` : nothing} -
-
-
- ${renderNode({ - schema: node, - value: (value as Record)[key], - path: [key], - hints: props.uiHints, - unsupported, - disabled: props.disabled ?? false, - showLabel: false, - onPatch: props.onPatch, - })} -
-
- `; - })} + ${subsectionContext + ? (() => { + const { sectionKey, subsectionKey, schema: node } = subsectionContext; + const hint = hintForPath([sectionKey, subsectionKey], props.uiHints); + const label = hint?.label ?? node.title ?? humanize(subsectionKey); + const description = hint?.help ?? node.description ?? ""; + const sectionValue = (value as Record)[sectionKey]; + const scopedValue = + sectionValue && typeof sectionValue === "object" + ? (sectionValue as Record)[subsectionKey] + : undefined; + const id = `config-section-${sectionKey}-${subsectionKey}`; + return html` +
+
+ ${getSectionIcon(sectionKey)} +
+

${label}

+ ${description + ? html`

${description}

` + : nothing} +
+
+
+ ${renderNode({ + schema: node, + value: scopedValue, + path: [sectionKey, subsectionKey], + hints: props.uiHints, + unsupported, + disabled: props.disabled ?? false, + showLabel: false, + onPatch: props.onPatch, + })} +
+
+ `; + })() + : entries.map(([key, node]) => { + const meta = SECTION_META[key] ?? { + label: key.charAt(0).toUpperCase() + key.slice(1), + description: node.description ?? "", + }; + + return html` +
+
+ ${getSectionIcon(key)} +
+

${meta.label}

+ ${meta.description + ? html`

${meta.description}

` + : nothing} +
+
+
+ ${renderNode({ + schema: node, + value: (value as Record)[key], + path: [key], + hints: props.uiHints, + unsupported, + disabled: props.disabled ?? false, + showLabel: false, + onPatch: props.onPatch, + })} +
+
+ `; + })}
`; } diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index fb83a2fb2..b1636d294 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -21,13 +21,20 @@ describe("config view", () => { uiHints: {}, formMode: "form" as const, formValue: {}, + originalValue: {}, + searchQuery: "", + activeSection: null, + activeSubsection: null, onRawChange: vi.fn(), onFormModeChange: vi.fn(), onFormPatch: vi.fn(), + onSearchChange: vi.fn(), + onSectionChange: vi.fn(), onReload: vi.fn(), onSave: vi.fn(), onApply: vi.fn(), onUpdate: vi.fn(), + onSubsectionChange: vi.fn(), }); it("disables save when form is unsafe", () => { diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index e8448c52f..9af992024 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1,6 +1,12 @@ import { html, nothing } from "lit"; import type { ConfigUiHints } from "../types"; import { analyzeConfigSchema, renderConfigForm } from "./config-form"; +import { + hintForPath, + humanize, + schemaType, + type JsonSchema, +} from "./config-form.shared"; export type ConfigProps = { raw: string; @@ -19,11 +25,13 @@ export type ConfigProps = { originalValue: Record | null; searchQuery: string; activeSection: string | null; + activeSubsection: string | null; onRawChange: (next: string) => void; onFormModeChange: (mode: "form" | "raw") => void; onFormPatch: (path: Array, value: unknown) => void; onSearchChange: (query: string) => void; onSectionChange: (section: string | null) => void; + onSubsectionChange: (section: string | null) => void; onReload: () => void; onSave: () => void; onApply: () => void; @@ -80,10 +88,49 @@ const SECTIONS: Array<{ key: string; label: string }> = [ { key: "wizard", label: "Setup Wizard" }, ]; +type SubsectionEntry = { + key: string; + label: string; + description?: string; + order: number; +}; + +const ALL_SUBSECTION = "__all__"; + function getSectionIcon(key: string) { return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default; } +function resolveSectionMeta(key: string, schema?: JsonSchema): { + label: string; + description?: string; +} { + const meta = SECTION_META[key]; + if (meta) return meta; + return { + label: schema?.title ?? humanize(key), + description: schema?.description ?? "", + }; +} + +function resolveSubsections(params: { + key: string; + schema: JsonSchema | undefined; + uiHints: ConfigUiHints; +}): SubsectionEntry[] { + const { key, schema, uiHints } = params; + if (!schema || schemaType(schema) !== "object" || !schema.properties) return []; + const entries = Object.entries(schema.properties).map(([subKey, node]) => { + const hint = hintForPath([key, subKey], uiHints); + const label = hint?.label ?? node.title ?? humanize(subKey); + const description = hint?.help ?? node.description ?? ""; + const order = hint?.order ?? 50; + return { key: subKey, label, description, order }; + }); + entries.sort((a, b) => (a.order !== b.order ? a.order - b.order : a.key.localeCompare(b.key))); + return entries; +} + function computeDiff( original: Record | null, current: Record | null @@ -164,6 +211,31 @@ export function renderConfig(props: ConfigProps) { .map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) })); const allSections = [...availableSections, ...extraSections]; + + const activeSectionSchema = + props.activeSection && analysis.schema && schemaType(analysis.schema) === "object" + ? (analysis.schema.properties?.[props.activeSection] as JsonSchema | undefined) + : undefined; + const activeSectionMeta = props.activeSection + ? resolveSectionMeta(props.activeSection, activeSectionSchema) + : null; + const subsections = props.activeSection + ? resolveSubsections({ + key: props.activeSection, + schema: activeSectionSchema, + uiHints: props.uiHints, + }) + : []; + const allowSubnav = + props.formMode === "form" && + Boolean(props.activeSection) && + subsections.length > 0; + const isAllSubsection = props.activeSubsection === ALL_SUBSECTION; + const effectiveSubsection = props.searchQuery + ? null + : isAllSubsection + ? null + : props.activeSubsection ?? (subsections[0]?.key ?? null); // Compute diff for showing changes const diff = props.formMode === "form" @@ -304,6 +376,46 @@ export function renderConfig(props: ConfigProps) { ` : nothing} + ${activeSectionMeta && props.formMode === "form" + ? html` +
+
${getSectionIcon(props.activeSection ?? "")}
+
+
${activeSectionMeta.label}
+ ${activeSectionMeta.description + ? html`
${activeSectionMeta.description}
` + : nothing} +
+
+ ` + : nothing} + + ${allowSubnav + ? html` +
+ + ${subsections.map( + (entry) => html` + + `, + )} +
+ ` + : nothing} +
${props.formMode === "form" @@ -322,6 +434,7 @@ export function renderConfig(props: ConfigProps) { onPatch: props.onFormPatch, searchQuery: props.searchQuery, activeSection: props.activeSection, + activeSubsection: effectiveSubsection, })} ${formUnsafe ? html`