feat(ui): improve config page with collapsible sections

- Group config settings into logical sections (Core, Agents, Communication, etc.)
- Add collapsible accordion UI for each section group
- Add icons and labels for each config category
- Improve mobile responsiveness with better button layout
- Style improvements for nested fieldsets and arrays
This commit is contained in:
Maude Bot
2026-01-20 10:56:44 -05:00
parent c6812c6af4
commit 716546824f
3 changed files with 375 additions and 38 deletions

View File

@@ -1274,3 +1274,253 @@
flex-wrap: wrap;
gap: 10px;
}
/* Config Form Sections */
.config-form--sectioned {
display: grid;
gap: 12px;
}
.config-section {
border: 1px solid var(--border);
border-radius: 14px;
background: linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 70%),
rgba(0, 0, 0, 0.15);
overflow: hidden;
}
:root[data-theme="light"] .config-section {
background: linear-gradient(160deg, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.5) 70%);
}
.config-section__header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid transparent;
transition: background 150ms ease, border-color 150ms ease;
}
.config-section__header:hover {
background: rgba(255, 255, 255, 0.04);
}
:root[data-theme="light"] .config-section__header:hover {
background: rgba(0, 0, 0, 0.03);
}
.config-section[open] .config-section__header {
border-bottom-color: var(--border);
background: rgba(255, 255, 255, 0.02);
}
:root[data-theme="light"] .config-section[open] .config-section__header {
background: rgba(0, 0, 0, 0.02);
}
.config-section__title {
font-family: var(--font-display);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
flex: 1;
}
.config-section__count {
font-size: 11px;
color: var(--muted);
padding: 3px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border);
}
:root[data-theme="light"] .config-section__count {
background: rgba(0, 0, 0, 0.05);
}
.config-section__chevron {
width: 18px;
height: 18px;
color: var(--muted);
transition: transform 200ms ease;
flex-shrink: 0;
}
.config-section[open] .config-section__chevron {
transform: rotate(180deg);
}
.config-section__content {
padding: 12px 16px 16px;
display: grid;
gap: 16px;
}
.config-field-group {
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(0, 0, 0, 0.12);
overflow: hidden;
}
:root[data-theme="light"] .config-field-group {
background: rgba(255, 255, 255, 0.6);
}
.config-field-group__header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--border);
}
:root[data-theme="light"] .config-field-group__header {
background: rgba(0, 0, 0, 0.03);
}
.config-field-group__icon {
font-size: 16px;
line-height: 1;
}
.config-field-group__label {
font-weight: 600;
font-size: 13px;
letter-spacing: 0.3px;
}
.config-field-group__content {
padding: 14px;
}
.config-field-group__content .fieldset {
border: none;
padding: 0;
margin: 0;
}
.config-field-group__content .fieldset > .legend {
display: none;
}
/* Config form field improvements */
.config-form .fieldset {
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
margin-top: 8px;
background: rgba(0, 0, 0, 0.08);
}
:root[data-theme="light"] .config-form .fieldset {
background: rgba(255, 255, 255, 0.5);
}
.config-form .fieldset .legend {
font-weight: 600;
font-size: 12px;
letter-spacing: 0.3px;
color: var(--text);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px dashed var(--border);
}
.config-form .fieldset .fieldset {
margin-top: 12px;
}
.config-form .array {
display: grid;
gap: 8px;
}
.config-form .array-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(0, 0, 0, 0.1);
}
:root[data-theme="light"] .config-form .array-item {
background: rgba(255, 255, 255, 0.6);
}
/* Mobile responsiveness */
@media (max-width: 640px) {
.config-section__header {
padding: 12px 14px;
}
.config-section__content {
padding: 10px 12px 14px;
}
.config-field-group__content {
padding: 12px;
}
.config-form .array-item {
flex-direction: column;
}
.config-form .array-item > div:first-child {
width: 100%;
}
.config-form .array-item .btn {
align-self: flex-end;
}
}
/* Config Header Layout */
.config-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.config-header__top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.config-header__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
@media (max-width: 640px) {
.config-header__top {
flex-direction: column;
align-items: stretch;
}
.config-header__top .row {
justify-content: space-between;
}
.config-header__actions {
justify-content: flex-end;
}
.config-header__actions .btn {
flex: 1;
min-width: 70px;
text-align: center;
}
}

View File

