Files
openclaw/ui/src/ui/views/config.ts
Val Alexander f76a3c5225 feat(ui): dashboard-v2 views refactor (slice 3/3 of dashboard-v2) (#41503)
* feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)

New self-contained chat modules extracted from dashboard-v2-structure:

- chat/slash-commands.ts: slash command definitions and completions
- chat/slash-command-executor.ts: execute slash commands via gateway RPC
- chat/slash-command-executor.node.test.ts: test coverage
- chat/speech.ts: speech-to-text (STT) support
- chat/input-history.ts: per-session input history navigation
- chat/pinned-messages.ts: pinned message management
- chat/deleted-messages.ts: deleted message tracking
- chat/export.ts: shared exportChatMarkdown helper
- chat-export.ts: re-export shim for backwards compat

Gateway fix:
- Restore usage/cost stripping in chat.history sanitization
- Add test coverage for sanitization behavior

These modules are additive and tree-shaken — no existing code
imports them yet. They will be wired in subsequent slices.

* feat(ui): add utilities, theming, and i18n updates (slice 2 of dashboard-v2)

UI utilities and theming improvements extracted from dashboard-v2-structure:

Icons & formatting:
- icons.ts: expanded icon set for new dashboard views
- format.ts: date/number formatting helpers
- tool-labels.ts: human-readable tool name mappings

Theming:
- theme.ts: enhanced theme resolution and system theme support
- theme-transition.ts: simplified transition logic
- storage.ts: theme parsing improvements for settings persistence

Navigation & types:
- navigation.ts: extended tab definitions for dashboard-v2
- app-view-state.ts: expanded view state management
- types.ts: new type definitions (HealthSummary, ModelCatalogEntry, etc.)

Components:
- components/dashboard-header.ts: reusable header component

i18n:
- Updated en, pt-BR, zh-CN, zh-TW locales with new dashboard strings

All changes are additive or backwards-compatible. Build passes.
Part of #36853.

* feat(ui): dashboard-v2 views refactor (slice 3 of dashboard-v2)

