feat(ui): complete config page overhaul with sidebar nav, search, toggles, and diff view

Major redesign of the config page:

Layout:
- Sidebar navigation with section list
- Search input to filter settings
- Section cards with icons and descriptions
- Responsive design for mobile (stacked layout)

Fields:
- New toggle switches for booleans (replaces checkboxes)
- Improved field-row layout with label, help text, and control
- Better fieldset and array styling

Features:
- Diff view showing pending changes before save
- Original value tracking for comparison
- Section filtering via sidebar nav
- Search across setting names, descriptions, and nested properties

Styling:
- Dedicated config.css with all new styles
- Dark and light theme support
- Smooth animations and transitions
- Mobile-first responsive breakpoints
This commit is contained in:
Maude Bot
2026-01-20 11:28:41 -05:00
parent 716546824f
commit 929d50b7d1
8 changed files with 1145 additions and 190 deletions

View File

@@ -2,3 +2,4 @@
@import "./styles/layout.css";
@import "./styles/layout.mobile.css";
@import "./styles/components.css";
@import "./styles/config.css";

708
ui/src/styles/config.css Normal file
View File

@@ -0,0 +1,708 @@
/* ===========================================
Config Page - Modern Layout
=========================================== */
/* Layout Container */
.config-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 0;
min-height: calc(100vh - 140px);
margin: -16px;
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--panel);
}
/* Sidebar */
.config-sidebar {
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.25));
border-right: 1px solid var(--border);
}
:root[data-theme="light"] .config-sidebar {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.6));
}
.config-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border);
}
.config-sidebar__title {
font-family: var(--font-display);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.config-sidebar__footer {
margin-top: auto;
padding: 12px;
border-top: 1px solid var(--border);
}
/* Search */
.config-search {
position: relative;
padding: 12px;
}
.config-search__icon {
position: absolute;
left: 22px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--muted);
pointer-events: none;
}
.config-search__input {
width: 100%;
padding: 10px 12px 10px 38px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
font-size: 13px;
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.config-search__input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--focus);
}
:root[data-theme="light"] .config-search__input {
background: rgba(255, 255, 255, 0.9);
}
.config-search__clear {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: var(--muted);
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 150ms ease, color 150ms ease;
}
.config-search__clear:hover {
background: rgba(255, 255, 255, 0.2);
color: var(--text);
}
/* Navigation */
.config-nav {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.config-nav__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--text);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 150ms ease, transform 100ms ease;
}
.config-nav__item:hover {
background: rgba(255, 255, 255, 0.06);
}
:root[data-theme="light"] .config-nav__item:hover {
background: rgba(0, 0, 0, 0.05);
}
.config-nav__item.active {
background: rgba(245, 159, 74, 0.15);
color: var(--accent);
}
.config-nav__item:active {
transform: scale(0.98);
}
.config-nav__icon {
font-size: 16px;
width: 24px;
text-align: center;
}
.config-nav__label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Mode Toggle */
.config-mode-toggle {
display: flex;
gap: 4px;
padding: 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
}
:root[data-theme="light"] .config-mode-toggle {
background: rgba(0, 0, 0, 0.08);
}
.config-mode-toggle__btn {
flex: 1;
padding: 8px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.config-mode-toggle__btn:hover {
color: var(--text);
}
.config-mode-toggle__btn.active {
background: rgba(255, 255, 255, 0.1);
color: var(--text);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
:root[data-theme="light"] .config-mode-toggle__btn.active {
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Main Content */
.config-main {
display: flex;
flex-direction: column;
min-width: 0;
background: var(--panel);
}
/* Actions Bar */
.config-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--border);
}
:root[data-theme="light"] .config-actions {
background: rgba(0, 0, 0, 0.03);
}
.config-actions__left,
.config-actions__right {
display: flex;
align-items: center;
gap: 8px;
}
.config-changes-badge {
padding: 4px 10px;
border-radius: 999px;
background: rgba(245, 159, 74, 0.2);
border: 1px solid rgba(245, 159, 74, 0.4);
color: var(--accent);
font-size: 12px;
font-weight: 500;
}
.config-status {
font-size: 12px;
}
/* Diff Panel */
.config-diff {
margin: 12px 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(0, 0, 0, 0.15);
overflow: hidden;
}
:root[data-theme="light"] .config-diff {
background: rgba(245, 159, 74, 0.05);
}
.config-diff__summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--accent);
list-style: none;
}
.config-diff__summary::-webkit-details-marker {
display: none;
}
.config-diff__chevron {
width: 16px;
height: 16px;
transition: transform 200ms ease;
}
.config-diff[open] .config-diff__chevron {
transform: rotate(180deg);
}
.config-diff__content {
padding: 0 14px 14px;
display: grid;
gap: 8px;
}
.config-diff__item {
padding: 8px 10px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.15);
font-size: 12px;
}
:root[data-theme="light"] .config-diff__item {
background: rgba(255, 255, 255, 0.8);
}
.config-diff__path {
font-family: var(--mono);
font-weight: 600;
margin-bottom: 4px;
color: var(--text);
}
.config-diff__values {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
color: var(--muted);
}
.config-diff__from {
color: var(--danger);
text-decoration: line-through;
opacity: 0.7;
}
.config-diff__arrow {
color: var(--muted);
}
.config-diff__to {
color: var(--ok);
}
/* Content Area */
.config-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.config-raw-field textarea {
min-height: 400px;
font-family: var(--mono);
font-size: 13px;
}
/* Loading State */
.config-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 60px 20px;
color: var(--muted);
}
.config-loading__spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.config-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 60px 20px;
text-align: center;
}
.config-empty__icon {
font-size: 48px;
opacity: 0.5;
}
.config-empty__text {
color: var(--muted);
font-size: 14px;
}
/* Section Cards */
.config-form--modern {
display: grid;
gap: 16px;
}
.config-section-card {
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.12);
overflow: hidden;
}
:root[data-theme="light"] .config-section-card {
background: linear-gradient(160deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.7) 70%);
}
.config-section-card__header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid var(--border);
}
:root[data-theme="light"] .config-section-card__header {
background: rgba(0, 0, 0, 0.02);
}
.config-section-card__icon {
font-size: 24px;
line-height: 1;
padding-top: 2px;
}
.config-section-card__titles {
flex: 1;
min-width: 0;
}
.config-section-card__title {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.2px;
}
.config-section-card__desc {
margin: 4px 0 0;
font-size: 13px;
color: var(--muted);
line-height: 1.4;
}
.config-section-card__content {
padding: 16px;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-flex;
cursor: pointer;
}
.toggle-switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch__track {
position: relative;
width: 44px;
height: 24px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid var(--border);
border-radius: 999px;
transition: background 200ms ease, border-color 200ms ease;
}
:root[data-theme="light"] .toggle-switch__track {
background: rgba(0, 0, 0, 0.1);
}
.toggle-switch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: var(--text);
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: transform 200ms ease, background 200ms ease;
}
.toggle-switch input:checked + .toggle-switch__track {
background: rgba(43, 217, 127, 0.3);
border-color: rgba(43, 217, 127, 0.5);
}
.toggle-switch input:checked + .toggle-switch__track .toggle-switch__thumb {
transform: translateX(20px);
background: var(--ok);
}
.toggle-switch input:focus + .toggle-switch__track {
box-shadow: 0 0 0 3px var(--focus);
}
.toggle-switch input:disabled + .toggle-switch__track {
opacity: 0.5;
cursor: not-allowed;
}
/* Field Row (for toggles) */
.field-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
:root[data-theme="light"] .field-row {
border-bottom-color: rgba(0, 0, 0, 0.06);
}
.field-row:last-child {
border-bottom: none;
}
.field-row__info {
flex: 1;
min-width: 0;
}
.field-row__label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.field-row__help {
display: block;
margin-top: 2px;
font-size: 12px;
color: var(--muted);
line-height: 1.4;
}
/* Pills */
.pill--sm {
padding: 3px 8px;
font-size: 10px;
}
.pill--ok {
border-color: rgba(43, 217, 127, 0.5);
color: var(--ok);
}
.pill--danger {
border-color: rgba(255, 92, 92, 0.5);
color: var(--danger);
}
/* Improved Fieldset */
.config-form--modern .fieldset {
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
margin-top: 12px;
background: rgba(0, 0, 0, 0.08);
}
:root[data-theme="light"] .config-form--modern .fieldset {
background: rgba(255, 255, 255, 0.5);
}
.config-form--modern .fieldset > .legend {
font-weight: 600;
font-size: 13px;
color: var(--text);
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px dashed var(--border);
}
.config-form--modern .field {
margin-bottom: 12px;
}
.config-form--modern .field:last-child {
margin-bottom: 0;
}
.config-form--modern .field span {
font-size: 12px;
font-weight: 500;
color: var(--muted);
}
.config-form--modern .field input,
.config-form--modern .field select,
.config-form--modern .field textarea {
font-size: 14px;
}
/* Array improvements */
.config-form--modern .array {
display: grid;
gap: 10px;
}
.config-form--modern .array-item {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(0, 0, 0, 0.1);
}
:root[data-theme="light"] .config-form--modern .array-item {
background: rgba(255, 255, 255, 0.6);
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.config-layout {
grid-template-columns: 1fr;
min-height: auto;
}
.config-sidebar {
border-right: none;
border-bottom: 1px solid var(--border);
}
.config-nav {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px;
max-height: none;
overflow: visible;
}
.config-nav__item {
flex: 0 0 auto;
padding: 8px 12px;
}
.config-nav__icon {
width: auto;
}
.config-sidebar__footer {
display: none;
}
.config-actions {
flex-wrap: wrap;
}
.config-actions__left {
width: 100%;
justify-content: center;
}
.config-actions__right {
width: 100%;
justify-content: center;
}
.config-diff__values {
flex-wrap: wrap;
}
.field-row {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.config-form--modern .array-item {
flex-direction: column;
}
}
@media (max-width: 480px) {
.config-nav__label {
display: none;
}
.config-nav__item {
padding: 10px;
}
.config-section-card__header {
padding: 12px;
}
.config-section-card__content {
padding: 12px;
}
}

View File

@@ -472,9 +472,14 @@ export function renderApp(state: AppViewState) {
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
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),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),