@@ -1,4 +1,4 @@
import { html } from "lit";
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { hintForPath, schemaType, type JsonSchema } from "./config-form.shared";
import { renderNode } from "./config-form.node";
@@ -12,6 +12,43 @@ export type ConfigFormProps = {
onPatch: (path: Array<string | number>, value: unknown) => void;
};
// Define logical section groupings
const SECTION_CONFIG: Record<string, { label: string; icon: string; order: number }> = {
// Core
env: { label: "Environment", icon: "🔧", order: 0 },
update: { label: "Updates", icon: "📦", order: 1 },
// Identity & Agents
agents: { label: "Agents", icon: "🤖", order: 10 },
auth: { label: "Authentication", icon: "🔐", order: 11 },
// Communication
channels: { label: "Channels", icon: "💬", order: 20 },
messages: { label: "Messages", icon: "📨", order: 21 },
// Automation
commands: { label: "Commands", icon: "⌨️", order: 30 },
hooks: { label: "Hooks", icon: "🪝", order: 31 },
skills: { label: "Skills", icon: "✨", order: 32 },
// Tools & Gateway
tools: { label: "Tools", icon: "🛠️", order: 40 },
gateway: { label: "Gateway", icon: "🌐", order: 41 },
// System
wizard: { label: "Setup Wizard", icon: "🧙", order: 50 },
};
// Logical groupings for the accordion layout
const SECTION_GROUPS: Array<{ title: string; keys: string[] }> = [
{ title: "Core Settings", keys: ["env", "update"] },
{ title: "Identity & Agents", keys: ["agents", "auth"] },
{ title: "Communication", keys: ["channels", "messages"] },
{ title: "Automation", keys: ["commands", "hooks", "skills"] },
{ title: "Tools & Gateway", keys: ["tools", "gateway"] },
{ title: "System", keys: ["wizard"] },
];
export function renderConfigForm(props: ConfigFormProps) {
if (!props.schema) {
return html`<div class="muted">Schema unavailable.</div>`;
@@ -22,28 +59,77 @@ export function renderConfigForm(props: ConfigFormProps) {
return html`<div class="callout danger">Unsupported schema. Use Raw.</div>`;
}
const unsupported = new Set(props.unsupportedPaths ?? []);
const entries = Object.entries(schema.properties);
const sorted = entries.sort((a, b) => {
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0;
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a[0].localeCompare(b[0]);
});
const properties = schema.properties;
const allKeys = new Set(Object.keys(properties));
// Collect any keys not in our defined groups
const groupedKeys = new Set(SECTION_GROUPS.flatMap(g => g.keys));
const ungroupedKeys = [...allKeys].filter(k => !groupedKeys.has(k));
// Build the groups with their entries
const groups = SECTION_GROUPS.map(group => {
const entries = group.keys
.filter(key => allKeys.has(key))
.map(key => ({ key, node: properties[key] }));
return { ...group, entries };
}).filter(group => group.entries.length > 0);
// Add ungrouped keys as "Other" if any exist
if (ungroupedKeys.length > 0) {
const sortedUngrouped = ungroupedKeys.sort((a, b) => {
const orderA = hintForPath([a], props.uiHints)?.order ?? 100;
const orderB = hintForPath([b], props.uiHints)?.order ?? 100;
if (orderA !== orderB) return orderA - orderB;
return a.localeCompare(b);
});
groups.push({
title: "Other",
keys: sortedUngrouped,
entries: sortedUngrouped.map(key => ({ key, node: properties[key] })),
});
}
return html`
<div class="config-form">
${sorted.map(([key, node]) =>
renderNode({
schema: node,
value: (value as Record<string, unknown>)[key],
path: [key],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
onPatch: props.onPatch,
}),
)}
<div class="config-form config-form--sectioned">
${groups.map((group, groupIndex) => html`
<details class="config-section" ?open=${groupIndex === 0}>
<summary class="config-section__header">
<span class="config-section__title">${group.title}</span>
<span class="config-section__count">${group.entries.length} ${group.entries.length === 1 ? 'setting' : 'settings'}</span>
<svg class="config-section__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-section__content">
${group.entries.map(({ key, node }) => {
const sectionInfo = SECTION_CONFIG[key];
const icon = sectionInfo?.icon ?? "📄";
const label = sectionInfo?.label ?? key;
return html`
<div class="config-field-group">
<div class="config-field-group__header">
<span class="config-field-group__icon">${icon}</span>
<span class="config-field-group__label">${label}</span>
</div>
<div class="config-field-group__content">
${renderNode({
schema: node,
value: (value as Record<string, unknown>)[key],
path: [key],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
</div>
`;
})}
</div>
</details>
`)}
</div>
`;
}

View File

@@ -47,58 +47,59 @@ export function renderConfig(props: ConfigProps) {
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div class="row">
<div class="card-title">Config</div>
<span class="pill">${validity}</span>
</div>
<div class="row">
<div class="config-header">
<div class="config-header__top">
<div class="row">
<div class="card-title">Config</div>
<span class="pill">${validity}</span>
</div>
<div class="toggle-group">
<button
class="btn ${props.formMode === "form" ? "primary" : ""}"
class="btn btn--sm ${props.formMode === "form" ? "primary" : ""}"
?disabled=${props.schemaLoading || !props.schema}
@click=${() => props.onFormModeChange("form")}
>
Form
</button>
<button
class="btn ${props.formMode === "raw" ? "primary" : ""}"
class="btn btn--sm ${props.formMode === "raw" ? "primary" : ""}"
@click=${() => props.onFormModeChange("raw")}
>
Raw
</button>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onReload}>
</div>
<div class="config-header__actions">
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? "Loading…" : "Reload"}
</button>
<button
class="btn primary"
class="btn btn--sm primary"
?disabled=${!canSave}
@click=${props.onSave}
>
${props.saving ? "Saving…" : "Save"}
</button>
<button
class="btn"
class="btn btn--sm"
?disabled=${!canApply}
@click=${props.onApply}
>
${props.applying ? "Applying…" : "Apply & Restart"}
${props.applying ? "Applying…" : "Apply"}
</button>
<button
class="btn"
class="btn btn--sm"
?disabled=${!canUpdate}
@click=${props.onUpdate}
>
${props.updating ? "Updating…" : "Update & Restart"}
${props.updating ? "Updating…" : "Update"}
</button>
</div>
</div>
<div class="muted" style="margin-top: 10px;">
<div class="muted" style="margin-top: 10px; font-size: 12px;">
Writes to <span class="mono">~/.clawdbot/clawdbot.json</span>. Apply &
Update restart the gateway and will ping the last active session when it
comes back.
Update restart the gateway.
</div>