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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user