View File

@@ -139,8 +139,11 @@ export class ClawdbotApp extends LitElement {
@state() configSchemaLoading = false;
@state() configUiHints: ConfigUiHints = {};
@state() configForm: Record<string, unknown> | null = null;
@state() configFormOriginal: Record<string, unknown> | null = null;
@state() configFormDirty = false;
@state() configFormMode: "form" | "raw" = "form";
@state() configSearchQuery = "";
@state() configActiveSection: string | null = null;
@state() channelsLoading = false;
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;

View File

@@ -28,8 +28,11 @@ export type ConfigState = {
configSchemaLoading: boolean;
configUiHints: ConfigUiHints;
configForm: Record<string, unknown> | null;
configFormOriginal: Record<string, unknown> | null;
configFormDirty: boolean;
configFormMode: "form" | "raw";
configSearchQuery: string;
configActiveSection: string | null;
lastError: string | null;
};
@@ -93,6 +96,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
if (!state.configFormDirty) {
state.configForm = cloneConfigObject(snapshot.config ?? {});
state.configFormOriginal = cloneConfigObject(snapshot.config ?? {});
}
}

View File

@@ -34,9 +34,10 @@ export function renderNode(params: {
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchQuery?: string;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult | typeof nothing {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const { schema, value, path, hints, unsupported, disabled, onPatch, searchQuery } = params;
const showLabel = params.showLabel ?? true;
const type = schemaType(schema);
const hint = hintForPath(path, hints);
@@ -303,21 +304,24 @@ export function renderNode(params: {
? schema.default
: false;
return html`
<label class="field checkbox">
<input
type="checkbox"
.checked=${displayValue}
?disabled=${disabled}
@change=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).checked)}
/>
${showLabel ? html`<span>${label}</span>` : nothing}
${help
? html`<div class="muted" style="grid-column: 1 / -1;">
${help}
</div>`
: nothing}
</label>
<div class="field-row">
<div class="field-row__info">
${showLabel ? html`<span class="field-row__label">${label}</span>` : nothing}
${help ? html`<span class="field-row__help">${help}</span>` : nothing}
</div>
<label class="toggle-switch">
<input
type="checkbox"
.checked=${displayValue}
?disabled=${disabled}
@change=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).checked)}
/>
<span class="toggle-switch__track">
<span class="toggle-switch__thumb"></span>
</span>
</label>
</div>
`;
}

View File

@@ -9,45 +9,104 @@ export type ConfigFormProps = {
value: Record<string, unknown> | null;
disabled?: boolean;
unsupportedPaths?: string[];
searchQuery?: string;
activeSection?: string | null;
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 },
// Section metadata
const SECTION_META: Record<string, { label: string; icon: string; description: string }> = {
env: {
label: "Environment Variables",
icon: "🔧",
description: "Environment variables passed to the gateway process"
},
update: {
label: "Updates",
icon: "📦",
description: "Auto-update settings and release channel"
},
agents: {
label: "Agents",
icon: "🤖",
description: "Agent configurations, models, and identities"
},
auth: {
label: "Authentication",
icon: "🔐",
description: "API keys and authentication profiles"
},
channels: {
label: "Channels",
icon: "💬",
description: "Messaging channels (Telegram, Discord, Slack, etc.)"
},
messages: {
label: "Messages",
icon: "📨",
description: "Message handling and routing settings"
},
commands: {
label: "Commands",
icon: "⌨️",
description: "Custom slash commands"
},
hooks: {
label: "Hooks",
icon: "🪝",
description: "Webhooks and event hooks"
},
skills: {
label: "Skills",
icon: "✨",
description: "Skill packs and capabilities"
},
tools: {
label: "Tools",
icon: "🛠️",
description: "Tool configurations (browser, search, etc.)"
},
gateway: {
label: "Gateway",
icon: "🌐",
description: "Gateway server settings (port, auth, binding)"
},
wizard: {
label: "Setup Wizard",
icon: "🧙",
description: "Setup wizard state and history"
},
};
// 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"] },
];
function matchesSearch(key: string, schema: JsonSchema, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
const meta = SECTION_META[key];
// Check key name
if (key.toLowerCase().includes(q)) return true;
// Check label and description
if (meta) {
if (meta.label.toLowerCase().includes(q)) return true;
if (meta.description.toLowerCase().includes(q)) return true;
}
// Check schema title/description
if (schema.title?.toLowerCase().includes(q)) return true;
if (schema.description?.toLowerCase().includes(q)) return true;
// Deep search in properties
if (schema.properties) {
for (const [propKey, propSchema] of Object.entries(schema.properties)) {
if (propKey.toLowerCase().includes(q)) return true;
if (propSchema.title?.toLowerCase().includes(q)) return true;
if (propSchema.description?.toLowerCase().includes(q)) return true;
}
}
return false;
}
export function renderConfigForm(props: ConfigFormProps) {
if (!props.schema) {
@@ -60,76 +119,79 @@ export function renderConfigForm(props: ConfigFormProps) {
}
const unsupported = new Set(props.unsupportedPaths ?? []);
const properties = schema.properties;
const allKeys = new Set(Object.keys(properties));
const searchQuery = props.searchQuery ?? "";
const activeSection = props.activeSection;
// 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));
// Filter and sort entries
let entries = Object.entries(properties);
// Filter by active section
if (activeSection) {
entries = entries.filter(([key]) => key === activeSection);
}
// Filter by search
if (searchQuery) {
entries = entries.filter(([key, node]) => matchesSearch(key, node, searchQuery));
}
// Sort by hint order, then alphabetically
entries.sort((a, b) => {
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 50;
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 50;
if (orderA !== orderB) return orderA - orderB;
return a[0].localeCompare(b[0]);
});
// 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] })),
});
if (entries.length === 0) {
return html`
<div class="config-empty">
<div class="config-empty__icon">🔍</div>
<div class="config-empty__text">
${searchQuery
? `No settings match "${searchQuery}"`
: "No settings in this section"}
</div>
</div>
`;
}
return html`
<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 class="config-form config-form--modern">
${entries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
icon: "📄",
description: node.description ?? ""
};
return html`
<section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header">
<span class="config-section-card__icon">${meta.icon}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3>
${meta.description ? html`
<p class="config-section-card__desc">${meta.description}</p>
` : nothing}
</div>
</div>
<div class="config-section-card__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,
searchQuery,
})}
</div>
</section>
`;
})}
</div>
`;
}

