Files
openclaw/ui/src/ui/views/config.ts
Tak Hoffman f8171ffcdc Config UI: tag filters and complete schema help/labels coverage (#23796)
* Config UI: add tag filters and complete schema help/labels

* Config UI: finalize tags/help polish and unblock test suite

* Protocol: regenerate Swift gateway models
2026-02-22 15:17:07 -06:00

821 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types.ts";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
import { getTagFilters, replaceTagFilters } from "./config-search.ts";
export type ConfigProps = {
raw: string;
originalRaw: string;
valid: boolean | null;
issues: unknown[];
loading: boolean;
saving: boolean;
applying: boolean;
updating: boolean;
connected: boolean;
schema: unknown;
schemaLoading: boolean;
uiHints: ConfigUiHints;
formMode: "form" | "raw";
formValue: Record<string, unknown> | null;
originalValue: Record<string, unknown> | null;
searchQuery: string;
activeSection: string | null;
activeSubsection: string | null;
onRawChange: (next: string) => void;
onFormModeChange: (mode: "form" | "raw") => void;
onFormPatch: (path: Array<string | number>, value: unknown) => void;
onSearchChange: (query: string) => void;
onSectionChange: (section: string | null) => void;
onSubsectionChange: (section: string | null) => void;
onReload: () => void;
onSave: () => void;
onApply: () => void;
onUpdate: () => void;
};
const TAG_SEARCH_PRESETS = [
"security",
"auth",
"network",
"access",
"privacy",
"observability",
"performance",
"reliability",
"storage",
"models",
"media",
"automation",
"channels",
"tools",
"advanced",
] as const;
// SVG Icons for sidebar (Lucide-style)
const sidebarIcons = {
all: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
`,
env: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
`,
update: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
`,
agents: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"
></path>
<circle cx="8" cy="14" r="1"></circle>
<circle cx="16" cy="14" r="1"></circle>
</svg>
`,
auth: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
`,
channels: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
`,
messages: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
`,
commands: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
`,
hooks: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
`,
skills: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
></polygon>
</svg>
`,
tools: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
`,
gateway: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
wizard: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 4V2"></path>
<path d="M15 16v-2"></path>
<path d="M8 9h2"></path>
<path d="M20 9h2"></path>
<path d="M17.8 11.8 19 13"></path>
<path d="M15 9h0"></path>
<path d="M17.8 6.2 19 5"></path>
<path d="m3 21 9-9"></path>
<path d="M12.2 6.2 11 5"></path>
</svg>
`,
// Additional sections
meta: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
`,
logging: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
`,
browser: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="4"></circle>
<line x1="21.17" y1="8" x2="12" y2="8"></line>
<line x1="3.95" y1="6.06" x2="8.54" y2="14"></line>
<line x1="10.88" y1="21.94" x2="15.46" y2="14"></line>
</svg>
`,
ui: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
`,
models: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
`,
bindings: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
`,
broadcast: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
<circle cx="12" cy="12" r="2"></circle>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path>
</svg>
`,
audio: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
`,
session: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
`,
cron: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
`,
web: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
discovery: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
`,
canvasHost: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
`,
talk: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
`,
plugins: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v6"></path>
<path d="m4.93 10.93 4.24 4.24"></path>
<path d="M2 12h6"></path>
<path d="m4.93 13.07 4.24-4.24"></path>
<path d="M12 22v-6"></path>
<path d="m19.07 13.07-4.24-4.24"></path>
<path d="M22 12h-6"></path>
<path d="m19.07 10.93-4.24 4.24"></path>
</svg>
`,
default: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
`,
};
// Section definitions
const SECTIONS: Array<{ key: string; label: string }> = [
{ key: "env", label: "Environment" },
{ key: "update", label: "Updates" },
{ key: "agents", label: "Agents" },
{ key: "auth", label: "Authentication" },
{ key: "channels", label: "Channels" },
{ key: "messages", label: "Messages" },
{ key: "commands", label: "Commands" },
{ key: "hooks", label: "Hooks" },
{ key: "skills", label: "Skills" },
{ key: "tools", label: "Tools" },
{ key: "gateway", label: "Gateway" },
{ 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<string, unknown> | null,
current: Record<string, unknown> | null,
): Array<{ path: string; from: unknown; to: unknown }> {
if (!original || !current) {
return [];
}
const changes: Array<{ path: string; from: unknown; to: unknown }> = [];
function compare(orig: unknown, curr: unknown, path: string) {
if (orig === curr) {
return;
}
if (typeof orig !== typeof curr) {
changes.push({ path, from: orig, to: curr });
return;
}
if (typeof orig !== "object" || orig === null || curr === null) {
if (orig !== curr) {
changes.push({ path, from: orig, to: curr });
}
return;
}
if (Array.isArray(orig) && Array.isArray(curr)) {
if (JSON.stringify(orig) !== JSON.stringify(curr)) {
changes.push({ path, from: orig, to: curr });
}
return;
}
const origObj = orig as Record<string, unknown>;
const currObj = curr as Record<string, unknown>;
const allKeys = new Set([...Object.keys(origObj), ...Object.keys(currObj)]);
for (const key of allKeys) {
compare(origObj[key], currObj[key], path ? `${path}.${key}` : key);
}
}
compare(original, current, "");
return changes;
}
function truncateValue(value: unknown, maxLen = 40): string {
let str: string;
try {
const json = JSON.stringify(value);
str = json ?? String(value);
} catch {
str = String(value);
}
if (str.length <= maxLen) {
return str;
}
return str.slice(0, maxLen - 3) + "...";
}
export function renderConfig(props: ConfigProps) {
const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const analysis = analyzeConfigSchema(props.schema);
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
// Get available sections from schema
const schemaProps = analysis.schema?.properties ?? {};
const availableSections = SECTIONS.filter((s) => s.key in schemaProps);
// Add any sections in schema but not in our list
const knownKeys = new Set(SECTIONS.map((s) => s.key));
const extraSections = Object.keys(schemaProps)
.filter((k) => !knownKeys.has(k))
.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]
: 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 (works for both form and raw modes)
const diff = props.formMode === "form" ? computeDiff(props.originalValue, props.formValue) : [];
const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw;
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
// Save/apply buttons require actual changes to be enabled.
// Note: formUnsafe warns about unsupported schema paths but shouldn't block saving.
const canSaveForm = Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
const canSave =
props.connected &&
!props.saving &&
hasChanges &&
(props.formMode === "raw" ? true : canSaveForm);
const canApply =
props.connected &&
!props.applying &&
!props.updating &&
hasChanges &&
(props.formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
const selectedTags = new Set(getTagFilters(props.searchQuery));
return html`
<div class="config-layout">
<!-- Sidebar -->
<aside class="config-sidebar">
<div class="config-sidebar__header">
<div class="config-sidebar__title">Settings</div>
<span
class="pill pill--sm ${
validity === "valid" ? "pill--ok" : validity === "invalid" ? "pill--danger" : ""
}"
>${validity}</span
>
</div>
<!-- Search -->
<div class="config-search">
<div class="config-search__input-row">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${
props.searchQuery
? html`
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing
}
</div>
<div class="config-search__hint">
<span class="config-search__hint-label" id="config-tag-filter-label">Tag filters:</span>
<details class="config-search__tag-picker">
<summary class="config-search__tag-trigger" aria-labelledby="config-tag-filter-label">
${
selectedTags.size === 0
? html`
<span class="config-search__tag-placeholder">Add tags</span>
`
: html`
<div class="config-search__tag-chips">
${Array.from(selectedTags)
.slice(0, 2)
.map(
(tag) =>
html`<span class="config-search__tag-chip">tag:${tag}</span>`,
)}
${
selectedTags.size > 2
? html`
<span class="config-search__tag-chip config-search__tag-chip--count"
>+${selectedTags.size - 2}</span
>
`
: nothing
}
</div>
`
}
<span class="config-search__tag-caret" aria-hidden="true">▾</span>
</summary>
<div class="config-search__tag-menu">
${TAG_SEARCH_PRESETS.map((tag) => {
const active = selectedTags.has(tag);
return html`
<button
type="button"
class="config-search__tag-option ${active ? "active" : ""}"
data-tag="${tag}"
aria-pressed=${active ? "true" : "false"}
@click=${() => {
const nextTags = active
? Array.from(selectedTags).filter((value) => value !== tag)
: [...selectedTags, tag];
props.onSearchChange(replaceTagFilters(props.searchQuery, nextTags));
}}
>
tag:${tag}
</button>
`;
})}
</div>
</details>
</div>
</div>
<!-- Section nav -->
<nav class="config-nav">
<button
class="config-nav__item ${props.activeSection === null ? "active" : ""}"
@click=${() => props.onSectionChange(null)}
>
<span class="config-nav__icon">${sidebarIcons.all}</span>
<span class="config-nav__label">All Settings</span>
</button>
${allSections.map(
(section) => html`
<button
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
@click=${() => props.onSectionChange(section.key)}
>
<span class="config-nav__icon"
>${getSectionIcon(section.key)}</span
>
<span class="config-nav__label">${section.label}</span>
</button>
`,
)}
</nav>
<!-- Mode toggle at bottom -->
<div class="config-sidebar__footer">
<div class="config-mode-toggle">
<button
class="config-mode-toggle__btn ${props.formMode === "form" ? "active" : ""}"
?disabled=${props.schemaLoading || !props.schema}
@click=${() => props.onFormModeChange("form")}
>
Form
</button>
<button
class="config-mode-toggle__btn ${props.formMode === "raw" ? "active" : ""}"
@click=${() => props.onFormModeChange("raw")}
>
Raw
</button>
</div>
</div>
</aside>
<!-- Main content -->
<main class="config-main">
<!-- Action bar -->
<div class="config-actions">
<div class="config-actions__left">
${
hasChanges
? html`
<span class="config-changes-badge"
>${
props.formMode === "raw"
? "Unsaved changes"
: `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`
}</span
>
`
: html`
<span class="config-status muted">No changes</span>
`
}
</div>
<div class="config-actions__right">
<button
class="btn btn--sm"
?disabled=${props.loading}
@click=${props.onReload}
>
${props.loading ? "Loading…" : "Reload"}
</button>
<button
class="btn btn--sm primary"
?disabled=${!canSave}
@click=${props.onSave}
>
${props.saving ? "Saving…" : "Save"}
</button>
<button
class="btn btn--sm"
?disabled=${!canApply}
@click=${props.onApply}
>
${props.applying ? "Applying…" : "Apply"}
</button>
<button
class="btn btn--sm"
?disabled=${!canUpdate}
@click=${props.onUpdate}
>
${props.updating ? "Updating…" : "Update"}
</button>
</div>
</div>
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
${
hasChanges && props.formMode === "form"
? html`
<details class="config-diff">
<summary class="config-diff__summary">
<span
>View ${diff.length} pending
change${diff.length !== 1 ? "s" : ""}</span
>
<svg
class="config-diff__chevron"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</summary>
<div class="config-diff__content">
${diff.map(
(change) => html`
<div class="config-diff__item">
<div class="config-diff__path">${change.path}</div>
<div class="config-diff__values">
<span class="config-diff__from"
>${truncateValue(change.from)}</span
>
<span class="config-diff__arrow">→</span>
<span class="config-diff__to"
>${truncateValue(change.to)}</span
>
</div>
</div>
`,
)}
</div>
</details>
`
: nothing
}
${
activeSectionMeta && props.formMode === "form"
? html`
<div class="config-section-hero">
<div class="config-section-hero__icon">
${getSectionIcon(props.activeSection ?? "")}
</div>
<div class="config-section-hero__text">
<div class="config-section-hero__title">
${activeSectionMeta.label}
</div>
${
activeSectionMeta.description
? html`<div class="config-section-hero__desc">
${activeSectionMeta.description}
</div>`
: nothing
}
</div>
</div>
`
: nothing
}
${
allowSubnav
? html`
<div class="config-subnav">
<button
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
@click=${() => props.onSubsectionChange(ALL_SUBSECTION)}
>
All
</button>
${subsections.map(
(entry) => html`
<button
class="config-subnav__item ${
effectiveSubsection === entry.key ? "active" : ""
}"
title=${entry.description || entry.label}
@click=${() => props.onSubsectionChange(entry.key)}
>
${entry.label}
</button>
`,
)}
</div>
`
: nothing
}
<!-- Form content -->
<div class="config-content">
${
props.formMode === "form"
? html`
${
props.schemaLoading
? html`
<div class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema…</span>
</div>
`
: renderConfigForm({
schema: analysis.schema,
uiHints: props.uiHints,
value: props.formValue,
disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths,
onPatch: props.onFormPatch,
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
})
}
${
formUnsafe
? html`
<div class="callout danger" style="margin-top: 12px">
Form view can't safely edit some fields. Use Raw to avoid losing config entries.
</div>
`
: nothing
}
`
: html`
<label class="field config-raw-field">
<span>Raw JSON5</span>
<textarea
.value=${props.raw}
@input=${(e: Event) =>
props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
`
}
</div>
${
props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;">
<pre class="code-block">
${JSON.stringify(props.issues, null, 2)}</pre
>
</div>`
: nothing
}
</main>
</div>
`;
}