From 929d50b7d155c8ee96be91945beaa02a666a90b8 Mon Sep 17 00:00:00 2001 From: Maude Bot Date: Tue, 20 Jan 2026 11:28:41 -0500 Subject: [PATCH] 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 --- ui/src/styles.css | 1 + ui/src/styles/config.css | 708 ++++++++++++++++++++++++++ ui/src/ui/app-render.ts | 5 + ui/src/ui/app.ts | 3 + ui/src/ui/controllers/config.ts | 4 + ui/src/ui/views/config-form.node.ts | 36 +- ui/src/ui/views/config-form.render.ts | 260 ++++++---- ui/src/ui/views/config.ts | 318 +++++++++--- 8 files changed, 1145 insertions(+), 190 deletions(-) create mode 100644 ui/src/styles/config.css diff --git a/ui/src/styles.css b/ui/src/styles.css index f3740b0c0..16b327f3a 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -2,3 +2,4 @@ @import "./styles/layout.css"; @import "./styles/layout.mobile.css"; @import "./styles/components.css"; +@import "./styles/config.css"; diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css new file mode 100644 index 000000000..aff3e88b4 --- /dev/null +++ b/ui/src/styles/config.css @@ -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; + } +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 2be9d1a16..63d44300a 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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), diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 82ce65d06..63c2ed13d 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -139,8 +139,11 @@ export class ClawdbotApp extends LitElement { @state() configSchemaLoading = false; @state() configUiHints: ConfigUiHints = {}; @state() configForm: Record | null = null; + @state() configFormOriginal: Record | 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; diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index ab6668318..38dc3b0fd 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -28,8 +28,11 @@ export type ConfigState = { configSchemaLoading: boolean; configUiHints: ConfigUiHints; configForm: Record | null; + configFormOriginal: Record | 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 ?? {}); } } diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index 8550d8125..76a6e6195 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -34,9 +34,10 @@ export function renderNode(params: { unsupported: Set; disabled: boolean; showLabel?: boolean; + searchQuery?: string; onPatch: (path: Array, 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` - +
+
+ ${showLabel ? html`${label}` : nothing} + ${help ? html`${help}` : nothing} +
+ +
`; } diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index bcb7e53bf..21c13c96c 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -9,45 +9,104 @@ export type ConfigFormProps = { value: Record | null; disabled?: boolean; unsupportedPaths?: string[]; + searchQuery?: string; + activeSection?: string | null; onPatch: (path: Array, value: unknown) => void; }; -// Define logical section groupings -const SECTION_CONFIG: Record = { - // 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 = { + 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` +
+
🔍
+
+ ${searchQuery + ? `No settings match "${searchQuery}"` + : "No settings in this section"} +
+
+ `; } return html` -
- ${groups.map((group, groupIndex) => html` -
- - ${group.title} - ${group.entries.length} ${group.entries.length === 1 ? 'setting' : 'settings'} - - - - -
- ${group.entries.map(({ key, node }) => { - const sectionInfo = SECTION_CONFIG[key]; - const icon = sectionInfo?.icon ?? "📄"; - const label = sectionInfo?.label ?? key; - - return html` -
-
- ${icon} - ${label} -
-
- ${renderNode({ - schema: node, - value: (value as Record)[key], - path: [key], - 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), + icon: "📄", + description: node.description ?? "" + }; + + return html` +
+
+ ${meta.icon} +
+

${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, + searchQuery, + })} +
+
+ `; + })}
`; } diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index a87c01b91..6fa9410e6 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -16,15 +16,79 @@ export type ConfigProps = { uiHints: ConfigUiHints; formMode: "form" | "raw"; formValue: Record | null; + originalValue: Record | null; + searchQuery: string; + activeSection: 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; 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 | null, + current: Record | 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; + const currObj = curr as Record; + 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` -
-
-
-
-
Config
- ${validity} -
-
+
+ + + + +
+ +
+
+ ${hasChanges ? html` + ${diff.length} unsaved change${diff.length !== 1 ? "s" : ""} + ` : html` + No changes + `} +
+
+ + + + +
-
+ + + ${hasChanges ? html` +
+ + View ${diff.length} pending change${diff.length !== 1 ? "s" : ""} + + + + +
+ ${diff.map(change => html` +
+
${change.path}
+
+ ${truncateValue(change.from)} + + ${truncateValue(change.to)} +
+
+ `)} +
+
+ ` : nothing} -
- Writes to ~/.clawdbot/clawdbot.json. Apply & - Update restart the gateway. -
+ +
+ ${props.formMode === "form" + ? html` + ${props.schemaLoading + ? html`
+
+ Loading schema… +
` + : 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`
+ Form view can't safely edit some fields. + Use Raw to avoid losing config entries. +
` + : nothing} + ` + : html` + + `} +
- - ${props.formMode === "form" - ? html`
- ${props.schemaLoading - ? html`
Loading schema…
` - : renderConfigForm({ - schema: analysis.schema, - uiHints: props.uiHints, - value: props.formValue, - disabled: props.loading || !props.formValue, - unsupportedPaths: analysis.unsupportedPaths, - onPatch: props.onFormPatch, - })} - ${formUnsafe - ? html`
- Form view can’t safely edit some fields. - Use Raw to avoid losing config entries. -
` - : nothing} -
` - : html``} - - ${props.issues.length > 0 - ? html`
-
${JSON.stringify(props.issues, null, 2)}
-
` - : nothing} -
+ ${props.issues.length > 0 + ? html`
+
${JSON.stringify(props.issues, null, 2)}
+
` + : nothing} + +
`; }