From e7728ff037c9c24512dc4bed81cabbe6e4c0ca9d Mon Sep 17 00:00:00 2001 From: ronitchidara Date: Tue, 27 Jan 2026 18:32:19 +0530 Subject: [PATCH] Web UI: add Phase 3 UX improvements - Add onboarding wizard progress indicator with step visualization - Add skills quick-toggle panel in topbar for fast enable/disable - Add config presets system (Fast Chat, Coding Agent, Voice Assistant, Privacy First) - Add config view levels (Basic/Standard/Advanced) to reduce complexity - Add voice input button for mobile WebChat using Web Speech API - Improve mobile responsiveness (safe-area insets, 44px touch targets, bottom sheets) --- CHANGELOG.md | 6 + .../onboarding/__tests__/test-utils.ts | 1 + src/wizard/clack-prompter.ts | 52 +++- src/wizard/onboarding.test.ts | 5 + src/wizard/onboarding.ts | 53 +++- src/wizard/prompts.ts | 14 + src/wizard/session.ts | 12 +- ui/src/styles/chat/layout.css | 68 +++++ ui/src/styles/components.css | 137 ++++++++++ ui/src/styles/config.css | 176 ++++++++++++ ui/src/styles/layout.mobile.css | 254 ++++++++++++++++++ ui/src/ui/app-render.helpers.ts | 81 +++++- ui/src/ui/app-render.ts | 22 +- ui/src/ui/app.ts | 53 ++++ ui/src/ui/config-presets.ts | 163 +++++++++++ ui/src/ui/config-view-levels.ts | 176 ++++++++++++ ui/src/ui/controllers/config.ts | 48 ++++ ui/src/ui/icons.ts | 2 + ui/src/ui/storage.ts | 6 + ui/src/ui/views/chat.ts | 37 ++- ui/src/ui/views/config.ts | 88 +++++- ui/src/ui/voice-input.ts | 200 ++++++++++++++ 22 files changed, 1632 insertions(+), 22 deletions(-) create mode 100644 ui/src/ui/config-presets.ts create mode 100644 ui/src/ui/config-view-levels.ts create mode 100644 ui/src/ui/voice-input.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f17001146..b7aa522d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ Status: beta. - Refactor: add centralized error utilities with MoltbotError base class. - Refactor: extract EmbeddingService from memory manager (reduces manager.ts by 25%). - Docs: add large file analysis for refactoring opportunities. +- Web UI: add onboarding wizard progress indicator with step visualization. +- Web UI: add skills quick-toggle panel in topbar for fast enable/disable. +- Web UI: add config presets system (Fast Chat, Coding Agent, Voice Assistant, Privacy First). +- Web UI: add config view levels (Basic/Standard/Advanced) to reduce complexity. +- Web UI: add voice input button for mobile WebChat using Web Speech API. +- Web UI: improve mobile responsiveness (safe-area insets, 44px touch targets, bottom sheets). - Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. - macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). - macOS: finish Moltbot app rename for macOS sources, bundle identifiers, and shared kit paths. (#2844) Thanks @fal3. diff --git a/src/commands/onboarding/__tests__/test-utils.ts b/src/commands/onboarding/__tests__/test-utils.ts index aea7f9cda..4f5dec200 100644 --- a/src/commands/onboarding/__tests__/test-utils.ts +++ b/src/commands/onboarding/__tests__/test-utils.ts @@ -21,5 +21,6 @@ export const makePrompter = (overrides: Partial = {}): WizardPro text: vi.fn(async () => "") as WizardPrompter["text"], confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + setStepProgress: vi.fn(), ...overrides, }); diff --git a/src/wizard/clack-prompter.ts b/src/wizard/clack-prompter.ts index 4e1581f92..827fbd13d 100644 --- a/src/wizard/clack-prompter.ts +++ b/src/wizard/clack-prompter.ts @@ -14,7 +14,7 @@ import { createCliProgress } from "../cli/progress.js"; import { note as emitNote } from "../terminal/note.js"; import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js"; import { theme } from "../terminal/theme.js"; -import type { WizardProgress, WizardPrompter } from "./prompts.js"; +import type { StepProgress, WizardProgress, WizardPrompter } from "./prompts.js"; import { WizardCancelledError } from "./prompts.js"; function guardCancel(value: T | symbol): T { @@ -26,6 +26,21 @@ function guardCancel(value: T | symbol): T { } export function createClackPrompter(): WizardPrompter { + let stepProgress: StepProgress | null = null; + + const formatStepIndicator = (): string | null => { + if (!stepProgress) return null; + const { current, total, label } = stepProgress; + return `Step ${current} of ${total}: ${label}`; + }; + + const showStepIndicator = () => { + const indicator = formatStepIndicator(); + if (indicator) { + emitNote(theme.muted(indicator), "Progress"); + } + }; + return { intro: async (title) => { intro(stylePromptTitle(title) ?? title); @@ -36,8 +51,9 @@ export function createClackPrompter(): WizardPrompter { note: async (message, title) => { emitNote(message, title); }, - select: async (params) => - guardCancel( + select: async (params) => { + showStepIndicator(); + return guardCancel( await select({ message: stylePromptMessage(params.message), options: params.options.map((opt) => { @@ -46,9 +62,11 @@ export function createClackPrompter(): WizardPrompter { }) as Option<(typeof params.options)[number]["value"]>[], initialValue: params.initialValue, }), - ), - multiselect: async (params) => - guardCancel( + ); + }, + multiselect: async (params) => { + showStepIndicator(); + return guardCancel( await multiselect({ message: stylePromptMessage(params.message), options: params.options.map((opt) => { @@ -57,23 +75,28 @@ export function createClackPrompter(): WizardPrompter { }) as Option<(typeof params.options)[number]["value"]>[], initialValues: params.initialValues, }), - ), - text: async (params) => - guardCancel( + ); + }, + text: async (params) => { + showStepIndicator(); + return guardCancel( await text({ message: stylePromptMessage(params.message), initialValue: params.initialValue, placeholder: params.placeholder, validate: params.validate, }), - ), - confirm: async (params) => - guardCancel( + ); + }, + confirm: async (params) => { + showStepIndicator(); + return guardCancel( await confirm({ message: stylePromptMessage(params.message), initialValue: params.initialValue, }), - ), + ); + }, progress: (label: string): WizardProgress => { const spin = spinner(); spin.start(theme.accent(label)); @@ -94,5 +117,8 @@ export function createClackPrompter(): WizardPrompter { }, }; }, + setStepProgress: (progress) => { + stepProgress = progress; + }, }; } diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index df832391e..fecbfd600 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -97,6 +97,7 @@ describe("runOnboardingWizard", () => { text: vi.fn(async () => ""), confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + setStepProgress: vi.fn(), }; const runtime: RuntimeEnv = { @@ -140,6 +141,7 @@ describe("runOnboardingWizard", () => { text: vi.fn(async () => ""), confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + setStepProgress: vi.fn(), }; const runtime: RuntimeEnv = { log: vi.fn(), @@ -191,6 +193,7 @@ describe("runOnboardingWizard", () => { text: vi.fn(async () => ""), confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + setStepProgress: vi.fn(), }; const runtime: RuntimeEnv = { @@ -246,6 +249,7 @@ describe("runOnboardingWizard", () => { text: vi.fn(async () => ""), confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + setStepProgress: vi.fn(), }; const runtime: RuntimeEnv = { @@ -297,6 +301,7 @@ describe("runOnboardingWizard", () => { text: vi.fn(async () => ""), confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + setStepProgress: vi.fn(), }; const runtime: RuntimeEnv = { diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 75543ca19..ca2705fae 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -41,7 +41,29 @@ import { resolveUserPath } from "../utils.js"; import { finalizeOnboardingWizard } from "./onboarding.finalize.js"; import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js"; import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js"; -import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; +import { WizardCancelledError, type StepProgress, type WizardPrompter } from "./prompts.js"; + +/** + * Step definitions for wizard flows. + * QuickStart has fewer steps since it skips workspace/gateway prompts. + */ +const QUICKSTART_STEPS = { + SECURITY: { current: 1, total: 5, label: "Security" }, + AUTH: { current: 2, total: 5, label: "Model Provider" }, + CHANNELS: { current: 3, total: 5, label: "Channels" }, + SKILLS: { current: 4, total: 5, label: "Skills" }, + FINALIZE: { current: 5, total: 5, label: "Finalize" }, +} as const satisfies Record; + +const ADVANCED_STEPS = { + SECURITY: { current: 1, total: 7, label: "Security" }, + CONFIG: { current: 2, total: 7, label: "Configuration" }, + AUTH: { current: 3, total: 7, label: "Model Provider" }, + GATEWAY: { current: 4, total: 7, label: "Gateway" }, + CHANNELS: { current: 5, total: 7, label: "Channels" }, + SKILLS: { current: 6, total: 7, label: "Skills" }, + FINALIZE: { current: 7, total: 7, label: "Finalize" }, +} as const satisfies Record; async function requireRiskAcknowledgement(params: { opts: OnboardOptions; @@ -91,6 +113,9 @@ export async function runOnboardingWizard( ) { printWizardHeader(runtime); await prompter.intro("Moltbot onboarding"); + + // Security acknowledgement (Step 1 for both flows) + prompter.setStepProgress(QUICKSTART_STEPS.SECURITY); await requireRiskAcknowledgement({ opts, prompter }); const snapshot = await readConfigFileSnapshot(); @@ -151,6 +176,11 @@ export async function runOnboardingWizard( flow = "advanced"; } + // Config handling step (Advanced flow only) + if (snapshot.exists && flow === "advanced") { + prompter.setStepProgress(ADVANCED_STEPS.CONFIG); + } + if (snapshot.exists) { await prompter.note(summarizeExistingConfig(baseConfig), "Existing config detected"); @@ -350,6 +380,9 @@ export async function runOnboardingWizard( }, }; + // Auth/model step + prompter.setStepProgress(flow === "quickstart" ? QUICKSTART_STEPS.AUTH : ADVANCED_STEPS.AUTH); + const authStore = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false, }); @@ -390,6 +423,11 @@ export async function runOnboardingWizard( await warnIfModelConfigLooksOff(nextConfig, prompter); + // Gateway configuration step (only shows indicator for Advanced flow) + if (flow === "advanced") { + prompter.setStepProgress(ADVANCED_STEPS.GATEWAY); + } + const gateway = await configureGatewayForOnboarding({ flow, baseConfig, @@ -402,6 +440,11 @@ export async function runOnboardingWizard( nextConfig = gateway.nextConfig; const settings = gateway.settings; + // Channels step + prompter.setStepProgress( + flow === "quickstart" ? QUICKSTART_STEPS.CHANNELS : ADVANCED_STEPS.CHANNELS, + ); + if (opts.skipChannels ?? opts.skipProviders) { await prompter.note("Skipping channel setup.", "Channels"); } else { @@ -426,6 +469,9 @@ export async function runOnboardingWizard( skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), }); + // Skills step + prompter.setStepProgress(flow === "quickstart" ? QUICKSTART_STEPS.SKILLS : ADVANCED_STEPS.SKILLS); + if (opts.skipSkills) { await prompter.note("Skipping skills setup.", "Skills"); } else { @@ -438,6 +484,11 @@ export async function runOnboardingWizard( nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); await writeConfigFile(nextConfig); + // Finalize step + prompter.setStepProgress( + flow === "quickstart" ? QUICKSTART_STEPS.FINALIZE : ADVANCED_STEPS.FINALIZE, + ); + await finalizeOnboardingWizard({ flow, opts, diff --git a/src/wizard/prompts.ts b/src/wizard/prompts.ts index 7aa496b76..c54ea91a2 100644 --- a/src/wizard/prompts.ts +++ b/src/wizard/prompts.ts @@ -33,6 +33,15 @@ export type WizardProgress = { stop: (message?: string) => void; }; +/** + * Tracks overall wizard progress (step X of Y). + */ +export type StepProgress = { + current: number; + total: number; + label: string; +}; + export type WizardPrompter = { intro: (title: string) => Promise; outro: (message: string) => Promise; @@ -42,6 +51,11 @@ export type WizardPrompter = { text: (params: WizardTextParams) => Promise; confirm: (params: WizardConfirmParams) => Promise; progress: (label: string) => WizardProgress; + /** + * Set the current step progress (displayed before major prompts). + * Pass null to clear the step indicator. + */ + setStepProgress: (progress: StepProgress | null) => void; }; export class WizardCancelledError extends Error { diff --git a/src/wizard/session.ts b/src/wizard/session.ts index 358907668..3a160499a 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -1,6 +1,11 @@ import { randomUUID } from "node:crypto"; -import { WizardCancelledError, type WizardProgress, type WizardPrompter } from "./prompts.js"; +import { + WizardCancelledError, + type StepProgress, + type WizardProgress, + type WizardPrompter, +} from "./prompts.js"; export type WizardStepOption = { value: unknown; @@ -153,6 +158,11 @@ class WizardSessionPrompter implements WizardPrompter { }; } + setStepProgress(_progress: StepProgress | null): void { + // Session-based wizard doesn't display step progress inline + // (the client UI can track steps separately if needed) + } + private async prompt(step: Omit): Promise { return await this.session.awaitAnswer({ ...step, diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 589b0b62d..b7364ac7c 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -281,6 +281,74 @@ box-sizing: border-box; } +/* Voice input button */ +.chat-voice-btn { + flex-shrink: 0; + width: 40px; + height: 40px; + min-width: 40px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s ease; +} + +.chat-voice-btn:hover:not(:disabled) { + background: var(--bg-tertiary); + color: var(--text); + border-color: var(--accent); +} + +.chat-voice-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chat-voice-btn svg { + width: 20px; + height: 20px; +} + +/* Recording state */ +.chat-voice-btn--recording { + background: var(--danger-subtle); + border-color: var(--danger); + color: var(--danger); + animation: voice-pulse 1.5s ease-in-out infinite; +} + +.chat-voice-btn--recording:hover:not(:disabled) { + background: var(--danger-subtle); + border-color: var(--danger); + color: var(--danger); +} + +@keyframes voice-pulse { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); + } + 50% { + box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); + } +} + +/* Voice error message */ +.chat-voice-error { + padding: 8px 12px; + margin-bottom: 8px; + font-size: 13px; + background: var(--danger-subtle); + color: var(--danger); + border-radius: 6px; + border: 1px solid var(--danger); +} + /* Chat controls - moved to content-header area, left aligned */ .chat-controls { display: flex; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 27dfe62d1..81c6a8114 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -274,6 +274,143 @@ stroke-linejoin: round; } +/* =========================================== + Skills Quick Toggle - Topbar dropdown + =========================================== */ + +.skills-quick-toggle { + position: relative; +} + +.skills-quick-toggle__button { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + font-size: 12px; + font-weight: 500; + height: 32px; + cursor: pointer; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: var(--secondary); + color: var(--text); + transition: border-color var(--duration-fast) var(--ease-out); +} + +.skills-quick-toggle__button:hover { + border-color: var(--accent); +} + +.skills-quick-toggle__icon { + display: flex; + align-items: center; +} + +.skills-quick-toggle__icon svg { + stroke: var(--accent); +} + +.skills-quick-toggle__count { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted); +} + +.skills-quick-toggle__backdrop { + position: fixed; + inset: 0; + z-index: 90; +} + +.skills-quick-toggle__panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 240px; + max-width: 320px; + max-height: 400px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + z-index: 100; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.skills-quick-toggle__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + font-size: 13px; + font-weight: 600; +} + +.skills-quick-toggle__close { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + border-radius: var(--radius-sm); + font-size: 18px; + line-height: 1; +} + +.skills-quick-toggle__close:hover { + background: var(--secondary); + color: var(--text); +} + +.skills-quick-toggle__list { + overflow-y: auto; + padding: 8px 0; +} + +.skills-quick-toggle__empty { + padding: 16px; + text-align: center; + color: var(--muted); + font-size: 13px; +} + +.skills-quick-toggle__item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-out); +} + +.skills-quick-toggle__item:hover { + background: var(--secondary); +} + +.skills-quick-toggle__item input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +.skills-quick-toggle__name { + font-size: 13px; + color: var(--text); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* =========================================== Status Dot - With glow for emphasis =========================================== */ diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index 7d96ac13f..ebe22cd1c 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -1445,3 +1445,179 @@ min-width: 70px; } } + +/* =========================================== + Config Presets - Quick config profiles + =========================================== */ + +.config-presets { + position: relative; +} + +.config-presets__trigger { + display: flex; + align-items: center; + gap: 6px; +} + +.config-presets__trigger svg { + stroke: var(--accent); +} + +.config-presets__backdrop { + position: fixed; + inset: 0; + z-index: 90; +} + +.config-presets__panel { + position: absolute; + top: calc(100% + 8px); + left: 0; + min-width: 320px; + max-width: 400px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + z-index: 100; + overflow: hidden; +} + +.config-presets__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + font-size: 13px; + font-weight: 600; +} + +.config-presets__close { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + border-radius: var(--radius-sm); + font-size: 18px; + line-height: 1; +} + +.config-presets__close:hover { + background: var(--secondary); + color: var(--text); +} + +.config-presets__list { + padding: 8px; +} + +.config-presets__item { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + padding: 12px; + border: none; + background: transparent; + text-align: left; + cursor: pointer; + border-radius: var(--radius-md); + transition: background var(--duration-fast) var(--ease-out); +} + +.config-presets__item:hover { + background: var(--secondary); +} + +.config-presets__emoji { + font-size: 20px; + line-height: 1; + flex-shrink: 0; + width: 28px; + text-align: center; +} + +.config-presets__info { + flex: 1; + min-width: 0; +} + +.config-presets__name { + font-size: 13px; + font-weight: 600; + color: var(--text); + margin-bottom: 2px; +} + +.config-presets__desc { + font-size: 12px; + color: var(--muted); + line-height: 1.4; +} + +.config-presets__footer { + padding: 10px 16px; + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--muted); + text-align: center; +} + +/* =========================================== + Config View Levels - Complexity filter + =========================================== */ + +.config-view-level { + padding: 12px 14px; + border-bottom: 1px solid var(--border); +} + +.config-view-level__label { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; +} + +.config-view-level__toggle { + display: flex; + gap: 2px; + padding: 2px; + background: var(--secondary); + border-radius: var(--radius-md); +} + +.config-view-level__btn { + flex: 1; + padding: 6px 8px; + border: none; + background: transparent; + font-size: 11px; + font-weight: 500; + color: var(--muted); + cursor: pointer; + border-radius: calc(var(--radius-md) - 2px); + transition: + background var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.config-view-level__btn:hover { + color: var(--text); +} + +.config-view-level__btn.active { + background: var(--bg-elevated); + color: var(--accent); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 450a83608..5c7409582 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -372,3 +372,257 @@ height: 11px; } } + +/* =========================================== + Mobile UX Polish + - Safe area insets for iPhone notch/home indicator + - 44px minimum touch targets (Apple HIG) + - 16px fonts to prevent iOS zoom on focus + - Bottom sheet pattern for dropdowns + =========================================== */ + +/* Safe area insets for iPhone notch and home indicator */ +@supports (padding-top: env(safe-area-inset-top)) { + @media (max-width: 600px) { + .shell { + padding-top: env(safe-area-inset-top); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + padding-bottom: env(safe-area-inset-bottom); + } + + .topbar { + padding-top: calc(10px + env(safe-area-inset-top)); + } + + /* Bottom compose area needs safe area */ + .chat-compose { + padding-bottom: calc(12px + env(safe-area-inset-bottom)); + } + + /* Nav needs horizontal safe areas */ + .nav { + padding-left: calc(10px + env(safe-area-inset-left)); + padding-right: calc(10px + env(safe-area-inset-right)); + } + } +} + +/* 44px minimum touch targets for mobile (Apple HIG) */ +@media (max-width: 600px) { + /* Buttons - minimum 44px height */ + .btn { + min-height: 44px; + padding: 10px 16px; + } + + /* Icon buttons */ + .btn--icon, + .topbar-btn { + min-width: 44px; + min-height: 44px; + padding: 10px; + } + + /* Nav items - 44px touch target */ + .nav-item { + min-height: 44px; + padding: 12px 16px; + display: flex; + align-items: center; + } + + /* Chat compose buttons */ + .chat-compose__actions .btn { + min-height: 44px; + padding: 10px 16px; + } + + /* Voice button */ + .chat-voice-btn { + min-width: 44px; + min-height: 44px; + width: 44px; + height: 44px; + } + + /* Skills quick-toggle */ + .skills-quick-toggle__btn { + min-height: 44px; + min-width: 44px; + padding: 10px 14px; + } + + .skills-quick-toggle__item label { + min-height: 44px; + padding: 12px 14px; + } + + /* Config presets */ + .config-preset-btn { + min-height: 44px; + min-width: 44px; + } + + .config-preset { + min-height: 48px; + } + + /* View level toggle */ + .config-view-level__option { + min-height: 44px; + padding: 12px 16px; + } + + /* Select dropdowns */ + .field select { + min-height: 44px; + } + + /* Checkboxes and radios with label */ + .field input[type="checkbox"], + .field input[type="radio"] { + width: 22px; + height: 22px; + } + + /* List items */ + .list-item { + min-height: 44px; + padding: 12px; + } +} + +/* 16px font for inputs to prevent iOS zoom on focus */ +@media (max-width: 600px) { + .field input, + .field textarea, + .field select { + font-size: 16px; + } + + /* Chat compose textarea */ + .chat-compose__field textarea { + font-size: 16px; + } + + /* Search inputs */ + input[type="search"], + input[type="text"], + input[type="email"], + input[type="password"], + input[type="number"], + input[type="tel"], + input[type="url"] { + font-size: 16px; + } +} + +/* Bottom sheet pattern for dropdowns on mobile */ +@media (max-width: 600px) { + /* Skills quick-toggle as bottom sheet */ + .skills-quick-toggle { + position: static; + } + + .skills-quick-toggle__panel { + position: fixed; + bottom: 0; + left: 0; + right: 0; + top: auto; + max-height: 60vh; + border-radius: 16px 16px 0 0; + width: 100%; + z-index: 1000; + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.25); + animation: slide-up 0.2s ease-out; + } + + .skills-quick-toggle__panel::before { + content: ""; + display: block; + width: 36px; + height: 4px; + background: var(--border); + border-radius: 2px; + margin: 8px auto 4px; + } + + /* Config preset panel as bottom sheet */ + .config-preset-panel { + position: fixed; + bottom: 0; + left: 0; + right: 0; + top: auto; + max-height: 70vh; + border-radius: 16px 16px 0 0; + width: 100%; + z-index: 1000; + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.25); + animation: slide-up 0.2s ease-out; + } + + .config-preset-panel::before { + content: ""; + display: block; + width: 36px; + height: 4px; + background: var(--border); + border-radius: 2px; + margin: 8px auto 4px; + } + + /* Backdrop for bottom sheets */ + .skills-quick-toggle__panel, + .config-preset-panel { + backdrop-filter: blur(8px); + background: var(--bg-secondary); + } +} + +@keyframes slide-up { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Additional mobile polish */ +@media (max-width: 600px) { + /* Larger checkbox/toggle spacing */ + .toggle-field { + gap: 12px; + } + + /* Better card action spacing */ + .card-actions { + gap: 8px; + flex-wrap: wrap; + } + + /* Compact callout on mobile */ + .callout { + padding: 10px 12px; + font-size: 13px; + } + + /* Better form field labels */ + .field > span { + font-size: 13px; + margin-bottom: 6px; + } + + /* Scroll momentum */ + .chat-thread, + .log-stream, + .content { + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + } +} diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 22f8d90db..70a7702d5 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -1,4 +1,4 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; import type { AppViewState } from "./app-view-state"; @@ -6,9 +6,10 @@ import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation"; import { icons } from "./icons"; import { loadChatHistory } from "./controllers/chat"; import { syncUrlWithSessionKey } from "./app-settings"; -import type { SessionsListResult } from "./types"; +import type { SessionsListResult, SkillStatusEntry } from "./types"; import type { ThemeMode } from "./theme"; import type { ThemeTransitionContext } from "./theme-transition"; +import { updateSkillEnabled } from "./controllers/skills"; export function renderTab(state: AppViewState, tab: Tab) { const href = pathForTab(tab, state.basePath); @@ -240,3 +241,79 @@ function renderMonitorIcon() { `; } + +export function renderSkillsQuickToggle(state: AppViewState) { + const skills = state.skillsReport?.skills ?? []; + const eligibleSkills = skills.filter((s) => s.eligible); + const enabledCount = eligibleSkills.filter((s) => !s.disabled).length; + const isOpen = state.settings.skillsPanelOpen ?? false; + + const togglePanel = () => { + state.applySettings({ + ...state.settings, + skillsPanelOpen: !isOpen, + }); + }; + + const closePanel = () => { + state.applySettings({ + ...state.settings, + skillsPanelOpen: false, + }); + }; + + const handleToggle = (skill: SkillStatusEntry) => { + void updateSkillEnabled(state, skill.skillKey, skill.disabled); + }; + + const sparklesIcon = html` + + + + `; + + return html` +
+ + ${isOpen + ? html` +
+
+
+ Toggle Skills + +
+
+ ${eligibleSkills.length === 0 + ? html`
No eligible skills
` + : eligibleSkills.map( + (skill) => html` + + `, + )} +
+
+ ` + : nothing} +
+ `; +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a088c33ff..1d802bc88 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -50,7 +50,7 @@ import { rotateDeviceToken, } from "./controllers/devices"; import { renderSkills } from "./views/skills"; -import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; +import { renderChatControls, renderSkillsQuickToggle, renderTab, renderThemeToggle } from "./app-render.helpers"; import { loadChannels } from "./controllers/channels"; import { loadPresence } from "./controllers/presence"; import { deleteSession, loadSessions, patchSession } from "./controllers/sessions"; @@ -66,12 +66,14 @@ import { loadNodes } from "./controllers/nodes"; import { loadChatHistory } from "./controllers/chat"; import { applyConfig, + applyConfigPreset, loadConfig, runUpdate, saveConfig, updateConfigFormValue, removeConfigFormValue, } from "./controllers/config"; +import { getPreset } from "./config-presets"; import { loadExecApprovals, removeExecApprovalsFormValue, @@ -143,6 +145,7 @@ export function renderApp(state: AppViewState) { Health ${state.connected ? "OK" : "Offline"} + ${renderSkillsQuickToggle(state)} ${renderThemeToggle(state)} @@ -480,6 +483,11 @@ export function renderApp(state: AppViewState) { onDraftChange: (next) => (state.chatMessage = next), attachments: state.chatAttachments, onAttachmentsChange: (next) => (state.chatAttachments = next), + // Voice input props + voiceRecording: state.voiceRecording, + voiceInterim: state.voiceInterim, + voiceError: state.voiceError, + onVoiceToggle: () => state.handleVoiceToggle(), onSend: () => state.handleSendChat(), canAbort: Boolean(state.chatRunId), onAbort: () => void state.handleAbortChat(), @@ -530,10 +538,22 @@ export function renderApp(state: AppViewState) { state.configActiveSubsection = null; }, onSubsectionChange: (section) => (state.configActiveSubsection = section), + presetPanelOpen: state.configPresetPanelOpen, onReload: () => loadConfig(state), onSave: () => saveConfig(state), onApply: () => applyConfig(state), onUpdate: () => runUpdate(state), + onApplyPreset: (presetId) => { + const preset = getPreset(presetId); + if (preset) { + applyConfigPreset(state, preset.values); + } + }, + onTogglePresetPanel: () => { + state.configPresetPanelOpen = !state.configPresetPanelOpen; + }, + viewLevel: state.configViewLevel, + onViewLevelChange: (level) => (state.configViewLevel = level), }) : nothing} diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index d23e543cd..9dc371339 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -63,6 +63,10 @@ import { handleSendChat as handleSendChatInternal, removeQueuedMessage as removeQueuedMessageInternal, } from "./app-chat"; +import { + startVoiceRecognition, + stopVoiceRecognition, +} from "./voice-input"; import { handleChannelConfigReload as handleChannelConfigReloadInternal, handleChannelConfigSave as handleChannelConfigSaveInternal, @@ -130,6 +134,11 @@ export class MoltbotApp extends LitElement { @state() chatThinkingLevel: string | null = null; @state() chatQueue: ChatQueueItem[] = []; @state() chatAttachments: ChatAttachment[] = []; + // Voice input state + @state() voiceRecording = false; + @state() voiceInterim = ""; + @state() voiceError: string | null = null; + voiceStopFn: (() => void) | null = null; // Sidebar state for tool output viewing @state() sidebarOpen = false; @state() sidebarContent: string | null = null; @@ -174,6 +183,8 @@ export class MoltbotApp extends LitElement { @state() configSearchQuery = ""; @state() configActiveSection: string | null = null; @state() configActiveSubsection: string | null = null; + @state() configPresetPanelOpen = false; + @state() configViewLevel: "basic" | "standard" | "advanced" = "standard"; @state() channelsLoading = false; @state() channelsSnapshot: ChannelsStatusSnapshot | null = null; @@ -386,6 +397,48 @@ export class MoltbotApp extends LitElement { ); } + handleVoiceToggle() { + if (this.voiceRecording) { + // Stop recording + stopVoiceRecognition(); + this.voiceRecording = false; + this.voiceInterim = ""; + this.voiceStopFn = null; + } else { + // Start recording + this.voiceError = null; + this.voiceInterim = ""; + const stopFn = startVoiceRecognition({ + onTranscript: (text, isFinal) => { + if (isFinal) { + // Append final transcript to draft + const separator = this.chatMessage && !this.chatMessage.endsWith(" ") ? " " : ""; + this.chatMessage = this.chatMessage + separator + text; + this.voiceInterim = ""; + } else { + // Show interim result + this.voiceInterim = text; + } + }, + onError: (error) => { + this.voiceError = error; + this.voiceRecording = false; + this.voiceInterim = ""; + this.voiceStopFn = null; + }, + onStart: () => { + this.voiceRecording = true; + }, + onEnd: () => { + this.voiceRecording = false; + this.voiceInterim = ""; + this.voiceStopFn = null; + }, + }); + this.voiceStopFn = stopFn; + } + } + async handleWhatsAppStart(force: boolean) { await handleWhatsAppStartInternal(this, force); } diff --git a/ui/src/ui/config-presets.ts b/ui/src/ui/config-presets.ts new file mode 100644 index 000000000..f7904a19d --- /dev/null +++ b/ui/src/ui/config-presets.ts @@ -0,0 +1,163 @@ +/** + * Config presets for quick configuration profiles. + * Each preset applies a set of config values optimized for specific use cases. + */ + +export type ConfigPreset = { + id: string; + name: string; + description: string; + emoji: string; + /** Partial config object to merge into the current config */ + values: Record; +}; + +export const CONFIG_PRESETS: ConfigPreset[] = [ + { + id: "fast-chat", + name: "Fast Chat", + emoji: "\u26a1", + description: "Optimized for quick, responsive conversations with minimal latency.", + values: { + agents: { + defaults: { + thinkingDefault: "minimal", + verboseDefault: "off", + timeoutSeconds: 60, + blockStreamingDefault: "on", + typingMode: "instant", + }, + }, + }, + }, + { + id: "coding-agent", + name: "Coding Agent", + emoji: "\ud83d\udcbb", + description: "Enhanced reasoning and tools for software development tasks.", + values: { + agents: { + defaults: { + thinkingDefault: "high", + verboseDefault: "on", + elevatedDefault: "on", + timeoutSeconds: 300, + contextTokens: 180000, + }, + }, + tools: { + bash: { enabled: true }, + edit: { enabled: true }, + read: { enabled: true }, + write: { enabled: true }, + glob: { enabled: true }, + grep: { enabled: true }, + }, + }, + }, + { + id: "voice-assistant", + name: "Voice Assistant", + emoji: "\ud83c\udf99\ufe0f", + description: "Optimized for voice input and natural conversation flow.", + values: { + agents: { + defaults: { + thinkingDefault: "low", + verboseDefault: "off", + blockStreamingDefault: "on", + blockStreamingBreak: "text_end", + typingMode: "never", + humanDelay: { + enabled: true, + minMs: 500, + maxMs: 1500, + }, + }, + }, + audio: { + transcription: { + enabled: true, + provider: "whisper", + }, + }, + }, + }, + { + id: "privacy-first", + name: "Privacy First", + emoji: "\ud83d\udd12", + description: "Maximum privacy with minimal data retention and logging.", + values: { + logging: { + level: "error", + redactSensitive: "tools", + }, + diagnostics: { + enabled: false, + otel: { + enabled: false, + }, + cacheTrace: { + enabled: false, + }, + }, + agents: { + defaults: { + memorySearch: { + enabled: false, + }, + }, + }, + }, + }, +]; + +/** + * Deep merge two objects, with source values overwriting target values. + */ +function deepMerge( + target: Record, + source: Record, +): Record { + const result = { ...target }; + for (const key of Object.keys(source)) { + const sourceValue = source[key]; + const targetValue = result[key]; + if ( + sourceValue !== null && + typeof sourceValue === "object" && + !Array.isArray(sourceValue) && + targetValue !== null && + typeof targetValue === "object" && + !Array.isArray(targetValue) + ) { + result[key] = deepMerge( + targetValue as Record, + sourceValue as Record, + ); + } else { + result[key] = sourceValue; + } + } + return result; +} + +/** + * Apply a preset to a config object, merging the preset values. + */ +export function applyPreset( + config: Record, + presetId: string, +): Record { + const preset = CONFIG_PRESETS.find((p) => p.id === presetId); + if (!preset) return config; + return deepMerge(config, preset.values); +} + +/** + * Get a preset by ID. + */ +export function getPreset(presetId: string): ConfigPreset | undefined { + return CONFIG_PRESETS.find((p) => p.id === presetId); +} diff --git a/ui/src/ui/config-view-levels.ts b/ui/src/ui/config-view-levels.ts new file mode 100644 index 000000000..a8d37fb4d --- /dev/null +++ b/ui/src/ui/config-view-levels.ts @@ -0,0 +1,176 @@ +/** + * Config view levels for filtering fields by complexity. + * - Basic: Essential settings for getting started (~20 fields) + * - Standard: Common settings for most users (~80 fields) + * - Advanced: All settings including expert-only options (~260 fields) + */ + +export type ConfigViewLevel = "basic" | "standard" | "advanced"; + +export const VIEW_LEVELS: Array<{ value: ConfigViewLevel; label: string; description: string }> = [ + { value: "basic", label: "Basic", description: "Essential settings only" }, + { value: "standard", label: "Standard", description: "Common settings" }, + { value: "advanced", label: "Advanced", description: "All settings" }, +]; + +/** + * Basic-level fields: Essential settings for getting started. + * These are the minimum fields a new user needs. + */ +export const BASIC_FIELDS = new Set([ + // Models + "models", + "models.anthropic", + "models.anthropic.apiKey", + "models.openai", + "models.openai.apiKey", + + // Agent basics + "agents", + "agents.defaults", + "agents.defaults.model", + "agents.defaults.model.primary", + "agents.defaults.thinkingDefault", + "agents.defaults.workspace", + + // Channels (top-level) + "channels", + "channels.telegram", + "channels.discord", + "channels.slack", + "channels.whatsapp", + + // Gateway basics + "gateway", + "gateway.port", + "gateway.mode", + + // Update channel + "update", + "update.channel", +]); + +/** + * Standard-level fields: Common settings most users will adjust. + * Includes basic fields plus commonly-used options. + */ +export const STANDARD_SECTIONS = new Set([ + // All basic fields are included + "models", + "agents", + "channels", + "gateway", + "update", + + // Additional standard sections + "tools", + "skills", + "messages", + "commands", + "session", + "logging", + "audio", +]); + +/** + * Paths that are explicitly advanced-only even within standard sections. + * These are hidden unless view level is "advanced". + */ +export const ADVANCED_ONLY_PATHS = new Set([ + // Diagnostics and debugging + "diagnostics", + "diagnostics.otel", + "diagnostics.cacheTrace", + + // Expert agent settings + "agents.defaults.contextPruning", + "agents.defaults.compaction", + "agents.defaults.sandbox", + "agents.defaults.subagents", + "agents.defaults.heartbeat", + "agents.defaults.memorySearch", + + // Expert gateway settings + "gateway.security", + "gateway.rateLimit", + "gateway.cors", + "gateway.tls", + + // Browser advanced + "browser.cdpUrl", + "browser.remoteCdpTimeoutMs", + "browser.remoteCdpHandshakeTimeoutMs", + "browser.snapshotDefaults", + "browser.profiles", + + // Internal + "meta", + "wizard", + "env.shellEnv", +]); + +/** + * Check if a path should be shown at the given view level. + * @param path - Dot-separated config path (e.g., "agents.defaults.model") + * @param level - Current view level + * @param isAdvancedHint - Whether the field has advanced: true in uiHints + */ +export function isPathVisibleAtLevel( + path: string, + level: ConfigViewLevel, + isAdvancedHint: boolean = false, +): boolean { + // Advanced level shows everything + if (level === "advanced") { + return true; + } + + // Check if this path is explicitly advanced-only + if (ADVANCED_ONLY_PATHS.has(path) || isAdvancedHint) { + return false; + } + + // For basic level, only show basic fields + if (level === "basic") { + // Check exact match + if (BASIC_FIELDS.has(path)) return true; + + // Check if any parent or prefix matches + const parts = path.split("."); + for (let i = 1; i <= parts.length; i++) { + const prefix = parts.slice(0, i).join("."); + if (BASIC_FIELDS.has(prefix)) return true; + } + + return false; + } + + // Standard level: show if section is in standard sections + const topLevel = path.split(".")[0]; + return STANDARD_SECTIONS.has(topLevel); +} + +/** + * Check if a section (top-level key) should be shown at the given view level. + */ +export function isSectionVisibleAtLevel( + sectionKey: string, + level: ConfigViewLevel, +): boolean { + if (level === "advanced") return true; + + if (level === "basic") { + return BASIC_FIELDS.has(sectionKey); + } + + // Standard + return STANDARD_SECTIONS.has(sectionKey); +} + +/** + * Get description for the current view level. + */ +export function getViewLevelDescription(level: ConfigViewLevel): string { + const info = VIEW_LEVELS.find((l) => l.value === level); + return info?.description ?? ""; +} diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index c66876eba..10eb55b4c 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -200,3 +200,51 @@ export function removeConfigFormValue( state.configRaw = serializeConfigForm(base); } } + +/** + * Apply a preset configuration by merging its values into the current config. + */ +export function applyConfigPreset( + state: ConfigState, + presetValues: Record, +) { + const base = cloneConfigObject( + state.configForm ?? state.configSnapshot?.config ?? {}, + ); + const merged = deepMergeConfig(base, presetValues); + state.configForm = merged; + state.configFormDirty = true; + if (state.configFormMode === "form") { + state.configRaw = serializeConfigForm(merged); + } +} + +/** + * Deep merge preset values into a config object. + */ +function deepMergeConfig( + target: Record, + source: Record, +): Record { + const result = { ...target }; + for (const key of Object.keys(source)) { + const sourceValue = source[key]; + const targetValue = result[key]; + if ( + sourceValue !== null && + typeof sourceValue === "object" && + !Array.isArray(sourceValue) && + targetValue !== null && + typeof targetValue === "object" && + !Array.isArray(targetValue) + ) { + result[key] = deepMergeConfig( + targetValue as Record, + sourceValue as Record, + ); + } else { + result[key] = sourceValue; + } + } + return result; +} diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts index eaf8f0e27..12a044888 100644 --- a/ui/src/ui/icons.ts +++ b/ui/src/ui/icons.ts @@ -39,6 +39,8 @@ export const icons = { plug: html``, circle: html``, puzzle: html``, + mic: html``, + micOff: html``, } as const; export type IconName = keyof typeof icons; diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 4b1836bfb..335cef834 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -13,6 +13,7 @@ export type UiSettings = { splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) navCollapsed: boolean; // Collapsible sidebar state navGroupsCollapsed: Record; // Which nav groups are collapsed + skillsPanelOpen: boolean; // Skills quick-toggle panel state }; export function loadSettings(): UiSettings { @@ -32,6 +33,7 @@ export function loadSettings(): UiSettings { splitRatio: 0.6, navCollapsed: false, navGroupsCollapsed: {}, + skillsPanelOpen: false, }; try { @@ -84,6 +86,10 @@ export function loadSettings(): UiSettings { parsed.navGroupsCollapsed !== null ? parsed.navGroupsCollapsed : defaults.navGroupsCollapsed, + skillsPanelOpen: + typeof parsed.skillsPanelOpen === "boolean" + ? parsed.skillsPanelOpen + : defaults.skillsPanelOpen, }; } catch { return defaults; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index f5fb6e80b..b0b1682df 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -15,6 +15,7 @@ import { renderStreamingGroup, } from "../chat/grouped-render"; import { renderMarkdownSidebar } from "./markdown-sidebar"; +import { isVoiceInputSupported } from "../voice-input"; import "../components/resizable-divider"; export type CompactionIndicatorStatus = { @@ -56,6 +57,11 @@ export type ChatProps = { // Image attachments attachments?: ChatAttachment[]; onAttachmentsChange?: (attachments: ChatAttachment[]) => void; + // Voice input state + voiceRecording?: boolean; + voiceInterim?: string; + voiceError?: string | null; + onVoiceToggle?: () => void; // Event handlers onRefresh: () => void; onToggleFocusMode: () => void; @@ -329,12 +335,17 @@ export function renderChat(props: ChatProps) {
${renderAttachmentPreview(props)} + ${props.voiceError + ? html`
${props.voiceError}
` + : nothing}
+ ${isVoiceInputSupported() && props.onVoiceToggle + ? html` + + ` + : nothing}
+ ${props.presetPanelOpen ? html` +
+
+
+ Quick Config Presets + +
+
+ ${CONFIG_PRESETS.map((preset) => html` + + `)} +
+ +
+ ` : nothing} +
+ `; +} + export function renderConfig(props: ConfigProps) { const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; @@ -201,6 +263,11 @@ export function renderConfig(props: ConfigProps) { const allSections = [...availableSections, ...extraSections]; + // Filter sections based on view level + const visibleSections = props.formMode === "form" + ? allSections.filter((s) => isSectionVisibleAtLevel(s.key, props.viewLevel)) + : allSections; + const activeSectionSchema = props.activeSection && analysis.schema && schemaType(analysis.schema) === "object" ? (analysis.schema.properties?.[props.activeSection] as JsonSchema | undefined) @@ -289,7 +356,7 @@ export function renderConfig(props: ConfigProps) { ${sidebarIcons.all} All Settings - ${allSections.map(section => html` + ${visibleSections.map(section => html` + `)} +
+
+ ` : nothing}