View File

@@ -16,15 +16,79 @@ export type ConfigProps = {
uiHints: ConfigUiHints;
formMode: "form" | "raw";
formValue: Record<string, unknown> | null;
originalValue: Record<string, unknown> | null;
searchQuery: string;
activeSection: 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;
onReload: () => void;
onSave: () => void;
onApply: () => void;
onUpdate: () => void;
};
// Section definitions with icons
const SECTIONS: Array<{ key: string; label: string; icon: string }> = [
{ key: "env", label: "Environment", icon: "🔧" },
{ key: "update", label: "Updates", icon: "📦" },
{ key: "agents", label: "Agents", icon: "🤖" },
{ key: "auth", label: "Authentication", icon: "🔐" },
{ key: "channels", label: "Channels", icon: "💬" },
{ key: "messages", label: "Messages", icon: "📨" },
{ key: "commands", label: "Commands", icon: "⌨️" },
{ key: "hooks", label: "Hooks", icon: "🪝" },
{ key: "skills", label: "Skills", icon: "✨" },
{ key: "tools", label: "Tools", icon: "🛠️" },
{ key: "gateway", label: "Gateway", icon: "🌐" },
{ key: "wizard", label: "Setup Wizard", icon: "🧙" },
];
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 {
const str = JSON.stringify(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";
@@ -45,97 +109,201 @@ export function renderConfig(props: ConfigProps) {
(props.formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
// 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), icon: "📄" }));
const allSections = [...availableSections, ...extraSections];
// Compute diff for showing changes
const diff = props.formMode === "form"
? computeDiff(props.originalValue, props.formValue)
: [];
const hasChanges = diff.length > 0;
return html`
<section class="card">
<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">
<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">
<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>
<!-- Section nav -->
<nav class="config-nav">
<button
class="config-nav__item ${props.activeSection === null ? "active" : ""}"
@click=${() => props.onSectionChange(null)}
>
<span class="config-nav__icon">📋</span>
<span class="config-nav__label">All Settings</span>
</button>
${allSections.map(section => html`
<button
class="btn btn--sm ${props.formMode === "form" ? "primary" : ""}"
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
@click=${() => props.onSectionChange(section.key)}
>
<span class="config-nav__icon">${section.icon}</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="btn btn--sm ${props.formMode === "raw" ? "primary" : ""}"
class="config-mode-toggle__btn ${props.formMode === "raw" ? "active" : ""}"
@click=${() => props.onFormModeChange("raw")}
>
Raw
</button>
</div>
</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 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>
</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">${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>
</div>
<!-- Diff panel -->
${hasChanges ? 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}
<div class="muted" style="margin-top: 10px; font-size: 12px;">
Writes to <span class="mono">~/.clawdbot/clawdbot.json</span>. Apply &
Update restart the gateway.
</div>
<!-- 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,
})}
${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.formMode === "form"
? html`<div style="margin-top: 12px;">
${props.schemaLoading
? html`<div class="muted">Loading schema…</div>`
: renderConfigForm({
schema: analysis.schema,
uiHints: props.uiHints,
value: props.formValue,
disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths,
onPatch: props.onFormPatch,
})}
${formUnsafe
? html`<div class="callout danger" style="margin-top: 12px;">
Form view cant safely edit some fields.
Use Raw to avoid losing config entries.
</div>`
: nothing}
</div>`
: html`<label class="field" style="margin-top: 12px;">
<span>Raw JSON5</span>
<textarea
.value=${props.raw}
@input=${(e: Event) =>
props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea>
</label>`}
${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}
</section>
${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>
`;
}