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:
parent
1755ba52df
commit
e7728ff037
@ -18,6 +18,12 @@ Status: beta.
|
|||||||
- Refactor: add centralized error utilities with MoltbotError base class.
|
- Refactor: add centralized error utilities with MoltbotError base class.
|
||||||
- Refactor: extract EmbeddingService from memory manager (reduces manager.ts by 25%).
|
- Refactor: extract EmbeddingService from memory manager (reduces manager.ts by 25%).
|
||||||
- Docs: add large file analysis for refactoring opportunities.
|
- 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.
|
- 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: 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.
|
- macOS: finish Moltbot app rename for macOS sources, bundle identifiers, and shared kit paths. (#2844) Thanks @fal3.
|
||||||
|
|||||||
@ -21,5 +21,6 @@ export const makePrompter = (overrides: Partial<WizardPrompter> = {}): WizardPro
|
|||||||
text: vi.fn(async () => "") as WizardPrompter["text"],
|
text: vi.fn(async () => "") as WizardPrompter["text"],
|
||||||
confirm: vi.fn(async () => false),
|
confirm: vi.fn(async () => false),
|
||||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||||
|
setStepProgress: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { createCliProgress } from "../cli/progress.js";
|
|||||||
import { note as emitNote } from "../terminal/note.js";
|
import { note as emitNote } from "../terminal/note.js";
|
||||||
import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
|
import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
|
||||||
import { theme } from "../terminal/theme.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";
|
import { WizardCancelledError } from "./prompts.js";
|
||||||
|
|
||||||
function guardCancel<T>(value: T | symbol): T {
|
function guardCancel<T>(value: T | symbol): T {
|
||||||
@ -26,6 +26,21 @@ function guardCancel<T>(value: T | symbol): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createClackPrompter(): WizardPrompter {
|
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 {
|
return {
|
||||||
intro: async (title) => {
|
intro: async (title) => {
|
||||||
intro(stylePromptTitle(title) ?? title);
|
intro(stylePromptTitle(title) ?? title);
|
||||||
@ -36,8 +51,9 @@ export function createClackPrompter(): WizardPrompter {
|
|||||||
note: async (message, title) => {
|
note: async (message, title) => {
|
||||||
emitNote(message, title);
|
emitNote(message, title);
|
||||||
},
|
},
|
||||||
select: async (params) =>
|
select: async (params) => {
|
||||||
guardCancel(
|
showStepIndicator();
|
||||||
|
return guardCancel(
|
||||||
await select({
|
await select({
|
||||||
message: stylePromptMessage(params.message),
|
message: stylePromptMessage(params.message),
|
||||||
options: params.options.map((opt) => {
|
options: params.options.map((opt) => {
|
||||||
@ -46,9 +62,11 @@ export function createClackPrompter(): WizardPrompter {
|
|||||||
}) as Option<(typeof params.options)[number]["value"]>[],
|
}) as Option<(typeof params.options)[number]["value"]>[],
|
||||||
initialValue: params.initialValue,
|
initialValue: params.initialValue,
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
multiselect: async (params) =>
|
},
|
||||||
guardCancel(
|
multiselect: async (params) => {
|
||||||
|
showStepIndicator();
|
||||||
|
return guardCancel(
|
||||||
await multiselect({
|
await multiselect({
|
||||||
message: stylePromptMessage(params.message),
|
message: stylePromptMessage(params.message),
|
||||||
options: params.options.map((opt) => {
|
options: params.options.map((opt) => {
|
||||||
@ -57,23 +75,28 @@ export function createClackPrompter(): WizardPrompter {
|
|||||||
}) as Option<(typeof params.options)[number]["value"]>[],
|
}) as Option<(typeof params.options)[number]["value"]>[],
|
||||||
initialValues: params.initialValues,
|
initialValues: params.initialValues,
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
text: async (params) =>
|
},
|
||||||
guardCancel(
|
text: async (params) => {
|
||||||
|
showStepIndicator();
|
||||||
|
return guardCancel(
|
||||||
await text({
|
await text({
|
||||||
message: stylePromptMessage(params.message),
|
message: stylePromptMessage(params.message),
|
||||||
initialValue: params.initialValue,
|
initialValue: params.initialValue,
|
||||||
placeholder: params.placeholder,
|
placeholder: params.placeholder,
|
||||||
validate: params.validate,
|
validate: params.validate,
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
confirm: async (params) =>
|
},
|
||||||
guardCancel(
|
confirm: async (params) => {
|
||||||
|
showStepIndicator();
|
||||||
|
return guardCancel(
|
||||||
await confirm({
|
await confirm({
|
||||||
message: stylePromptMessage(params.message),
|
message: stylePromptMessage(params.message),
|
||||||
initialValue: params.initialValue,
|
initialValue: params.initialValue,
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
progress: (label: string): WizardProgress => {
|
progress: (label: string): WizardProgress => {
|
||||||
const spin = spinner();
|
const spin = spinner();
|
||||||
spin.start(theme.accent(label));
|
spin.start(theme.accent(label));
|
||||||
@ -94,5 +117,8 @@ export function createClackPrompter(): WizardPrompter {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
setStepProgress: (progress) => {
|
||||||
|
stepProgress = progress;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,6 +97,7 @@ describe("runOnboardingWizard", () => {
|
|||||||
text: vi.fn(async () => ""),
|
text: vi.fn(async () => ""),
|
||||||
confirm: vi.fn(async () => false),
|
confirm: vi.fn(async () => false),
|
||||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||||
|
setStepProgress: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
@ -140,6 +141,7 @@ describe("runOnboardingWizard", () => {
|
|||||||
text: vi.fn(async () => ""),
|
text: vi.fn(async () => ""),
|
||||||
confirm: vi.fn(async () => false),
|
confirm: vi.fn(async () => false),
|
||||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||||
|
setStepProgress: vi.fn(),
|
||||||
};
|
};
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
@ -191,6 +193,7 @@ describe("runOnboardingWizard", () => {
|
|||||||
text: vi.fn(async () => ""),
|
text: vi.fn(async () => ""),
|
||||||
confirm: vi.fn(async () => false),
|
confirm: vi.fn(async () => false),
|
||||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||||
|
setStepProgress: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
@ -246,6 +249,7 @@ describe("runOnboardingWizard", () => {
|
|||||||
text: vi.fn(async () => ""),
|
text: vi.fn(async () => ""),
|
||||||
confirm: vi.fn(async () => false),
|
confirm: vi.fn(async () => false),
|
||||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||||
|
setStepProgress: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
@ -297,6 +301,7 @@ describe("runOnboardingWizard", () => {
|
|||||||
text: vi.fn(async () => ""),
|
text: vi.fn(async () => ""),
|
||||||
confirm: vi.fn(async () => false),
|
confirm: vi.fn(async () => false),
|
||||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||||
|
setStepProgress: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
|
|||||||
@ -41,7 +41,29 @@ import { resolveUserPath } from "../utils.js";
|
|||||||
import { finalizeOnboardingWizard } from "./onboarding.finalize.js";
|
import { finalizeOnboardingWizard } from "./onboarding.finalize.js";
|
||||||
import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js";
|
import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js";
|
||||||
import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.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: {
|
async function requireRiskAcknowledgement(params: {
|
||||||
opts: OnboardOptions;
|
opts: OnboardOptions;
|
||||||
@ -91,6 +113,9 @@ export async function runOnboardingWizard(
|
|||||||
) {
|
) {
|
||||||
printWizardHeader(runtime);
|
printWizardHeader(runtime);
|
||||||
await prompter.intro("Moltbot onboarding");
|
await prompter.intro("Moltbot onboarding");
|
||||||
|
|
||||||
|
// Security acknowledgement (Step 1 for both flows)
|
||||||
|
prompter.setStepProgress(QUICKSTART_STEPS.SECURITY);
|
||||||
await requireRiskAcknowledgement({ opts, prompter });
|
await requireRiskAcknowledgement({ opts, prompter });
|
||||||
|
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
@ -151,6 +176,11 @@ export async function runOnboardingWizard(
|
|||||||
flow = "advanced";
|
flow = "advanced";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config handling step (Advanced flow only)
|
||||||
|
if (snapshot.exists && flow === "advanced") {
|
||||||
|
prompter.setStepProgress(ADVANCED_STEPS.CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
if (snapshot.exists) {
|
if (snapshot.exists) {
|
||||||
await prompter.note(summarizeExistingConfig(baseConfig), "Existing config detected");
|
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, {
|
const authStore = ensureAuthProfileStore(undefined, {
|
||||||
allowKeychainPrompt: false,
|
allowKeychainPrompt: false,
|
||||||
});
|
});
|
||||||
@ -390,6 +423,11 @@ export async function runOnboardingWizard(
|
|||||||
|
|
||||||
await warnIfModelConfigLooksOff(nextConfig, prompter);
|
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({
|
const gateway = await configureGatewayForOnboarding({
|
||||||
flow,
|
flow,
|
||||||
baseConfig,
|
baseConfig,
|
||||||
@ -402,6 +440,11 @@ export async function runOnboardingWizard(
|
|||||||
nextConfig = gateway.nextConfig;
|
nextConfig = gateway.nextConfig;
|
||||||
const settings = gateway.settings;
|
const settings = gateway.settings;
|
||||||
|
|
||||||
|
// Channels step
|
||||||
|
prompter.setStepProgress(
|
||||||
|
flow === "quickstart" ? QUICKSTART_STEPS.CHANNELS : ADVANCED_STEPS.CHANNELS,
|
||||||
|
);
|
||||||
|
|
||||||
if (opts.skipChannels ?? opts.skipProviders) {
|
if (opts.skipChannels ?? opts.skipProviders) {
|
||||||
await prompter.note("Skipping channel setup.", "Channels");
|
await prompter.note("Skipping channel setup.", "Channels");
|
||||||
} else {
|
} else {
|
||||||
@ -426,6 +469,9 @@ export async function runOnboardingWizard(
|
|||||||
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Skills step
|
||||||
|
prompter.setStepProgress(flow === "quickstart" ? QUICKSTART_STEPS.SKILLS : ADVANCED_STEPS.SKILLS);
|
||||||
|
|
||||||
if (opts.skipSkills) {
|
if (opts.skipSkills) {
|
||||||
await prompter.note("Skipping skills setup.", "Skills");
|
await prompter.note("Skipping skills setup.", "Skills");
|
||||||
} else {
|
} else {
|
||||||
@ -438,6 +484,11 @@ export async function runOnboardingWizard(
|
|||||||
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
||||||
await writeConfigFile(nextConfig);
|
await writeConfigFile(nextConfig);
|
||||||
|
|
||||||
|
// Finalize step
|
||||||
|
prompter.setStepProgress(
|
||||||
|
flow === "quickstart" ? QUICKSTART_STEPS.FINALIZE : ADVANCED_STEPS.FINALIZE,
|
||||||
|
);
|
||||||
|
|
||||||
await finalizeOnboardingWizard({
|
await finalizeOnboardingWizard({
|
||||||
flow,
|
flow,
|
||||||
opts,
|
opts,
|
||||||
|
|||||||
@ -33,6 +33,15 @@ export type WizardProgress = {
|
|||||||
stop: (message?: string) => void;
|
stop: (message?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks overall wizard progress (step X of Y).
|
||||||
|
*/
|
||||||
|
export type StepProgress = {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WizardPrompter = {
|
export type WizardPrompter = {
|
||||||
intro: (title: string) => Promise<void>;
|
intro: (title: string) => Promise<void>;
|
||||||
outro: (message: string) => Promise<void>;
|
outro: (message: string) => Promise<void>;
|
||||||
@ -42,6 +51,11 @@ export type WizardPrompter = {
|
|||||||
text: (params: WizardTextParams) => Promise<string>;
|
text: (params: WizardTextParams) => Promise<string>;
|
||||||
confirm: (params: WizardConfirmParams) => Promise<boolean>;
|
confirm: (params: WizardConfirmParams) => Promise<boolean>;
|
||||||
progress: (label: string) => WizardProgress;
|
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 {
|
export class WizardCancelledError extends Error {
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
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 = {
|
export type WizardStepOption = {
|
||||||
value: unknown;
|
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> {
|
private async prompt(step: Omit<WizardStep, "id">): Promise<unknown> {
|
||||||
return await this.session.awaitAnswer({
|
return await this.session.awaitAnswer({
|
||||||
...step,
|
...step,
|
||||||
|
|||||||
@ -281,6 +281,74 @@
|
|||||||
box-sizing: border-box;
|
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 - moved to content-header area, left aligned */
|
||||||
.chat-controls {
|
.chat-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -274,6 +274,143 @@
|
|||||||
stroke-linejoin: round;
|
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
|
Status Dot - With glow for emphasis
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|||||||
@ -1445,3 +1445,179 @@
|
|||||||
min-width: 70px;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -372,3 +372,257 @@
|
|||||||
height: 11px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { html } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import { repeat } from "lit/directives/repeat.js";
|
import { repeat } from "lit/directives/repeat.js";
|
||||||
|
|
||||||
import type { AppViewState } from "./app-view-state";
|
import type { AppViewState } from "./app-view-state";
|
||||||
@ -6,9 +6,10 @@ import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
|
|||||||
import { icons } from "./icons";
|
import { icons } from "./icons";
|
||||||
import { loadChatHistory } from "./controllers/chat";
|
import { loadChatHistory } from "./controllers/chat";
|
||||||
import { syncUrlWithSessionKey } from "./app-settings";
|
import { syncUrlWithSessionKey } from "./app-settings";
|
||||||
import type { SessionsListResult } from "./types";
|
import type { SessionsListResult, SkillStatusEntry } from "./types";
|
||||||
import type { ThemeMode } from "./theme";
|
import type { ThemeMode } from "./theme";
|
||||||
import type { ThemeTransitionContext } from "./theme-transition";
|
import type { ThemeTransitionContext } from "./theme-transition";
|
||||||
|
import { updateSkillEnabled } from "./controllers/skills";
|
||||||
|
|
||||||
export function renderTab(state: AppViewState, tab: Tab) {
|
export function renderTab(state: AppViewState, tab: Tab) {
|
||||||
const href = pathForTab(tab, state.basePath);
|
const href = pathForTab(tab, state.basePath);
|
||||||
@ -240,3 +241,79 @@ function renderMonitorIcon() {
|
|||||||
</svg>
|
</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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|||||||
@ -50,7 +50,7 @@ import {
|
|||||||
rotateDeviceToken,
|
rotateDeviceToken,
|
||||||
} from "./controllers/devices";
|
} from "./controllers/devices";
|
||||||
import { renderSkills } from "./views/skills";
|
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 { loadChannels } from "./controllers/channels";
|
||||||
import { loadPresence } from "./controllers/presence";
|
import { loadPresence } from "./controllers/presence";
|
||||||
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
|
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
|
||||||
@ -66,12 +66,14 @@ import { loadNodes } from "./controllers/nodes";
|
|||||||
import { loadChatHistory } from "./controllers/chat";
|
import { loadChatHistory } from "./controllers/chat";
|
||||||
import {
|
import {
|
||||||
applyConfig,
|
applyConfig,
|
||||||
|
applyConfigPreset,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
runUpdate,
|
runUpdate,
|
||||||
saveConfig,
|
saveConfig,
|
||||||
updateConfigFormValue,
|
updateConfigFormValue,
|
||||||
removeConfigFormValue,
|
removeConfigFormValue,
|
||||||
} from "./controllers/config";
|
} from "./controllers/config";
|
||||||
|
import { getPreset } from "./config-presets";
|
||||||
import {
|
import {
|
||||||
loadExecApprovals,
|
loadExecApprovals,
|
||||||
removeExecApprovalsFormValue,
|
removeExecApprovalsFormValue,
|
||||||
@ -143,6 +145,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
<span>Health</span>
|
<span>Health</span>
|
||||||
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
|
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${renderSkillsQuickToggle(state)}
|
||||||
${renderThemeToggle(state)}
|
${renderThemeToggle(state)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -480,6 +483,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
onDraftChange: (next) => (state.chatMessage = next),
|
onDraftChange: (next) => (state.chatMessage = next),
|
||||||
attachments: state.chatAttachments,
|
attachments: state.chatAttachments,
|
||||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||||
|
// Voice input props
|
||||||
|
voiceRecording: state.voiceRecording,
|
||||||
|
voiceInterim: state.voiceInterim,
|
||||||
|
voiceError: state.voiceError,
|
||||||
|
onVoiceToggle: () => state.handleVoiceToggle(),
|
||||||
onSend: () => state.handleSendChat(),
|
onSend: () => state.handleSendChat(),
|
||||||
canAbort: Boolean(state.chatRunId),
|
canAbort: Boolean(state.chatRunId),
|
||||||
onAbort: () => void state.handleAbortChat(),
|
onAbort: () => void state.handleAbortChat(),
|
||||||
@ -530,10 +538,22 @@ export function renderApp(state: AppViewState) {
|
|||||||
state.configActiveSubsection = null;
|
state.configActiveSubsection = null;
|
||||||
},
|
},
|
||||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||||
|
presetPanelOpen: state.configPresetPanelOpen,
|
||||||
onReload: () => loadConfig(state),
|
onReload: () => loadConfig(state),
|
||||||
onSave: () => saveConfig(state),
|
onSave: () => saveConfig(state),
|
||||||
onApply: () => applyConfig(state),
|
onApply: () => applyConfig(state),
|
||||||
onUpdate: () => runUpdate(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}
|
: nothing}
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,10 @@ import {
|
|||||||
handleSendChat as handleSendChatInternal,
|
handleSendChat as handleSendChatInternal,
|
||||||
removeQueuedMessage as removeQueuedMessageInternal,
|
removeQueuedMessage as removeQueuedMessageInternal,
|
||||||
} from "./app-chat";
|
} from "./app-chat";
|
||||||
|
import {
|
||||||
|
startVoiceRecognition,
|
||||||
|
stopVoiceRecognition,
|
||||||
|
} from "./voice-input";
|
||||||
import {
|
import {
|
||||||
handleChannelConfigReload as handleChannelConfigReloadInternal,
|
handleChannelConfigReload as handleChannelConfigReloadInternal,
|
||||||
handleChannelConfigSave as handleChannelConfigSaveInternal,
|
handleChannelConfigSave as handleChannelConfigSaveInternal,
|
||||||
@ -130,6 +134,11 @@ export class MoltbotApp extends LitElement {
|
|||||||
@state() chatThinkingLevel: string | null = null;
|
@state() chatThinkingLevel: string | null = null;
|
||||||
@state() chatQueue: ChatQueueItem[] = [];
|
@state() chatQueue: ChatQueueItem[] = [];
|
||||||
@state() chatAttachments: ChatAttachment[] = [];
|
@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
|
// Sidebar state for tool output viewing
|
||||||
@state() sidebarOpen = false;
|
@state() sidebarOpen = false;
|
||||||
@state() sidebarContent: string | null = null;
|
@state() sidebarContent: string | null = null;
|
||||||
@ -174,6 +183,8 @@ export class MoltbotApp extends LitElement {
|
|||||||
@state() configSearchQuery = "";
|
@state() configSearchQuery = "";
|
||||||
@state() configActiveSection: string | null = null;
|
@state() configActiveSection: string | null = null;
|
||||||
@state() configActiveSubsection: string | null = null;
|
@state() configActiveSubsection: string | null = null;
|
||||||
|
@state() configPresetPanelOpen = false;
|
||||||
|
@state() configViewLevel: "basic" | "standard" | "advanced" = "standard";
|
||||||
|
|
||||||
@state() channelsLoading = false;
|
@state() channelsLoading = false;
|
||||||
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
|
@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) {
|
async handleWhatsAppStart(force: boolean) {
|
||||||
await handleWhatsAppStartInternal(this, force);
|
await handleWhatsAppStartInternal(this, force);
|
||||||
}
|
}
|
||||||
|
|||||||
163
ui/src/ui/config-presets.ts
Normal file
163
ui/src/ui/config-presets.ts
Normal 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);
|
||||||
|
}
|
||||||
176
ui/src/ui/config-view-levels.ts
Normal file
176
ui/src/ui/config-view-levels.ts
Normal 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 ?? "";
|
||||||
|
}
|
||||||
@ -200,3 +200,51 @@ export function removeConfigFormValue(
|
|||||||
state.configRaw = serializeConfigForm(base);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>`,
|
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>`,
|
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>`,
|
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;
|
} as const;
|
||||||
|
|
||||||
export type IconName = keyof typeof icons;
|
export type IconName = keyof typeof icons;
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export type UiSettings = {
|
|||||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||||
navCollapsed: boolean; // Collapsible sidebar state
|
navCollapsed: boolean; // Collapsible sidebar state
|
||||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||||
|
skillsPanelOpen: boolean; // Skills quick-toggle panel state
|
||||||
};
|
};
|
||||||
|
|
||||||
export function loadSettings(): UiSettings {
|
export function loadSettings(): UiSettings {
|
||||||
@ -32,6 +33,7 @@ export function loadSettings(): UiSettings {
|
|||||||
splitRatio: 0.6,
|
splitRatio: 0.6,
|
||||||
navCollapsed: false,
|
navCollapsed: false,
|
||||||
navGroupsCollapsed: {},
|
navGroupsCollapsed: {},
|
||||||
|
skillsPanelOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -84,6 +86,10 @@ export function loadSettings(): UiSettings {
|
|||||||
parsed.navGroupsCollapsed !== null
|
parsed.navGroupsCollapsed !== null
|
||||||
? parsed.navGroupsCollapsed
|
? parsed.navGroupsCollapsed
|
||||||
: defaults.navGroupsCollapsed,
|
: defaults.navGroupsCollapsed,
|
||||||
|
skillsPanelOpen:
|
||||||
|
typeof parsed.skillsPanelOpen === "boolean"
|
||||||
|
? parsed.skillsPanelOpen
|
||||||
|
: defaults.skillsPanelOpen,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return defaults;
|
return defaults;
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
renderStreamingGroup,
|
renderStreamingGroup,
|
||||||
} from "../chat/grouped-render";
|
} from "../chat/grouped-render";
|
||||||
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
||||||
|
import { isVoiceInputSupported } from "../voice-input";
|
||||||
import "../components/resizable-divider";
|
import "../components/resizable-divider";
|
||||||
|
|
||||||
export type CompactionIndicatorStatus = {
|
export type CompactionIndicatorStatus = {
|
||||||
@ -56,6 +57,11 @@ export type ChatProps = {
|
|||||||
// Image attachments
|
// Image attachments
|
||||||
attachments?: ChatAttachment[];
|
attachments?: ChatAttachment[];
|
||||||
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
||||||
|
// Voice input state
|
||||||
|
voiceRecording?: boolean;
|
||||||
|
voiceInterim?: string;
|
||||||
|
voiceError?: string | null;
|
||||||
|
onVoiceToggle?: () => void;
|
||||||
// Event handlers
|
// Event handlers
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onToggleFocusMode: () => void;
|
onToggleFocusMode: () => void;
|
||||||
@ -329,12 +335,17 @@ export function renderChat(props: ChatProps) {
|
|||||||
|
|
||||||
<div class="chat-compose">
|
<div class="chat-compose">
|
||||||
${renderAttachmentPreview(props)}
|
${renderAttachmentPreview(props)}
|
||||||
|
${props.voiceError
|
||||||
|
? html`<div class="chat-voice-error">${props.voiceError}</div>`
|
||||||
|
: nothing}
|
||||||
<div class="chat-compose__row">
|
<div class="chat-compose__row">
|
||||||
<label class="field chat-compose__field">
|
<label class="field chat-compose__field">
|
||||||
<span>Message</span>
|
<span>Message</span>
|
||||||
<textarea
|
<textarea
|
||||||
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
|
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
|
||||||
.value=${props.draft}
|
.value=${props.voiceRecording && props.voiceInterim
|
||||||
|
? props.draft + props.voiceInterim
|
||||||
|
: props.draft}
|
||||||
?disabled=${!props.connected}
|
?disabled=${!props.connected}
|
||||||
@keydown=${(e: KeyboardEvent) => {
|
@keydown=${(e: KeyboardEvent) => {
|
||||||
if (e.key !== "Enter") return;
|
if (e.key !== "Enter") return;
|
||||||
@ -350,9 +361,31 @@ export function renderChat(props: ChatProps) {
|
|||||||
props.onDraftChange(target.value);
|
props.onDraftChange(target.value);
|
||||||
}}
|
}}
|
||||||
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
|
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
|
||||||
placeholder=${composePlaceholder}
|
placeholder=${props.voiceRecording
|
||||||
|
? "Listening..."
|
||||||
|
: composePlaceholder}
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</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">
|
<div class="chat-compose__actions">
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
|
|||||||
@ -7,6 +7,12 @@ import {
|
|||||||
schemaType,
|
schemaType,
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
} from "./config-form.shared";
|
} 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 = {
|
export type ConfigProps = {
|
||||||
raw: string;
|
raw: string;
|
||||||
@ -27,6 +33,8 @@ export type ConfigProps = {
|
|||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
activeSection: string | null;
|
activeSection: string | null;
|
||||||
activeSubsection: string | null;
|
activeSubsection: string | null;
|
||||||
|
presetPanelOpen: boolean;
|
||||||
|
viewLevel: "basic" | "standard" | "advanced";
|
||||||
onRawChange: (next: string) => void;
|
onRawChange: (next: string) => void;
|
||||||
onFormModeChange: (mode: "form" | "raw") => void;
|
onFormModeChange: (mode: "form" | "raw") => void;
|
||||||
onFormPatch: (path: Array<string | number>, value: unknown) => void;
|
onFormPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
@ -37,6 +45,9 @@ export type ConfigProps = {
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
|
onApplyPreset: (presetId: string) => void;
|
||||||
|
onTogglePresetPanel: () => void;
|
||||||
|
onViewLevelChange: (level: "basic" | "standard" | "advanced") => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// SVG Icons for sidebar (Lucide-style)
|
// SVG Icons for sidebar (Lucide-style)
|
||||||
@ -181,6 +192,57 @@ function truncateValue(value: unknown, maxLen = 40): string {
|
|||||||
return str.slice(0, maxLen - 3) + "...";
|
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) {
|
export function renderConfig(props: ConfigProps) {
|
||||||
const validity =
|
const validity =
|
||||||
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
|
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
|
||||||
@ -201,6 +263,11 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
|
|
||||||
const allSections = [...availableSections, ...extraSections];
|
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 =
|
const activeSectionSchema =
|
||||||
props.activeSection && analysis.schema && schemaType(analysis.schema) === "object"
|
props.activeSection && analysis.schema && schemaType(analysis.schema) === "object"
|
||||||
? (analysis.schema.properties?.[props.activeSection] as JsonSchema | undefined)
|
? (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__icon">${sidebarIcons.all}</span>
|
||||||
<span class="config-nav__label">All Settings</span>
|
<span class="config-nav__label">All Settings</span>
|
||||||
</button>
|
</button>
|
||||||
${allSections.map(section => html`
|
${visibleSections.map(section => html`
|
||||||
<button
|
<button
|
||||||
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
|
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
|
||||||
@click=${() => props.onSectionChange(section.key)}
|
@click=${() => props.onSectionChange(section.key)}
|
||||||
@ -300,8 +367,24 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
`)}
|
`)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mode toggle at bottom -->
|
<!-- View level and mode toggle at bottom -->
|
||||||
<div class="config-sidebar__footer">
|
<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">
|
<div class="config-mode-toggle">
|
||||||
<button
|
<button
|
||||||
class="config-mode-toggle__btn ${props.formMode === "form" ? "active" : ""}"
|
class="config-mode-toggle__btn ${props.formMode === "form" ? "active" : ""}"
|
||||||
@ -325,6 +408,7 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
<!-- Action bar -->
|
<!-- Action bar -->
|
||||||
<div class="config-actions">
|
<div class="config-actions">
|
||||||
<div class="config-actions__left">
|
<div class="config-actions__left">
|
||||||
|
${renderPresetSelector(props)}
|
||||||
${hasChanges ? html`
|
${hasChanges ? html`
|
||||||
<span class="config-changes-badge">${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span>
|
<span class="config-changes-badge">${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span>
|
||||||
` : html`
|
` : html`
|
||||||
|
|||||||
200
ui/src/ui/voice-input.ts
Normal file
200
ui/src/ui/voice-input.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user