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)
This commit is contained in:
ronitchidara 2026-01-27 18:32:19 +05:30
parent 1755ba52df
commit e7728ff037
22 changed files with 1632 additions and 22 deletions

View File

@ -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.

View File

@ -21,5 +21,6 @@ export const makePrompter = (overrides: Partial<WizardPrompter> = {}): 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,
});

View File

@ -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<T>(value: T | symbol): T {
@ -26,6 +26,21 @@ function guardCancel<T>(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;
},
};
}

View File

@ -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 = {

View File

@ -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<string, StepProgress>;
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<string, StepProgress>;
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,

View File

@ -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<void>;
outro: (message: string) => Promise<void>;
@ -42,6 +51,11 @@ export type WizardPrompter = {
text: (params: WizardTextParams) => Promise<string>;
confirm: (params: WizardConfirmParams) => Promise<boolean>;
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 {

View File

@ -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<WizardStep, "id">): Promise<unknown> {
return await this.session.awaitAnswer({
...step,

View File

@ -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;

View File

@ -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
=========================================== */

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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() {
</svg>
`;
}
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`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3l1.912 5.813a2 2 0 001.275 1.275L21 12l-5.813 1.912a2 2 0 00-1.275 1.275L12 21l-1.912-5.813a2 2 0 00-1.275-1.275L3 12l5.813-1.912a2 2 0 001.275-1.275L12 3z"></path>
</svg>
`;
return html`
<div class="skills-quick-toggle">
<button
class="skills-quick-toggle__button pill"
@click=${togglePanel}
aria-expanded=${isOpen}
aria-haspopup="true"
title="Quick-toggle skills"
>
<span class="skills-quick-toggle__icon">${sparklesIcon}</span>
<span>Skills</span>
<span class="skills-quick-toggle__count">${enabledCount}/${eligibleSkills.length}</span>
</button>
${isOpen
? html`
<div class="skills-quick-toggle__backdrop" @click=${closePanel}></div>
<div class="skills-quick-toggle__panel">
<div class="skills-quick-toggle__header">
<span>Toggle Skills</span>
<button class="skills-quick-toggle__close" @click=${closePanel}>×</button>
</div>
<div class="skills-quick-toggle__list">
${eligibleSkills.length === 0
? html`<div class="skills-quick-toggle__empty">No eligible skills</div>`
: eligibleSkills.map(
(skill) => html`
<label class="skills-quick-toggle__item">
<input
type="checkbox"
.checked=${!skill.disabled}
@change=${() => handleToggle(skill)}
/>
<span class="skills-quick-toggle__name">
${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
</span>
</label>
`,
)}
</div>
</div>
`
: nothing}
</div>
`;
}

View File

@ -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) {
<span>Health</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
</div>
${renderSkillsQuickToggle(state)}
${renderThemeToggle(state)}
</div>
</header>
@ -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}

View File

@ -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);
}

163
ui/src/ui/config-presets.ts Normal file
View File

@ -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<string, unknown>;
};
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<string, unknown>,
source: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown>,
sourceValue as Record<string, unknown>,
);
} else {
result[key] = sourceValue;
}
}
return result;
}
/**
* Apply a preset to a config object, merging the preset values.
*/
export function applyPreset(
config: Record<string, unknown>,
presetId: string,
): Record<string, unknown> {
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);
}

View File

@ -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 ?? "";
}

View File

@ -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<string, unknown>,
) {
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<string, unknown>,
source: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown>,
sourceValue as Record<string, unknown>,
);
} else {
result[key] = sourceValue;
}
}
return result;
}

View File

@ -39,6 +39,8 @@ export const icons = {
plug: html`<svg viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></svg>`,
circle: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>`,
puzzle: html`<svg viewBox="0 0 24 24"><path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.076.874.54 1.02 1.02a2.5 2.5 0 1 0 3.237-3.237c-.48-.146-.944-.505-1.02-1.02a.98.98 0 0 1 .303-.917l1.526-1.526A2.402 2.402 0 0 1 11.998 2c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.236 3.236c-.464.18-.894.527-.967 1.02Z"/></svg>`,
mic: html`<svg viewBox="0 0 24 24"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>`,
micOff: html`<svg viewBox="0 0 24 24"><line x1="2" x2="22" y1="2" y2="22"/><path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2"/><path d="M5 10v2a7 7 0 0 0 12 5"/><path d="M15 9.34V5a3 3 0 0 0-5.68-1.33"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12"/><line x1="12" x2="12" y1="19" y2="22"/></svg>`,
} as const;
export type IconName = keyof typeof icons;

View File

@ -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<string, boolean>; // 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;

View File

@ -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) {
<div class="chat-compose">
${renderAttachmentPreview(props)}
${props.voiceError
? html`<div class="chat-voice-error">${props.voiceError}</div>`
: nothing}
<div class="chat-compose__row">
<label class="field chat-compose__field">
<span>Message</span>
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
.value=${props.draft}
.value=${props.voiceRecording && props.voiceInterim
? props.draft + props.voiceInterim
: props.draft}
?disabled=${!props.connected}
@keydown=${(e: KeyboardEvent) => {
if (e.key !== "Enter") return;
@ -350,9 +361,31 @@ export function renderChat(props: ChatProps) {
props.onDraftChange(target.value);
}}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${composePlaceholder}
placeholder=${props.voiceRecording
? "Listening..."
: composePlaceholder}
></textarea>
</label>
${isVoiceInputSupported() && props.onVoiceToggle
? html`
<button
class="btn chat-voice-btn ${props.voiceRecording
? "chat-voice-btn--recording"
: ""}"
type="button"
?disabled=${!props.connected}
@click=${props.onVoiceToggle}
aria-label=${props.voiceRecording
? "Stop recording"
: "Start voice input"}
title=${props.voiceRecording
? "Stop recording"
: "Voice input"}
>
${props.voiceRecording ? icons.micOff : icons.mic}
</button>
`
: nothing}
<div class="chat-compose__actions">
<button
class="btn"

View File

@ -7,6 +7,12 @@ import {
schemaType,
type JsonSchema,
} from "./config-form.shared";
import { CONFIG_PRESETS, type ConfigPreset } from "../config-presets";
import {
VIEW_LEVELS,
isSectionVisibleAtLevel,
type ConfigViewLevel,
} from "../config-view-levels";
export type ConfigProps = {
raw: string;
@ -27,6 +33,8 @@ export type ConfigProps = {
searchQuery: string;
activeSection: string | null;
activeSubsection: string | null;
presetPanelOpen: boolean;
viewLevel: "basic" | "standard" | "advanced";
onRawChange: (next: string) => void;
onFormModeChange: (mode: "form" | "raw") => void;
onFormPatch: (path: Array<string | number>, value: unknown) => void;
@ -37,6 +45,9 @@ export type ConfigProps = {
onSave: () => void;
onApply: () => void;
onUpdate: () => void;
onApplyPreset: (presetId: string) => void;
onTogglePresetPanel: () => void;
onViewLevelChange: (level: "basic" | "standard" | "advanced") => void;
};
// SVG Icons for sidebar (Lucide-style)
@ -181,6 +192,57 @@ function truncateValue(value: unknown, maxLen = 40): string {
return str.slice(0, maxLen - 3) + "...";
}
function renderPresetSelector(props: ConfigProps) {
const presetIcon = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3l1.912 5.813a2 2 0 001.275 1.275L21 12l-5.813 1.912a2 2 0 00-1.275 1.275L12 21l-1.912-5.813a2 2 0 00-1.275-1.275L3 12l5.813-1.912a2 2 0 001.275-1.275L12 3z"></path>
</svg>
`;
return html`
<div class="config-presets">
<button
class="config-presets__trigger btn btn--sm"
@click=${props.onTogglePresetPanel}
?disabled=${props.formMode !== "form"}
title=${props.formMode !== "form" ? "Presets only available in Form mode" : "Apply a preset configuration"}
>
${presetIcon}
<span>Presets</span>
</button>
${props.presetPanelOpen ? html`
<div class="config-presets__backdrop" @click=${props.onTogglePresetPanel}></div>
<div class="config-presets__panel">
<div class="config-presets__header">
<span>Quick Config Presets</span>
<button class="config-presets__close" @click=${props.onTogglePresetPanel}>×</button>
</div>
<div class="config-presets__list">
${CONFIG_PRESETS.map((preset) => html`
<button
class="config-presets__item"
@click=${() => {
props.onApplyPreset(preset.id);
props.onTogglePresetPanel();
}}
>
<span class="config-presets__emoji">${preset.emoji}</span>
<div class="config-presets__info">
<div class="config-presets__name">${preset.name}</div>
<div class="config-presets__desc">${preset.description}</div>
</div>
</button>
`)}
</div>
<div class="config-presets__footer">
Presets merge with your current configuration
</div>
</div>
` : nothing}
</div>
`;
}
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) {
<span class="config-nav__icon">${sidebarIcons.all}</span>
<span class="config-nav__label">All Settings</span>
</button>
${allSections.map(section => html`
${visibleSections.map(section => html`
<button
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
@click=${() => props.onSectionChange(section.key)}
@ -300,8 +367,24 @@ export function renderConfig(props: ConfigProps) {
`)}
</nav>
<!-- Mode toggle at bottom -->
<!-- View level and mode toggle at bottom -->
<div class="config-sidebar__footer">
${props.formMode === "form" ? html`
<div class="config-view-level">
<label class="config-view-level__label">View Level</label>
<div class="config-view-level__toggle">
${VIEW_LEVELS.map((level) => html`
<button
class="config-view-level__btn ${props.viewLevel === level.value ? "active" : ""}"
@click=${() => props.onViewLevelChange(level.value)}
title=${level.description}
>
${level.label}
</button>
`)}
</div>
</div>
` : nothing}
<div class="config-mode-toggle">
<button
class="config-mode-toggle__btn ${props.formMode === "form" ? "active" : ""}"
@ -325,6 +408,7 @@ export function renderConfig(props: ConfigProps) {
<!-- Action bar -->
<div class="config-actions">
<div class="config-actions__left">
${renderPresetSelector(props)}
${hasChanges ? html`
<span class="config-changes-badge">${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span>
` : html`

200
ui/src/ui/voice-input.ts Normal file
View File

@ -0,0 +1,200 @@
/**
* Voice input utility using Web Speech API.
* Provides feature detection, speech recognition, and transcription.
*/
// Web Speech API types (not available in TypeScript by default)
interface SpeechRecognitionResult {
readonly length: number;
item(index: number): SpeechRecognitionAlternative;
[index: number]: SpeechRecognitionAlternative;
}
interface SpeechRecognitionAlternative {
readonly transcript: string;
readonly confidence: number;
}
interface SpeechRecognitionResultList {
readonly length: number;
item(index: number): SpeechRecognitionResult;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionEvent extends Event {
readonly results: SpeechRecognitionResultList;
readonly resultIndex: number;
}
interface SpeechRecognitionErrorEvent extends Event {
readonly error: string;
readonly message: string;
}
interface SpeechRecognition extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
onstart: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
interface SpeechRecognitionConstructor {
new (): SpeechRecognition;
}
declare global {
interface Window {
SpeechRecognition?: SpeechRecognitionConstructor;
webkitSpeechRecognition?: SpeechRecognitionConstructor;
}
}
/**
* Check if Web Speech API is supported in the browser.
*/
export function isVoiceInputSupported(): boolean {
return Boolean(
typeof window !== "undefined" &&
(window.SpeechRecognition || window.webkitSpeechRecognition),
);
}
/**
* Get the SpeechRecognition constructor if available.
*/
function getSpeechRecognition(): SpeechRecognitionConstructor | null {
if (typeof window === "undefined") return null;
return window.SpeechRecognition || window.webkitSpeechRecognition || null;
}
export type VoiceInputState = {
isRecording: boolean;
transcript: string;
interimTranscript: string;
error: string | null;
};
export type VoiceInputCallbacks = {
onTranscript: (text: string, isFinal: boolean) => void;
onError: (error: string) => void;
onStart: () => void;
onEnd: () => void;
};
let activeRecognition: SpeechRecognition | null = null;
/**
* Start voice recognition and call back with transcriptions.
* Returns a stop function, or null if not supported.
*/
export function startVoiceRecognition(
callbacks: VoiceInputCallbacks,
): (() => void) | null {
const SpeechRecognitionClass = getSpeechRecognition();
if (!SpeechRecognitionClass) {
callbacks.onError("Voice input not supported in this browser");
return null;
}
// Stop any existing recognition
if (activeRecognition) {
activeRecognition.abort();
activeRecognition = null;
}
const recognition = new SpeechRecognitionClass();
activeRecognition = recognition;
// Configure recognition
recognition.continuous = false; // Single utterance mode for mobile
recognition.interimResults = true; // Show partial results
recognition.lang = navigator.language || "en-US";
recognition.onstart = () => {
callbacks.onStart();
};
recognition.onresult = (event: SpeechRecognitionEvent) => {
let finalTranscript = "";
let interimTranscript = "";
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (result[0]) {
if (result.isFinal) {
finalTranscript += result[0].transcript;
} else {
interimTranscript += result[0].transcript;
}
}
}
if (finalTranscript) {
callbacks.onTranscript(finalTranscript, true);
} else if (interimTranscript) {
callbacks.onTranscript(interimTranscript, false);
}
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
let errorMessage = "Voice recognition error";
switch (event.error) {
case "no-speech":
errorMessage = "No speech detected. Try again.";
break;
case "audio-capture":
errorMessage = "Microphone not available";
break;
case "not-allowed":
errorMessage = "Microphone access denied";
break;
case "network":
errorMessage = "Network error during recognition";
break;
case "aborted":
// User cancelled, not an error
return;
default:
errorMessage = `Recognition error: ${event.error}`;
}
callbacks.onError(errorMessage);
};
recognition.onend = () => {
if (activeRecognition === recognition) {
activeRecognition = null;
}
callbacks.onEnd();
};
try {
recognition.start();
} catch {
callbacks.onError("Failed to start voice recognition");
return null;
}
// Return stop function
return () => {
if (activeRecognition === recognition) {
recognition.stop();
activeRecognition = null;
}
};
}
/**
* Stop any active voice recognition.
*/
export function stopVoiceRecognition(): void {
if (activeRecognition) {
activeRecognition.stop();
activeRecognition = null;
}
}