Complete views refactor from dashboard-v2-structure, building on
slice 1 (chat infra, #41497) and slice 2 (utilities/theming, #41500).

Core app wiring:
- app.ts: updated host component with new state properties
- app-render.ts: refactored render pipeline for new dashboard layout
- app-render.helpers.ts: extracted render helpers
- app-settings.ts: theme listener lifecycle fix, cron runs on tab load
- app-gateway.ts: refactored chat event handling
- app-chat.ts: slash command integration

New views:
- views/command-palette.ts: command palette (Cmd+K)
- views/login-gate.ts: authentication gate
- views/bottom-tabs.ts: mobile tab navigation
- views/overview-*.ts: modular overview dashboard (cards, attention,
  event log, hints, log tail, quick actions)
- views/agents-panels-overview.ts: agent overview panel

Refactored views:
- views/chat.ts: major refactor with STT, slash commands, search,
  export, pinned messages, input history
- views/config.ts: restructured config management
- views/agents.ts: streamlined agent management
- views/overview.ts: modular composition from sub-views
- views/sessions.ts: enhanced session management

Controllers:
- controllers/health.ts: new health check controller
- controllers/models.ts: new model catalog controller
- controllers/agents.ts: tools catalog improvements
- controllers/config.ts: config form enhancements

Tests & infrastructure:
- Updated test helpers, browser tests, node tests
- vite.config.ts: build configuration updates
- markdown.ts: rendering improvements

Build passes  | 44 files | +6,626/-1,499
Part of #36853. Depends on #41497 and #41500.

* UI: fix chat review follow-ups

* fix(ui): repair chat clear and attachment regressions

* fix(ui): address remaining chat review comments

* fix(ui): address review follow-ups

* fix(ui): replay queued local slash commands

* fix(ui): repair control-ui type drift

* fix(ui): restore control UI styling

* feat(ui): enhance layout and styling for config and topbar components

- Updated grid layout for the config layout to allow full-width usage.
- Introduced new styles for top tabs and search components to improve usability.
- Added theme mode toggle styling for better visual integration.
- Implemented tests for layout and theme mode components to ensure proper rendering and functionality.

* feat(ui): add config file opening functionality and enhance styles

- Implemented a new handler to open the configuration file using the default application based on the operating system.
- Updated various CSS styles across components for improved visual consistency and usability, including adjustments to padding, margins, and font sizes.
- Introduced new styles for the data table and sidebar components to enhance layout and interaction.
- Added tests for the collapsed navigation rail to ensure proper functionality in different states.

* refactor(ui): update CSS styles for improved layout and consistency

- Simplified font-body declaration in base.css for cleaner code.
- Adjusted transition properties in components.css for better readability.
- Added new .workspace-link class in components.css for enhanced link styling.
- Changed config layout from grid to flex in config.css for better responsiveness.
- Updated related tests to reflect layout changes in config-layout.browser.test.ts.

* feat(ui): enhance theme handling and loading states in chat interface

- Updated CSS to support new theme mode attributes for better styling consistency across light and dark themes.
- Introduced loading skeletons in the chat view to improve user experience during data fetching.
- Refactored command palette to manage focus more effectively, enhancing accessibility.
- Added tests for the appearance theme picker and loading states to ensure proper rendering and functionality.

* refactor(ui): streamline ephemeral state management in chat and config views

- Introduced interfaces for ephemeral state in chat and config views to encapsulate related variables.
- Refactored state management to utilize a single object for better organization and maintainability.
- Removed legacy state variables and updated related functions to reference the new state structure.
- Enhanced readability and consistency across the codebase by standardizing state handling.

* chore: remove test files to reduce PR scope

* fix(ui): resolve type errors in debug props and chat search

* refactor(ui): remove stream mode functionality across various components

- Eliminated stream mode related translations and CSS styles to streamline the user interface.
- Updated multiple components to remove references to stream mode, enhancing code clarity and maintainability.
- Adjusted rendering logic in views to ensure consistent behavior without stream mode.
- Improved overall readability by cleaning up unused variables and props.

* fix(ui): add msg-meta CSS and fix rebase type errors

* fix(ui): add CSS for chat footer action buttons (TTS, delete) and msg-meta

* feat(ui): add delete confirmation with remember-decision checkbox

* fix(ui): delete confirmation with remember, attention icon sizing

* fix(ui): open delete confirm popover to the left (not clipped)

* fix(ui): show all nav items in collapsed sidebar, remove gap

* fix(ui): address P1/P2 review feedback — session queue clear, kill scope, palette guard, stop button

* fix(ui): address Greptile re-review — kill scope, queue flush, idle handling, parallel fetch

- SECURITY: /kill <target> now enforces session tree scope (not just /kill all)
- /kill reports idle sessions gracefully instead of throwing
- Queue continues draining after local slash commands
- /model fetches sessions.list + models.list in parallel (perf fix)

* fix(ui): style update banner close button — SVG stroke + sizing

* fix(ui): update layout styles for sidebar and content spacing

* UI: restore colon slash command parsing

* UI: restore slash command session queries

* Refactor thinking resolution: Introduce resolveThinkingDefaultForModel function and update model-selection to utilize it. Add tests for new functionality in thinking.test.ts.

* fix(ui): constrain welcome state logo size, add missing CSS for new session view

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-12 12:46:19 -05:00

1112 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { html, nothing, type TemplateResult } from "lit";
import { icons } from "../icons.ts";
import type { ThemeTransitionContext } from "../theme-transition.ts";
import type { ThemeMode, ThemeName } from "../theme.ts";
import type { ConfigUiHints } from "../types.ts";
import {
countSensitiveConfigValues,
humanize,
pathKey,
REDACTED_PLACEHOLDER,
schemaType,
type JsonSchema,
} from "./config-form.shared.ts";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
export type ConfigProps = {
raw: string;
originalRaw: string;
valid: boolean | null;
issues: unknown[];
loading: boolean;
saving: boolean;
applying: boolean;
updating: boolean;
connected: boolean;
schema: unknown;
schemaLoading: boolean;
uiHints: ConfigUiHints;
formMode: "form" | "raw";
showModeToggle?: boolean;
formValue: Record<string, unknown> | null;
originalValue: Record<string, unknown> | null;
searchQuery: string;
activeSection: string | null;
activeSubsection: string | null;
onRawChange: (next: string) => void;
onFormModeChange: (mode: "form" | "raw") => void;
onFormPatch: (path: Array<string | number>, value: unknown) => void;
onSearchChange: (query: string) => void;
onSectionChange: (section: string | null) => void;
onSubsectionChange: (section: string | null) => void;
onReload: () => void;
onSave: () => void;
onApply: () => void;
onUpdate: () => void;
onOpenFile?: () => void;
version: string;
theme: ThemeName;
themeMode: ThemeMode;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
gatewayUrl: string;
assistantName: string;
configPath?: string | null;
navRootLabel?: string;
includeSections?: string[];
excludeSections?: string[];
includeVirtualSections?: boolean;
};
// SVG Icons for sidebar (Lucide-style)
const sidebarIcons = {
all: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
`,
env: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
`,
update: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
`,
agents: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"
></path>
<circle cx="8" cy="14" r="1"></circle>
<circle cx="16" cy="14" r="1"></circle>
</svg>
`,
auth: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
`,
channels: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
`,
messages: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
`,
commands: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
`,
hooks: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
`,
skills: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
></polygon>
</svg>
`,
tools: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
`,
gateway: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
wizard: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 4V2"></path>
<path d="M15 16v-2"></path>
<path d="M8 9h2"></path>
<path d="M20 9h2"></path>
<path d="M17.8 11.8 19 13"></path>
<path d="M15 9h0"></path>
<path d="M17.8 6.2 19 5"></path>
<path d="m3 21 9-9"></path>
<path d="M12.2 6.2 11 5"></path>
</svg>
`,
// Additional sections
meta: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
`,
logging: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
`,
browser: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="4"></circle>
<line x1="21.17" y1="8" x2="12" y2="8"></line>
<line x1="3.95" y1="6.06" x2="8.54" y2="14"></line>
<line x1="10.88" y1="21.94" x2="15.46" y2="14"></line>
</svg>
`,
ui: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
`,
models: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
`,
bindings: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
`,
broadcast: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
<circle cx="12" cy="12" r="2"></circle>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path>
</svg>
`,
audio: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
`,
session: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
`,
cron: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
`,
web: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
discovery: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
`,
canvasHost: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
`,
talk: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
`,
plugins: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v6"></path>
<path d="m4.93 10.93 4.24 4.24"></path>
<path d="M2 12h6"></path>
<path d="m4.93 13.07 4.24-4.24"></path>
<path d="M12 22v-6"></path>
<path d="m19.07 13.07-4.24-4.24"></path>
<path d="M22 12h-6"></path>
<path d="m19.07 10.93-4.24 4.24"></path>
</svg>
`,
__appearance__: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
`,
default: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
`,
};
// Categorised section definitions
type SectionCategory = {
id: string;
label: string;
sections: Array<{ key: string; label: string }>;
};
const SECTION_CATEGORIES: SectionCategory[] = [
{
id: "core",
label: "Core",
sections: [
{ key: "env", label: "Environment" },
{ key: "auth", label: "Authentication" },
{ key: "update", label: "Updates" },
{ key: "meta", label: "Meta" },
{ key: "logging", label: "Logging" },
],
},
{
id: "ai",
label: "AI & Agents",
sections: [
{ key: "agents", label: "Agents" },
{ key: "models", label: "Models" },
{ key: "skills", label: "Skills" },
{ key: "tools", label: "Tools" },
{ key: "memory", label: "Memory" },
{ key: "session", label: "Session" },
],
},
{
id: "communication",
label: "Communication",
sections: [
{ key: "channels", label: "Channels" },
{ key: "messages", label: "Messages" },
{ key: "broadcast", label: "Broadcast" },
{ key: "talk", label: "Talk" },
{ key: "audio", label: "Audio" },
],
},
{
id: "automation",
label: "Automation",
sections: [
{ key: "commands", label: "Commands" },
{ key: "hooks", label: "Hooks" },
{ key: "bindings", label: "Bindings" },
{ key: "cron", label: "Cron" },
{ key: "approvals", label: "Approvals" },
{ key: "plugins", label: "Plugins" },
],
},
{
id: "infrastructure",
label: "Infrastructure",
sections: [
{ key: "gateway", label: "Gateway" },
{ key: "web", label: "Web" },
{ key: "browser", label: "Browser" },
{ key: "nodeHost", label: "NodeHost" },
{ key: "canvasHost", label: "CanvasHost" },
{ key: "discovery", label: "Discovery" },
{ key: "media", label: "Media" },
],
},
{
id: "appearance",
label: "Appearance",
sections: [
{ key: "__appearance__", label: "Appearance" },
{ key: "ui", label: "UI" },
{ key: "wizard", label: "Setup Wizard" },
],
},
];
// Flat lookup: all categorised keys
const CATEGORISED_KEYS = new Set(SECTION_CATEGORIES.flatMap((c) => c.sections.map((s) => s.key)));
function getSectionIcon(key: string) {
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
}
function scopeSchemaSections(
schema: JsonSchema | null,
params: { include?: ReadonlySet<string> | null; exclude?: ReadonlySet<string> | null },
): JsonSchema | null {
if (!schema || schemaType(schema) !== "object" || !schema.properties) {
return schema;
}
const include = params.include;
const exclude = params.exclude;
const nextProps: Record<string, JsonSchema> = {};
for (const [key, value] of Object.entries(schema.properties)) {
if (include && include.size > 0 && !include.has(key)) {
continue;
}
if (exclude && exclude.size > 0 && exclude.has(key)) {
continue;
}
nextProps[key] = value;
}
return { ...schema, properties: nextProps };
}
function scopeUnsupportedPaths(
unsupportedPaths: string[],
params: { include?: ReadonlySet<string> | null; exclude?: ReadonlySet<string> | null },
): string[] {
const include = params.include;
const exclude = params.exclude;
if ((!include || include.size === 0) && (!exclude || exclude.size === 0)) {
return unsupportedPaths;
}
return unsupportedPaths.filter((entry) => {
if (entry === "<root>") {
return true;
}
const [top] = entry.split(".");
if (include && include.size > 0) {
return include.has(top);
}
if (exclude && exclude.size > 0) {
return !exclude.has(top);
}
return true;
});
}
function resolveSectionMeta(
key: string,
schema?: JsonSchema,
): {
label: string;
description?: string;
} {
const meta = SECTION_META[key];
if (meta) {
return meta;
}
return {
label: schema?.title ?? humanize(key),
description: schema?.description ?? "",
};
}
function computeDiff(
original: Record<string, unknown> | null,
current: Record<string, unknown> | null,
): Array<{ path: string; from: unknown; to: unknown }> {
if (!original || !current) {
return [];
}
const changes: Array<{ path: string; from: unknown; to: unknown }> = [];
function compare(orig: unknown, curr: unknown, path: string) {
if (orig === curr) {
return;
}
if (typeof orig !== typeof curr) {
changes.push({ path, from: orig, to: curr });
return;
}
if (typeof orig !== "object" || orig === null || curr === null) {
if (orig !== curr) {
changes.push({ path, from: orig, to: curr });
}
return;
}
if (Array.isArray(orig) && Array.isArray(curr)) {
if (JSON.stringify(orig) !== JSON.stringify(curr)) {
changes.push({ path, from: orig, to: curr });
}
return;
}
const origObj = orig as Record<string, unknown>;
const currObj = curr as Record<string, unknown>;
const allKeys = new Set([...Object.keys(origObj), ...Object.keys(currObj)]);
for (const key of allKeys) {
compare(origObj[key], currObj[key], path ? `${path}.${key}` : key);
}
}
compare(original, current, "");
return changes;
}
function truncateValue(value: unknown, maxLen = 40): string {
let str: string;
try {
const json = JSON.stringify(value);
str = json ?? String(value);
} catch {
str = String(value);
}
if (str.length <= maxLen) {
return str;
}
return str.slice(0, maxLen - 3) + "...";
}
function renderDiffValue(path: string, value: unknown, _uiHints: ConfigUiHints): string {
return truncateValue(value);
}
type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult };
const THEME_OPTIONS: ThemeOption[] = [
{ id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap },
{ id: "knot", label: "Knot", description: "Knot family", icon: icons.link },
{ id: "dash", label: "Dash", description: "Field family", icon: icons.barChart },
];
function renderAppearanceSection(props: ConfigProps) {
const MODE_OPTIONS: Array<{
id: ThemeMode;
label: string;
description: string;
icon: TemplateResult;
}> = [
{ id: "system", label: "System", description: "Follow OS light or dark", icon: icons.monitor },
{ id: "light", label: "Light", description: "Force light mode", icon: icons.sun },
{ id: "dark", label: "Dark", description: "Force dark mode", icon: icons.moon },
];
return html`
<div class="settings-appearance">
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Theme</h3>
<p class="settings-appearance__hint">Choose a theme family.</p>
<div class="settings-theme-grid">
${THEME_OPTIONS.map(
(opt) => html`
<button
class="settings-theme-card ${opt.id === props.theme ? "settings-theme-card--active" : ""}"
title=${opt.description}
@click=${(e: Event) => {
if (opt.id !== props.theme) {
const context: ThemeTransitionContext = {
element: (e.currentTarget as HTMLElement) ?? undefined,
};
props.setTheme(opt.id, context);
}
}}
>
<span class="settings-theme-card__icon" aria-hidden="true">${opt.icon}</span>
<span class="settings-theme-card__label">${opt.label}</span>
${
opt.id === props.theme
? html`<span class="settings-theme-card__check" aria-hidden="true">${icons.check}</span>`
: nothing
}
</button>
`,
)}
</div>
</div>
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Mode</h3>
<p class="settings-appearance__hint">Choose light or dark mode for the selected theme.</p>
<div class="settings-theme-grid">
${MODE_OPTIONS.map(
(opt) => html`
<button
class="settings-theme-card ${opt.id === props.themeMode ? "settings-theme-card--active" : ""}"
title=${opt.description}
@click=${(e: Event) => {
if (opt.id !== props.themeMode) {
const context: ThemeTransitionContext = {
element: (e.currentTarget as HTMLElement) ?? undefined,
};
props.setThemeMode(opt.id, context);
}
}}
>
<span class="settings-theme-card__icon" aria-hidden="true">${opt.icon}</span>
<span class="settings-theme-card__label">${opt.label}</span>
${
opt.id === props.themeMode
? html`<span class="settings-theme-card__check" aria-hidden="true">${icons.check}</span>`
: nothing
}
</button>
`,
)}
</div>
</div>
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Connection</h3>
<div class="settings-info-grid">
<div class="settings-info-row">
<span class="settings-info-row__label">Gateway</span>
<span class="settings-info-row__value mono">${props.gatewayUrl || "-"}</span>
</div>
<div class="settings-info-row">
<span class="settings-info-row__label">Status</span>
<span class="settings-info-row__value">
<span class="settings-status-dot ${props.connected ? "settings-status-dot--ok" : ""}"></span>
${props.connected ? "Connected" : "Offline"}
</span>
</div>
${
props.assistantName
? html`
<div class="settings-info-row">
<span class="settings-info-row__label">Assistant</span>
<span class="settings-info-row__value">${props.assistantName}</span>
</div>
`
: nothing
}
</div>
</div>
</div>
`;
}
interface ConfigEphemeralState {
rawRevealed: boolean;
envRevealed: boolean;
validityDismissed: boolean;
revealedSensitivePaths: Set<string>;
}
function createConfigEphemeralState(): ConfigEphemeralState {
return {
rawRevealed: false,
envRevealed: false,
validityDismissed: false,
revealedSensitivePaths: new Set(),
};
}
const cvs = createConfigEphemeralState();
function isSensitivePathRevealed(path: Array<string | number>): boolean {
const key = pathKey(path);
return key ? cvs.revealedSensitivePaths.has(key) : false;
}
function toggleSensitivePathReveal(path: Array<string | number>) {
const key = pathKey(path);
if (!key) {
return;
}
if (cvs.revealedSensitivePaths.has(key)) {
cvs.revealedSensitivePaths.delete(key);
} else {
cvs.revealedSensitivePaths.add(key);
}
}
export function resetConfigViewStateForTests() {
Object.assign(cvs, createConfigEphemeralState());
}
export function renderConfig(props: ConfigProps) {
const showModeToggle = props.showModeToggle ?? false;
const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const includeVirtualSections = props.includeVirtualSections ?? true;
const include = props.includeSections?.length ? new Set(props.includeSections) : null;
const exclude = props.excludeSections?.length ? new Set(props.excludeSections) : null;
const rawAnalysis = analyzeConfigSchema(props.schema);
const analysis = {
schema: scopeSchemaSections(rawAnalysis.schema, { include, exclude }),
unsupportedPaths: scopeUnsupportedPaths(rawAnalysis.unsupportedPaths, { include, exclude }),
};
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
const formMode = showModeToggle ? props.formMode : "form";
const envSensitiveVisible = cvs.envRevealed;
// Build categorised nav from schema - only include sections that exist in the schema
const schemaProps = analysis.schema?.properties ?? {};
const VIRTUAL_SECTIONS = new Set(["__appearance__"]);
const visibleCategories = SECTION_CATEGORIES.map((cat) => ({
...cat,
sections: cat.sections.filter(
(s) => (includeVirtualSections && VIRTUAL_SECTIONS.has(s.key)) || s.key in schemaProps,
),
})).filter((cat) => cat.sections.length > 0);
// Catch any schema keys not in our categories
const extraSections = Object.keys(schemaProps)
.filter((k) => !CATEGORISED_KEYS.has(k))
.map((k) => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
const otherCategory: SectionCategory | null =
extraSections.length > 0 ? { id: "other", label: "Other", sections: extraSections } : null;
const isVirtualSection =
includeVirtualSections &&
props.activeSection != null &&
VIRTUAL_SECTIONS.has(props.activeSection);
const activeSectionSchema =
props.activeSection &&
!isVirtualSection &&
analysis.schema &&
schemaType(analysis.schema) === "object"
? analysis.schema.properties?.[props.activeSection]
: undefined;
const activeSectionMeta =
props.activeSection && !isVirtualSection
? resolveSectionMeta(props.activeSection, activeSectionSchema)
: null;
// Config subsections are always rendered as a single page per section.
const effectiveSubsection = null;
const topTabs = [
{ key: null as string | null, label: props.navRootLabel ?? "Settings" },
...[...visibleCategories, ...(otherCategory ? [otherCategory] : [])].flatMap((cat) =>
cat.sections.map((s) => ({ key: s.key, label: s.label })),
),
];
// Compute diff for showing changes (works for both form and raw modes)
const diff = formMode === "form" ? computeDiff(props.originalValue, props.formValue) : [];
const hasRawChanges = formMode === "raw" && props.raw !== props.originalRaw;
const hasChanges = formMode === "form" ? diff.length > 0 : hasRawChanges;
// Save/apply buttons require actual changes to be enabled.
// Note: formUnsafe warns about unsupported schema paths but shouldn't block saving.
const canSaveForm = Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
const canSave =
props.connected && !props.saving && hasChanges && (formMode === "raw" ? true : canSaveForm);
const canApply =
props.connected &&
!props.applying &&
!props.updating &&
hasChanges &&
(formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
const showAppearanceOnRoot =
includeVirtualSections &&
formMode === "form" &&
props.activeSection === null &&
Boolean(include?.has("__appearance__"));
return html`
<div class="config-layout">
<main class="config-main">
<div class="config-actions">
<div class="config-actions__left">
${
hasChanges
? html`
<span class="config-changes-badge"
>${
formMode === "raw"
? "Unsaved changes"
: `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`
}</span
>
`
: html`
<span class="config-status muted">No changes</span>
`
}
</div>
<div class="config-actions__right">
${
props.onOpenFile
? html`
<button
class="btn btn--sm"
title=${props.configPath ? `Open ${props.configPath}` : "Open config file"}
@click=${props.onOpenFile}
>
${icons.fileText} Open
</button>
`
: nothing
}
<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 class="config-top-tabs">
${
formMode === "form"
? html`
<div class="config-search config-search--top">
<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>
`
: nothing
}
<div class="config-top-tabs__scroller" role="tablist" aria-label="Settings sections">
${topTabs.map(
(tab) => html`
<button
class="config-top-tabs__tab ${props.activeSection === tab.key ? "active" : ""}"
role="tab"
aria-selected=${props.activeSection === tab.key}
@click=${() => props.onSectionChange(tab.key)}
title=${tab.label}
>
${tab.label}
</button>
`,
)}
</div>
<div class="config-top-tabs__right">
${
showModeToggle
? html`
<div class="config-mode-toggle">
<button
class="config-mode-toggle__btn ${formMode === "form" ? "active" : ""}"
?disabled=${props.schemaLoading || !props.schema}
title=${formUnsafe ? "Form view can't safely edit some fields" : ""}
@click=${() => props.onFormModeChange("form")}
>
Form
</button>
<button
class="config-mode-toggle__btn ${formMode === "raw" ? "active" : ""}"
@click=${() => props.onFormModeChange("raw")}
>
Raw
</button>
</div>
`
: nothing
}
</div>
</div>
${
validity === "invalid" && !cvs.validityDismissed
? html`
<div class="config-validity-warning">
<svg class="config-validity-warning__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span class="config-validity-warning__text">Your configuration is invalid. Some settings may not work as expected.</span>
<button
class="btn btn--sm"
@click=${() => {
cvs.validityDismissed = true;
props.onRawChange(props.raw);
}}
>Don't remind again</button>
</div>
`
: nothing
}
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
${
hasChanges && formMode === "form"
? html`
<details class="config-diff">
<summary class="config-diff__summary">
<span
>View ${diff.length} pending
change${diff.length !== 1 ? "s" : ""}</span
>
<svg
class="config-diff__chevron"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</summary>
<div class="config-diff__content">
${diff.map(
(change) => html`
<div class="config-diff__item">
<div class="config-diff__path">${change.path}</div>
<div class="config-diff__values">
<span class="config-diff__from"
>${renderDiffValue(change.path, change.from, props.uiHints)}</span
>
<span class="config-diff__arrow">→</span>
<span class="config-diff__to"
>${renderDiffValue(change.path, change.to, props.uiHints)}</span
>
</div>
</div>
`,
)}
</div>
</details>
`
: nothing
}
${
activeSectionMeta && formMode === "form"
? html`
<div class="config-section-hero">
<div class="config-section-hero__icon">
${getSectionIcon(props.activeSection ?? "")}
</div>
<div class="config-section-hero__text">
<div class="config-section-hero__title">
${activeSectionMeta.label}
</div>
${
activeSectionMeta.description
? html`<div class="config-section-hero__desc">
${activeSectionMeta.description}
</div>`
: nothing
}
</div>
${
props.activeSection === "env"
? html`
<button
class="config-env-peek-btn ${envSensitiveVisible ? "config-env-peek-btn--active" : ""}"
title=${envSensitiveVisible ? "Hide env values" : "Reveal env values"}
@click=${() => {
cvs.envRevealed = !cvs.envRevealed;
props.onRawChange(props.raw);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
Peek
</button>
`
: nothing
}
</div>
`
: nothing
}
<!-- Form content -->
<div class="config-content">
${
props.activeSection === "__appearance__"
? includeVirtualSections
? renderAppearanceSection(props)
: nothing
: formMode === "form"
? html`
${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing}
${
props.schemaLoading
? html`
<div class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema…</span>
</div>
`
: renderConfigForm({
schema: analysis.schema,
uiHints: props.uiHints,
value: props.formValue,
disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths,
onPatch: props.onFormPatch,
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
revealSensitive:
props.activeSection === "env" ? envSensitiveVisible : false,
isSensitivePathRevealed,
onToggleSensitivePath: (path) => {
toggleSensitivePathReveal(path);
props.onRawChange(props.raw);
},
})
}
`
: (() => {
const sensitiveCount = countSensitiveConfigValues(
props.formValue,
[],
props.uiHints,
);
const blurred = sensitiveCount > 0 && !cvs.rawRevealed;
return html`
${
formUnsafe
? html`
<div class="callout info" style="margin-bottom: 12px">
Your config contains fields the form editor can't safely represent. Use Raw mode to edit those
entries.
</div>
`
: nothing
}
<label class="field config-raw-field">
<span style="display:flex;align-items:center;gap:8px;">
Raw JSON5
${
sensitiveCount > 0
? html`
<span class="pill pill--sm">${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"} ${blurred ? "redacted" : "visible"}</span>
<button
class="btn btn--icon ${blurred ? "" : "active"}"
style="width:28px;height:28px;padding:0;"
title=${
blurred ? "Reveal sensitive values" : "Hide sensitive values"
}
aria-label="Toggle raw config redaction"
aria-pressed=${!blurred}
@click=${() => {
cvs.rawRevealed = !cvs.rawRevealed;
props.onRawChange(props.raw);
}}
>
${blurred ? icons.eyeOff : icons.eye}
</button>
`
: nothing
}
</span>
<textarea
class="${blurred ? "config-raw-redacted" : ""}"
placeholder=${blurred ? REDACTED_PLACEHOLDER : "Raw JSON5 config"}
.value=${blurred ? "" : props.raw}
?readonly=${blurred}
@input=${(e: Event) => {
if (blurred) {
return;
}
props.onRawChange((e.target as HTMLTextAreaElement).value);
}}
></textarea>
</label>
`;
})()
}
</div>
${
props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;">
<pre class="code-block">
${JSON.stringify(props.issues, null, 2)}</pre
>
</div>`
: nothing
}
</main>
</div>
`;
}