Resources
@@ -200,383 +215,383 @@ export function renderApp(state: AppViewState) {
${state.lastError
- ? html`
${state.lastError}
`
- : nothing}
+ ? html`
${state.lastError}`
+ : nothing}
${isChat ? renderChatControls(state) : nothing}
${state.tab === "overview"
- ? renderOverview({
- connected: state.connected,
- hello: state.hello,
- settings: state.settings,
- password: state.password,
- lastError: state.lastError,
- presenceCount,
- sessionsCount,
- cronEnabled: state.cronStatus?.enabled ?? null,
- cronNext,
- lastChannelsRefresh: state.channelsLastSuccess,
- onSettingsChange: (next) => state.applySettings(next),
- onPasswordChange: (next) => (state.password = next),
- onSessionKeyChange: (next) => {
- state.sessionKey = next;
- state.chatMessage = "";
- state.resetToolStream();
- state.applySettings({
- ...state.settings,
- sessionKey: next,
- lastActiveSessionKey: next,
- });
- void state.loadAssistantIdentity();
- },
- onConnect: () => state.connect(),
- onRefresh: () => state.loadOverview(),
- })
- : nothing}
+ ? renderOverview({
+ connected: state.connected,
+ hello: state.hello,
+ settings: state.settings,
+ password: state.password,
+ lastError: state.lastError,
+ presenceCount,
+ sessionsCount,
+ cronEnabled: state.cronStatus?.enabled ?? null,
+ cronNext,
+ lastChannelsRefresh: state.channelsLastSuccess,
+ onSettingsChange: (next) => state.applySettings(next),
+ onPasswordChange: (next) => (state.password = next),
+ onSessionKeyChange: (next) => {
+ state.sessionKey = next;
+ state.chatMessage = "";
+ state.resetToolStream();
+ state.applySettings({
+ ...state.settings,
+ sessionKey: next,
+ lastActiveSessionKey: next,
+ });
+ void state.loadAssistantIdentity();
+ },
+ onConnect: () => state.connect(),
+ onRefresh: () => state.loadOverview(),
+ })
+ : nothing}
${state.tab === "channels"
- ? renderChannels({
- connected: state.connected,
- loading: state.channelsLoading,
- snapshot: state.channelsSnapshot,
- lastError: state.channelsError,
- lastSuccessAt: state.channelsLastSuccess,
- whatsappMessage: state.whatsappLoginMessage,
- whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
- whatsappConnected: state.whatsappLoginConnected,
- whatsappBusy: state.whatsappBusy,
- configSchema: state.configSchema,
- configSchemaLoading: state.configSchemaLoading,
- configForm: state.configForm,
- configUiHints: state.configUiHints,
- configSaving: state.configSaving,
- configFormDirty: state.configFormDirty,
- nostrProfileFormState: state.nostrProfileFormState,
- nostrProfileAccountId: state.nostrProfileAccountId,
- onRefresh: (probe) => loadChannels(state, probe),
- onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
- onWhatsAppWait: () => state.handleWhatsAppWait(),
- onWhatsAppLogout: () => state.handleWhatsAppLogout(),
- onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
- onConfigSave: () => state.handleChannelConfigSave(),
- onConfigReload: () => state.handleChannelConfigReload(),
- onNostrProfileEdit: (accountId, profile) =>
- state.handleNostrProfileEdit(accountId, profile),
- onNostrProfileCancel: () => state.handleNostrProfileCancel(),
- onNostrProfileFieldChange: (field, value) =>
- state.handleNostrProfileFieldChange(field, value),
- onNostrProfileSave: () => state.handleNostrProfileSave(),
- onNostrProfileImport: () => state.handleNostrProfileImport(),
- onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
- })
- : nothing}
+ ? renderChannels({
+ connected: state.connected,
+ loading: state.channelsLoading,
+ snapshot: state.channelsSnapshot,
+ lastError: state.channelsError,
+ lastSuccessAt: state.channelsLastSuccess,
+ whatsappMessage: state.whatsappLoginMessage,
+ whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
+ whatsappConnected: state.whatsappLoginConnected,
+ whatsappBusy: state.whatsappBusy,
+ configSchema: state.configSchema,
+ configSchemaLoading: state.configSchemaLoading,
+ configForm: state.configForm,
+ configUiHints: state.configUiHints,
+ configSaving: state.configSaving,
+ configFormDirty: state.configFormDirty,
+ nostrProfileFormState: state.nostrProfileFormState,
+ nostrProfileAccountId: state.nostrProfileAccountId,
+ onRefresh: (probe) => loadChannels(state, probe),
+ onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
+ onWhatsAppWait: () => state.handleWhatsAppWait(),
+ onWhatsAppLogout: () => state.handleWhatsAppLogout(),
+ onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
+ onConfigSave: () => state.handleChannelConfigSave(),
+ onConfigReload: () => state.handleChannelConfigReload(),
+ onNostrProfileEdit: (accountId, profile) =>
+ state.handleNostrProfileEdit(accountId, profile),
+ onNostrProfileCancel: () => state.handleNostrProfileCancel(),
+ onNostrProfileFieldChange: (field, value) =>
+ state.handleNostrProfileFieldChange(field, value),
+ onNostrProfileSave: () => state.handleNostrProfileSave(),
+ onNostrProfileImport: () => state.handleNostrProfileImport(),
+ onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
+ })
+ : nothing}
${state.tab === "instances"
- ? renderInstances({
- loading: state.presenceLoading,
- entries: state.presenceEntries,
- lastError: state.presenceError,
- statusMessage: state.presenceStatus,
- onRefresh: () => loadPresence(state),
- })
- : nothing}
+ ? renderInstances({
+ loading: state.presenceLoading,
+ entries: state.presenceEntries,
+ lastError: state.presenceError,
+ statusMessage: state.presenceStatus,
+ onRefresh: () => loadPresence(state),
+ })
+ : nothing}
${state.tab === "sessions"
- ? renderSessions({
- loading: state.sessionsLoading,
- result: state.sessionsResult,
- error: state.sessionsError,
- activeMinutes: state.sessionsFilterActive,
- limit: state.sessionsFilterLimit,
- includeGlobal: state.sessionsIncludeGlobal,
- includeUnknown: state.sessionsIncludeUnknown,
- basePath: state.basePath,
- onFiltersChange: (next) => {
- state.sessionsFilterActive = next.activeMinutes;
- state.sessionsFilterLimit = next.limit;
- state.sessionsIncludeGlobal = next.includeGlobal;
- state.sessionsIncludeUnknown = next.includeUnknown;
- },
- onRefresh: () => loadSessions(state),
- onPatch: (key, patch) => patchSession(state, key, patch),
- onDelete: (key) => deleteSession(state, key),
- })
- : nothing}
+ ? renderSessions({
+ loading: state.sessionsLoading,
+ result: state.sessionsResult,
+ error: state.sessionsError,
+ activeMinutes: state.sessionsFilterActive,
+ limit: state.sessionsFilterLimit,
+ includeGlobal: state.sessionsIncludeGlobal,
+ includeUnknown: state.sessionsIncludeUnknown,
+ basePath: state.basePath,
+ onFiltersChange: (next) => {
+ state.sessionsFilterActive = next.activeMinutes;
+ state.sessionsFilterLimit = next.limit;
+ state.sessionsIncludeGlobal = next.includeGlobal;
+ state.sessionsIncludeUnknown = next.includeUnknown;
+ },
+ onRefresh: () => loadSessions(state),
+ onPatch: (key, patch) => patchSession(state, key, patch),
+ onDelete: (key) => deleteSession(state, key),
+ })
+ : nothing}
${state.tab === "cron"
- ? renderCron({
- loading: state.cronLoading,
- status: state.cronStatus,
- jobs: state.cronJobs,
- error: state.cronError,
- busy: state.cronBusy,
- form: state.cronForm,
- channels: state.channelsSnapshot?.channelMeta?.length
- ? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
- : state.channelsSnapshot?.channelOrder ?? [],
- channelLabels: state.channelsSnapshot?.channelLabels ?? {},
- channelMeta: state.channelsSnapshot?.channelMeta ?? [],
- runsJobId: state.cronRunsJobId,
- runs: state.cronRuns,
- onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
- onRefresh: () => state.loadCron(),
- onAdd: () => addCronJob(state),
- onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
- onRun: (job) => runCronJob(state, job),
- onRemove: (job) => removeCronJob(state, job),
- onLoadRuns: (jobId) => loadCronRuns(state, jobId),
- })
- : nothing}
+ ? renderCron({
+ loading: state.cronLoading,
+ status: state.cronStatus,
+ jobs: state.cronJobs,
+ error: state.cronError,
+ busy: state.cronBusy,
+ form: state.cronForm,
+ channels: state.channelsSnapshot?.channelMeta?.length
+ ? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
+ : state.channelsSnapshot?.channelOrder ?? [],
+ channelLabels: state.channelsSnapshot?.channelLabels ?? {},
+ channelMeta: state.channelsSnapshot?.channelMeta ?? [],
+ runsJobId: state.cronRunsJobId,
+ runs: state.cronRuns,
+ onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
+ onRefresh: () => state.loadCron(),
+ onAdd: () => addCronJob(state),
+ onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
+ onRun: (job) => runCronJob(state, job),
+ onRemove: (job) => removeCronJob(state, job),
+ onLoadRuns: (jobId) => loadCronRuns(state, jobId),
+ })
+ : nothing}
${state.tab === "skills"
- ? renderSkills({
- loading: state.skillsLoading,
- report: state.skillsReport,
- error: state.skillsError,
- filter: state.skillsFilter,
- edits: state.skillEdits,
- messages: state.skillMessages,
- busyKey: state.skillsBusyKey,
- onFilterChange: (next) => (state.skillsFilter = next),
- onRefresh: () => loadSkills(state, { clearMessages: true }),
- onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
- onEdit: (key, value) => updateSkillEdit(state, key, value),
- onSaveKey: (key) => saveSkillApiKey(state, key),
- onInstall: (skillKey, name, installId) =>
- installSkill(state, skillKey, name, installId),
- })
- : nothing}
+ ? renderSkills({
+ loading: state.skillsLoading,
+ report: state.skillsReport,
+ error: state.skillsError,
+ filter: state.skillsFilter,
+ edits: state.skillEdits,
+ messages: state.skillMessages,
+ busyKey: state.skillsBusyKey,
+ onFilterChange: (next) => (state.skillsFilter = next),
+ onRefresh: () => loadSkills(state, { clearMessages: true }),
+ onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
+ onEdit: (key, value) => updateSkillEdit(state, key, value),
+ onSaveKey: (key) => saveSkillApiKey(state, key),
+ onInstall: (skillKey, name, installId) =>
+ installSkill(state, skillKey, name, installId),
+ })
+ : nothing}
${state.tab === "nodes"
- ? renderNodes({
- loading: state.nodesLoading,
- nodes: state.nodes,
- devicesLoading: state.devicesLoading,
- devicesError: state.devicesError,
- devicesList: state.devicesList,
- configForm: state.configForm ?? (state.configSnapshot?.config as Record
| null),
- configLoading: state.configLoading,
- configSaving: state.configSaving,
- configDirty: state.configFormDirty,
- configFormMode: state.configFormMode,
- execApprovalsLoading: state.execApprovalsLoading,
- execApprovalsSaving: state.execApprovalsSaving,
- execApprovalsDirty: state.execApprovalsDirty,
- execApprovalsSnapshot: state.execApprovalsSnapshot,
- execApprovalsForm: state.execApprovalsForm,
- execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
- execApprovalsTarget: state.execApprovalsTarget,
- execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
- onRefresh: () => loadNodes(state),
- onDevicesRefresh: () => loadDevices(state),
- onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
- onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
- onDeviceRotate: (deviceId, role, scopes) =>
- rotateDeviceToken(state, { deviceId, role, scopes }),
- onDeviceRevoke: (deviceId, role) =>
- revokeDeviceToken(state, { deviceId, role }),
- onLoadConfig: () => loadConfig(state),
- onLoadExecApprovals: () => {
- const target =
- state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
- ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
- : { kind: "gateway" as const };
- return loadExecApprovals(state, target);
- },
- onBindDefault: (nodeId) => {
- if (nodeId) {
- updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
- } else {
- removeConfigFormValue(state, ["tools", "exec", "node"]);
- }
- },
- onBindAgent: (agentIndex, nodeId) => {
- const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
- if (nodeId) {
- updateConfigFormValue(state, basePath, nodeId);
- } else {
- removeConfigFormValue(state, basePath);
- }
- },
- onSaveBindings: () => saveConfig(state),
- onExecApprovalsTargetChange: (kind, nodeId) => {
- state.execApprovalsTarget = kind;
- state.execApprovalsTargetNodeId = nodeId;
- state.execApprovalsSnapshot = null;
- state.execApprovalsForm = null;
- state.execApprovalsDirty = false;
- state.execApprovalsSelectedAgent = null;
- },
- onExecApprovalsSelectAgent: (agentId) => {
- state.execApprovalsSelectedAgent = agentId;
- },
- onExecApprovalsPatch: (path, value) =>
- updateExecApprovalsFormValue(state, path, value),
- onExecApprovalsRemove: (path) =>
- removeExecApprovalsFormValue(state, path),
- onSaveExecApprovals: () => {
- const target =
- state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
- ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
- : { kind: "gateway" as const };
- return saveExecApprovals(state, target);
- },
- })
- : nothing}
+ ? renderNodes({
+ loading: state.nodesLoading,
+ nodes: state.nodes,
+ devicesLoading: state.devicesLoading,
+ devicesError: state.devicesError,
+ devicesList: state.devicesList,
+ configForm: state.configForm ?? (state.configSnapshot?.config as Record | null),
+ configLoading: state.configLoading,
+ configSaving: state.configSaving,
+ configDirty: state.configFormDirty,
+ configFormMode: state.configFormMode,
+ execApprovalsLoading: state.execApprovalsLoading,
+ execApprovalsSaving: state.execApprovalsSaving,
+ execApprovalsDirty: state.execApprovalsDirty,
+ execApprovalsSnapshot: state.execApprovalsSnapshot,
+ execApprovalsForm: state.execApprovalsForm,
+ execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
+ execApprovalsTarget: state.execApprovalsTarget,
+ execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
+ onRefresh: () => loadNodes(state),
+ onDevicesRefresh: () => loadDevices(state),
+ onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
+ onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
+ onDeviceRotate: (deviceId, role, scopes) =>
+ rotateDeviceToken(state, { deviceId, role, scopes }),
+ onDeviceRevoke: (deviceId, role) =>
+ revokeDeviceToken(state, { deviceId, role }),
+ onLoadConfig: () => loadConfig(state),
+ onLoadExecApprovals: () => {
+ const target =
+ state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
+ ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
+ : { kind: "gateway" as const };
+ return loadExecApprovals(state, target);
+ },
+ onBindDefault: (nodeId) => {
+ if (nodeId) {
+ updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
+ } else {
+ removeConfigFormValue(state, ["tools", "exec", "node"]);
+ }
+ },
+ onBindAgent: (agentIndex, nodeId) => {
+ const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
+ if (nodeId) {
+ updateConfigFormValue(state, basePath, nodeId);
+ } else {
+ removeConfigFormValue(state, basePath);
+ }
+ },
+ onSaveBindings: () => saveConfig(state),
+ onExecApprovalsTargetChange: (kind, nodeId) => {
+ state.execApprovalsTarget = kind;
+ state.execApprovalsTargetNodeId = nodeId;
+ state.execApprovalsSnapshot = null;
+ state.execApprovalsForm = null;
+ state.execApprovalsDirty = false;
+ state.execApprovalsSelectedAgent = null;
+ },
+ onExecApprovalsSelectAgent: (agentId) => {
+ state.execApprovalsSelectedAgent = agentId;
+ },
+ onExecApprovalsPatch: (path, value) =>
+ updateExecApprovalsFormValue(state, path, value),
+ onExecApprovalsRemove: (path) =>
+ removeExecApprovalsFormValue(state, path),
+ onSaveExecApprovals: () => {
+ const target =
+ state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
+ ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
+ : { kind: "gateway" as const };
+ return saveExecApprovals(state, target);
+ },
+ })
+ : nothing}
${state.tab === "chat"
- ? renderChat({
- sessionKey: state.sessionKey,
- onSessionKeyChange: (next) => {
- state.sessionKey = next;
- state.chatMessage = "";
- state.chatAttachments = [];
- state.chatStream = null;
- state.chatStreamStartedAt = null;
- state.chatRunId = null;
- state.chatQueue = [];
- state.resetToolStream();
- state.resetChatScroll();
- state.applySettings({
- ...state.settings,
- sessionKey: next,
- lastActiveSessionKey: next,
- });
- void state.loadAssistantIdentity();
- void loadChatHistory(state);
- void refreshChatAvatar(state);
- },
- thinkingLevel: state.chatThinkingLevel,
- showThinking,
- loading: state.chatLoading,
- sending: state.chatSending,
- compactionStatus: state.compactionStatus,
- assistantAvatarUrl: chatAvatarUrl,
- messages: state.chatMessages,
- toolMessages: state.chatToolMessages,
- stream: state.chatStream,
- streamStartedAt: state.chatStreamStartedAt,
- draft: state.chatMessage,
- queue: state.chatQueue,
- connected: state.connected,
- canSend: state.connected,
- disabledReason: chatDisabledReason,
- error: state.lastError,
- sessions: state.sessionsResult,
- focusMode: chatFocus,
- onRefresh: () => {
- state.resetToolStream();
- return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
- },
- onToggleFocusMode: () => {
- if (state.onboarding) return;
- state.applySettings({
- ...state.settings,
- chatFocusMode: !state.settings.chatFocusMode,
- });
- },
- onChatScroll: (event) => state.handleChatScroll(event),
- onDraftChange: (next) => (state.chatMessage = next),
- attachments: state.chatAttachments,
- onAttachmentsChange: (next) => (state.chatAttachments = next),
- onSend: () => state.handleSendChat(),
- canAbort: Boolean(state.chatRunId),
- onAbort: () => void state.handleAbortChat(),
- onQueueRemove: (id) => state.removeQueuedMessage(id),
- onNewSession: () =>
- state.handleSendChat("/new", { restoreDraft: true }),
- // Sidebar props for tool output viewing
- sidebarOpen: state.sidebarOpen,
- sidebarContent: state.sidebarContent,
- sidebarError: state.sidebarError,
- splitRatio: state.splitRatio,
- onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
- onCloseSidebar: () => state.handleCloseSidebar(),
- onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
- assistantName: state.assistantName,
- assistantAvatar: state.assistantAvatar,
- })
- : nothing}
+ ? renderChat({
+ sessionKey: state.sessionKey,
+ onSessionKeyChange: (next) => {
+ state.sessionKey = next;
+ state.chatMessage = "";
+ state.chatAttachments = [];
+ state.chatStream = null;
+ state.chatStreamStartedAt = null;
+ state.chatRunId = null;
+ state.chatQueue = [];
+ state.resetToolStream();
+ state.resetChatScroll();
+ state.applySettings({
+ ...state.settings,
+ sessionKey: next,
+ lastActiveSessionKey: next,
+ });
+ void state.loadAssistantIdentity();
+ void loadChatHistory(state);
+ void refreshChatAvatar(state);
+ },
+ thinkingLevel: state.chatThinkingLevel,
+ showThinking,
+ loading: state.chatLoading,
+ sending: state.chatSending,
+ compactionStatus: state.compactionStatus,
+ assistantAvatarUrl: chatAvatarUrl,
+ messages: state.chatMessages,
+ toolMessages: state.chatToolMessages,
+ stream: state.chatStream,
+ streamStartedAt: state.chatStreamStartedAt,
+ draft: state.chatMessage,
+ queue: state.chatQueue,
+ connected: state.connected,
+ canSend: state.connected,
+ disabledReason: chatDisabledReason,
+ error: state.lastError,
+ sessions: state.sessionsResult,
+ focusMode: chatFocus,
+ onRefresh: () => {
+ state.resetToolStream();
+ return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
+ },
+ onToggleFocusMode: () => {
+ if (state.onboarding) return;
+ state.applySettings({
+ ...state.settings,
+ chatFocusMode: !state.settings.chatFocusMode,
+ });
+ },
+ onChatScroll: (event) => state.handleChatScroll(event),
+ onDraftChange: (next) => (state.chatMessage = next),
+ onSend: () => state.handleSendChat(),
+ canAbort: Boolean(state.chatRunId),
+ onAbort: () => void state.handleAbortChat(),
+ onQueueRemove: (id) => state.removeQueuedMessage(id),
+ attachments: state.chatAttachments,
+ onAttachmentsChange: (next) => (state.chatAttachments = next),
+ onNewSession: () =>
+ state.handleSendChat("/new", { restoreDraft: true }),
+ // Sidebar props for tool output viewing
+ sidebarOpen: state.sidebarOpen,
+ sidebarContent: state.sidebarContent,
+ sidebarError: state.sidebarError,
+ splitRatio: state.splitRatio,
+ onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
+ onCloseSidebar: () => state.handleCloseSidebar(),
+ onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
+ assistantName: state.assistantName,
+ assistantAvatar: state.assistantAvatar,
+ })
+ : nothing}
${state.tab === "config"
- ? renderConfig({
- raw: state.configRaw,
- originalRaw: state.configRawOriginal,
- valid: state.configValid,
- issues: state.configIssues,
- loading: state.configLoading,
- saving: state.configSaving,
- applying: state.configApplying,
- updating: state.updateRunning,
- connected: state.connected,
- schema: state.configSchema,
- schemaLoading: state.configSchemaLoading,
- uiHints: state.configUiHints,
- formMode: state.configFormMode,
- formValue: state.configForm,
- originalValue: state.configFormOriginal,
- searchQuery: state.configSearchQuery,
- activeSection: state.configActiveSection,
- activeSubsection: state.configActiveSubsection,
- onRawChange: (next) => {
- state.configRaw = next;
- },
- onFormModeChange: (mode) => (state.configFormMode = mode),
- onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
- onSearchChange: (query) => (state.configSearchQuery = query),
- onSectionChange: (section) => {
- state.configActiveSection = section;
- state.configActiveSubsection = null;
- },
- onSubsectionChange: (section) => (state.configActiveSubsection = section),
- onReload: () => loadConfig(state),
- onSave: () => saveConfig(state),
- onApply: () => applyConfig(state),
- onUpdate: () => runUpdate(state),
- })
- : nothing}
+ ? renderConfig({
+ raw: state.configRaw,
+ originalRaw: state.configRawOriginal,
+ valid: state.configValid,
+ issues: state.configIssues,
+ loading: state.configLoading,
+ saving: state.configSaving,
+ applying: state.configApplying,
+ updating: state.updateRunning,
+ connected: state.connected,
+ schema: state.configSchema,
+ schemaLoading: state.configSchemaLoading,
+ uiHints: state.configUiHints,
+ formMode: state.configFormMode,
+ formValue: state.configForm,
+ originalValue: state.configFormOriginal,
+ searchQuery: state.configSearchQuery,
+ activeSection: state.configActiveSection,
+ activeSubsection: state.configActiveSubsection,
+ onRawChange: (next) => {
+ state.configRaw = next;
+ },
+ onFormModeChange: (mode) => (state.configFormMode = mode),
+ onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
+ onSearchChange: (query) => (state.configSearchQuery = query),
+ onSectionChange: (section) => {
+ state.configActiveSection = section;
+ state.configActiveSubsection = null;
+ },
+ onSubsectionChange: (section) => (state.configActiveSubsection = section),
+ onReload: () => loadConfig(state),
+ onSave: () => saveConfig(state),
+ onApply: () => applyConfig(state),
+ onUpdate: () => runUpdate(state),
+ })
+ : nothing}
${state.tab === "debug"
- ? renderDebug({
- loading: state.debugLoading,
- status: state.debugStatus,
- health: state.debugHealth,
- models: state.debugModels,
- heartbeat: state.debugHeartbeat,
- eventLog: state.eventLog,
- callMethod: state.debugCallMethod,
- callParams: state.debugCallParams,
- callResult: state.debugCallResult,
- callError: state.debugCallError,
- onCallMethodChange: (next) => (state.debugCallMethod = next),
- onCallParamsChange: (next) => (state.debugCallParams = next),
- onRefresh: () => loadDebug(state),
- onCall: () => callDebugMethod(state),
- })
- : nothing}
+ ? renderDebug({
+ loading: state.debugLoading,
+ status: state.debugStatus,
+ health: state.debugHealth,
+ models: state.debugModels,
+ heartbeat: state.debugHeartbeat,
+ eventLog: state.eventLog,
+ callMethod: state.debugCallMethod,
+ callParams: state.debugCallParams,
+ callResult: state.debugCallResult,
+ callError: state.debugCallError,
+ onCallMethodChange: (next) => (state.debugCallMethod = next),
+ onCallParamsChange: (next) => (state.debugCallParams = next),
+ onRefresh: () => loadDebug(state),
+ onCall: () => callDebugMethod(state),
+ })
+ : nothing}
${state.tab === "logs"
- ? renderLogs({
- loading: state.logsLoading,
- error: state.logsError,
- file: state.logsFile,
- entries: state.logsEntries,
- filterText: state.logsFilterText,
- levelFilters: state.logsLevelFilters,
- autoFollow: state.logsAutoFollow,
- truncated: state.logsTruncated,
- onFilterTextChange: (next) => (state.logsFilterText = next),
- onLevelToggle: (level, enabled) => {
- state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
- },
- onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
- onRefresh: () => loadLogs(state, { reset: true }),
- onExport: (lines, label) => state.exportLogs(lines, label),
- onScroll: (event) => state.handleLogsScroll(event),
- })
- : nothing}
+ ? renderLogs({
+ loading: state.logsLoading,
+ error: state.logsError,
+ file: state.logsFile,
+ entries: state.logsEntries,
+ filterText: state.logsFilterText,
+ levelFilters: state.logsLevelFilters,
+ autoFollow: state.logsAutoFollow,
+ truncated: state.logsTruncated,
+ onFilterTextChange: (next) => (state.logsFilterText = next),
+ onLevelToggle: (level, enabled) => {
+ state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
+ },
+ onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
+ onRefresh: () => loadLogs(state, { reset: true }),
+ onExport: (lines, label) => state.exportLogs(lines, label),
+ onScroll: (event) => state.handleLogsScroll(event),
+ })
+ : nothing}
${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)}
diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts
index 7e3ab29cf..5b71e17a5 100644
--- a/ui/src/ui/app-settings.ts
+++ b/ui/src/ui/app-settings.ts
@@ -200,6 +200,16 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
const root = document.documentElement;
root.dataset.theme = resolved;
root.style.colorScheme = resolved;
+
+ // Update meta theme-color for mobile browsers (muted backgrounds)
+ let metaThemeColor = document.querySelector('meta[name="theme-color"]');
+ if (!metaThemeColor) {
+ metaThemeColor = document.createElement("meta");
+ metaThemeColor.setAttribute("name", "theme-color");
+ document.head.appendChild(metaThemeColor);
+ }
+ const color = resolved === "light" ? "#FAFAF9" : "#1A1816";
+ metaThemeColor.setAttribute("content", color);
}
export function attachThemeListener(host: SettingsHost) {
diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts
index 26f4a5836..433dfa6f7 100644
--- a/ui/src/ui/app.ts
+++ b/ui/src/ui/app.ts
@@ -351,6 +351,15 @@ export class MoltbotApp extends LitElement {
);
}
+ setColorTheme(colorTheme: string) {
+ import("./theme").then(({ applyColorTheme, saveColorTheme }) => {
+ applyColorTheme(colorTheme as any);
+ saveColorTheme(colorTheme as any);
+ this.settings = { ...this.settings, colorTheme: colorTheme as any };
+ this.requestUpdate();
+ });
+ }
+
async loadOverview() {
await loadOverviewInternal(
this as unknown as Parameters[0],
diff --git a/ui/src/ui/components/badge.ts b/ui/src/ui/components/badge.ts
new file mode 100644
index 000000000..46c16901e
--- /dev/null
+++ b/ui/src/ui/components/badge.ts
@@ -0,0 +1,108 @@
+
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { classMap } from "lit/directives/class-map.js";
+
+export type BadgeVariant = "default" | "secondary" | "outline" | "destructive" | "success" | "warning";
+
+@customElement("ui-badge")
+export class UiBadge extends LitElement {
+ @property({ type: String }) variant: BadgeVariant = "default";
+
+ static styles = css`
+ :host {
+ display: inline-flex;
+ vertical-align: middle;
+ }
+
+ .badge {
+ display: inline-flex;
+ align-items: center;
+ border-radius: var(--radius-full, 9999px);
+ border: 1px solid transparent;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.3;
+ transition: colors 0.2s ease;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+ }
+
+ .variant-default {
+ border-color: transparent;
+ background-color: var(--accent, #FF9F40);
+ color: var(--primary-foreground, #fff);
+ }
+
+ .variant-secondary {
+ border-color: transparent;
+ background-color: var(--secondary, #2A2723);
+ color: var(--secondary-foreground, #EDE9E6);
+ }
+
+ .variant-outline {
+ border-color: var(--border, #3E3B38);
+ color: var(--foreground, #EDE9E6);
+ }
+
+ .variant-destructive {
+ border-color: transparent;
+ background-color: var(--danger, #EF4444);
+ color: white;
+ }
+
+ .variant-success {
+ border-color: transparent;
+ background-color: var(--ok, #22C55E);
+ color: white;
+ }
+
+ .variant-warning {
+ border-color: transparent;
+ background-color: var(--warn, #F59E0B);
+ color: white;
+ }
+
+ /* Light mode styles */
+ :host-context([data-theme="light"]) .variant-default {
+ background-color: var(--accent, #FF8C42);
+ }
+
+ :host-context([data-theme="light"]) .variant-secondary {
+ background-color: var(--secondary, #F5F4F2);
+ color: var(--secondary-foreground, #3f3f46);
+ }
+
+ :host-context([data-theme="light"]) .variant-outline {
+ border-color: var(--border, #e4e4e7);
+ color: var(--foreground, #1A1410);
+ }
+
+ :host-context([data-theme="light"]) .variant-destructive {
+ background-color: var(--danger, #D32F2F);
+ }
+
+ :host-context([data-theme="light"]) .variant-success {
+ background-color: var(--ok, #2E7D32);
+ }
+
+ :host-context([data-theme="light"]) .variant-warning {
+ background-color: var(--warn, #F57C00);
+ }
+ `;
+
+ render() {
+ return html`
+
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-badge": UiBadge;
+ }
+}
diff --git a/ui/src/ui/components/button.ts b/ui/src/ui/components/button.ts
new file mode 100644
index 000000000..3b7fa71ff
--- /dev/null
+++ b/ui/src/ui/components/button.ts
@@ -0,0 +1,206 @@
+
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { classMap } from "lit/directives/class-map.js";
+import "./icon";
+
+export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "outline";
+export type ButtonSize = "sm" | "md" | "lg" | "icon";
+
+@customElement("ui-button")
+export class UiButton extends LitElement {
+ @property({ type: String }) variant: ButtonVariant = "secondary";
+ @property({ type: String }) size: ButtonSize = "md";
+ @property({ type: Boolean }) disabled = false;
+ @property({ type: Boolean }) loading = false;
+ @property({ type: String }) icon?: string;
+ @property({ type: String }) href?: string;
+ @property({ type: String }) target?: string;
+ @property({ type: String }) type: "button" | "submit" | "reset" = "button";
+
+ static styles = css`
+ :host {
+ display: inline-block;
+ vertical-align: middle;
+ }
+
+ .btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-md, 6px);
+ cursor: pointer;
+ font-family: inherit;
+ font-weight: 500;
+ line-height: 1;
+ text-decoration: none;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+ position: relative;
+ }
+
+ .btn:focus-visible {
+ outline: 2px solid var(--accent, #FF9F40);
+ outline-offset: 2px;
+ }
+
+ .btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ pointer-events: none;
+ }
+
+ /* Sizes */
+ .size-sm {
+ height: 28px;
+ padding: 0 10px;
+ font-size: 12px;
+ }
+ .size-md {
+ height: 36px;
+ padding: 0 16px;
+ font-size: 14px;
+ }
+ .size-lg {
+ height: 44px;
+ padding: 0 24px;
+ font-size: 16px;
+ }
+ .size-icon {
+ height: 36px;
+ width: 36px;
+ padding: 0;
+ }
+ .size-icon.size-sm {
+ height: 28px;
+ width: 28px;
+ }
+
+ /* Variants */
+ .variant-primary {
+ background-color: var(--accent, #FF9F40);
+ color: var(--primary-foreground, #fff);
+ border-color: var(--accent, #FF9F40);
+ }
+ .variant-primary:hover {
+ background-color: var(--accent-hover, #F97316);
+ border-color: var(--accent-hover, #F97316);
+ }
+
+ .variant-secondary {
+ background-color: var(--secondary, #2A2723);
+ color: var(--secondary-foreground, #EDE9E6);
+ border-color: var(--border, #3E3B38);
+ }
+ .variant-secondary:hover {
+ background-color: var(--secondary-hover, #3E3B38);
+ border-color: var(--border-strong, #57534E);
+ }
+
+ .variant-outline {
+ background-color: transparent;
+ border-color: var(--border, #3E3B38);
+ color: var(--foreground, #EDE9E6);
+ }
+ .variant-outline:hover {
+ background-color: var(--secondary, #2A2723);
+ border-color: var(--border-strong, #57534E);
+ }
+
+ .variant-ghost {
+ background-color: transparent;
+ color: var(--muted-foreground, #A8A29D);
+ }
+ .variant-ghost:hover {
+ background-color: var(--secondary, #2A2723);
+ color: var(--foreground, #EDE9E6);
+ }
+
+ .variant-danger {
+ background-color: var(--danger, #EF4444);
+ color: white;
+ }
+ .variant-danger:hover {
+ background-color: var(--danger-hover, #DC2626);
+ }
+
+ /* Light mode overrides */
+ :host-context([data-theme="light"]) .variant-secondary {
+ background-color: white;
+ color: var(--foreground);
+ border-color: var(--border);
+ }
+ :host-context([data-theme="light"]) .variant-secondary:hover {
+ background-color: var(--bg-hover);
+ }
+
+ :host-context([data-theme="light"]) .variant-outline {
+ color: var(--foreground);
+ }
+ :host-context([data-theme="light"]) .variant-outline:hover {
+ background-color: var(--bg-hover);
+ }
+
+ :host-context([data-theme="light"]) .variant-ghost:hover {
+ background-color: var(--bg-hover);
+ }
+
+ /* Loading Spinner */
+ .spinner {
+ animation: spin 1s linear infinite;
+ }
+ @keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+ }
+ `;
+
+ render() {
+ const classes = {
+ btn: true,
+ [`variant-${this.variant}`]: true,
+ [`size-${this.size}`]: true,
+ loading: this.loading,
+ };
+
+ const content = html`
+ ${this.loading
+ ? html``
+ : this.icon
+ ? html``
+ : null}
+
+ `;
+
+ if (this.href) {
+ return html`
+
+ ${content}
+
+ `;
+ }
+
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-button": UiButton;
+ }
+}
diff --git a/ui/src/ui/components/card.ts b/ui/src/ui/components/card.ts
new file mode 100644
index 000000000..279982ae4
--- /dev/null
+++ b/ui/src/ui/components/card.ts
@@ -0,0 +1,110 @@
+
+import { LitElement, html, css } from "lit";
+import { customElement } from "lit/decorators.js";
+
+@customElement("ui-card")
+export class UiCard extends LitElement {
+ static styles = css`
+ :host {
+ display: block;
+ }
+
+ .card {
+ border: 1px solid var(--border, #3E3B38);
+ background: var(--card, #1A1816);
+ border-radius: var(--radius-lg, 12px);
+ box-shadow: var(--shadow-sm, 0 1px 2px rgba(0,0,0,0.1));
+ overflow: hidden;
+ transition: all 0.2s ease;
+ }
+
+ .card:hover {
+ border-color: var(--border-strong, #57534E);
+ box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0,0,0,0.1));
+ }
+
+ .card-header {
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border, #3E3B38);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .card-header:not(:has(*)) {
+ display: none;
+ }
+
+ .card-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--foreground, #EDE9E6);
+ margin: 0;
+ }
+
+ .card-desc {
+ font-size: 13px;
+ color: var(--muted-foreground, #A8A29D);
+ margin-top: 4px;
+ }
+
+ .card-content {
+ padding: 20px;
+ }
+
+ .card-footer {
+ padding: 16px 20px;
+ border-top: 1px solid var(--border, #3E3B38);
+ background: var(--bg-muted, #1F1D1B);
+ display: flex;
+ align-items: center;
+ }
+
+ /* Light mode styles */
+ :host-context([data-theme="light"]) .card {
+ background: var(--card, #FFFFFF);
+ border-color: var(--border, #e4e4e7);
+ }
+
+ :host-context([data-theme="light"]) .card:hover {
+ border-color: var(--border-strong, #d4d4d8);
+ }
+
+ :host-context([data-theme="light"]) .card-header {
+ border-bottom-color: var(--border, #e4e4e7);
+ }
+
+ :host-context([data-theme="light"]) .card-title {
+ color: var(--foreground, #1A1410);
+ }
+
+ :host-context([data-theme="light"]) .card-desc {
+ color: var(--muted-foreground, #71717a);
+ }
+
+ :host-context([data-theme="light"]) .card-footer {
+ border-top-color: var(--border, #e4e4e7);
+ background: var(--bg-muted, #EFEEE9);
+ }
+ `;
+
+ render() {
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-card": UiCard;
+ }
+}
diff --git a/ui/src/ui/components/checkbox.ts b/ui/src/ui/components/checkbox.ts
new file mode 100644
index 000000000..7f7d236a4
--- /dev/null
+++ b/ui/src/ui/components/checkbox.ts
@@ -0,0 +1,106 @@
+
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import "./icon";
+
+@customElement("ui-checkbox")
+export class UiCheckbox extends LitElement {
+ @property({ type: Boolean }) checked = false;
+ @property({ type: Boolean }) disabled = false;
+ @property({ type: String }) label = "";
+
+ static styles = css`
+ :host {
+ display: inline-flex;
+ vertical-align: middle;
+ }
+
+ .wrapper {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ user-select: none;
+ }
+
+ .wrapper.disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .checkbox {
+ width: 18px;
+ height: 18px;
+ border: 1px solid var(--input-border, #3E3B38);
+ border-radius: var(--radius-sm, 4px);
+ background: var(--input-bg, #1A1816);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ color: white;
+ }
+
+ .wrapper:hover .checkbox {
+ border-color: var(--accent, #FF9F40);
+ background: rgba(255, 159, 64, 0.1);
+ }
+
+ .wrapper.checked .checkbox {
+ background: var(--accent, #FF9F40);
+ border-color: var(--accent, #FF9F40);
+ }
+
+ /* Light mode */
+ :host-context([data-theme="light"]) .checkbox {
+ background: white;
+ }
+
+ :host-context([data-theme="light"]) .wrapper:hover .checkbox {
+ background: rgba(255, 159, 64, 0.1);
+ }
+
+ :host-context([data-theme="light"]) .wrapper.checked .checkbox {
+ background: var(--accent, #FF9F40);
+ }
+
+ .label {
+ font-size: 14px;
+ color: var(--foreground, #EDE9E6);
+ }
+
+ :host-context([data-theme="light"]) .label {
+ color: var(--foreground, #1A1410);
+ }
+ `;
+
+ toggle() {
+ if (this.disabled) return;
+ this.checked = !this.checked;
+ this.dispatchEvent(new CustomEvent("change", {
+ detail: { checked: this.checked },
+ bubbles: true,
+ composed: true
+ }));
+ }
+
+ render() {
+ return html`
+
+
+ ${this.checked ? html`` : null}
+
+ ${this.label ? html`
${this.label}` : null}
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-checkbox": UiCheckbox;
+ }
+}
diff --git a/ui/src/ui/components/dropdown-menu.ts b/ui/src/ui/components/dropdown-menu.ts
new file mode 100644
index 000000000..53097f939
--- /dev/null
+++ b/ui/src/ui/components/dropdown-menu.ts
@@ -0,0 +1,200 @@
+import { LitElement, html, css } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import "./icon";
+
+export interface DropdownMenuItem {
+ label: string;
+ value: string;
+ variant?: "default" | "danger";
+ disabled?: boolean;
+}
+
+@customElement("ui-dropdown-menu")
+export class UiDropdownMenu extends LitElement {
+ @property({ type: Array }) items: DropdownMenuItem[] = [];
+ @property({ type: Boolean }) disabled = false;
+ @state() private open = false;
+
+ static styles = css`
+ :host {
+ position: relative;
+ display: inline-block;
+ }
+
+ .trigger {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ border: 1px solid var(--border, #3E3B38);
+ background: var(--bg-elevated, #1F1D1B);
+ padding: 9px 12px;
+ border-radius: var(--radius-md, 8px);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ color: var(--text, #EDE9E6);
+ }
+
+ .trigger:hover:not(:disabled) {
+ background: var(--bg-hover, #2A2826);
+ border-color: var(--border-strong, #57534E);
+ transform: translateY(-1px);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+ }
+
+ .trigger:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ min-width: 160px;
+ background: var(--card, #1A1816);
+ border: 1px solid var(--border, #3E3B38);
+ border-radius: var(--radius-md, 8px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ padding: 4px;
+ z-index: 100;
+ opacity: 0;
+ transform: translateY(-4px);
+ pointer-events: none;
+ transition: all 0.15s ease;
+ }
+
+ .menu.open {
+ opacity: 1;
+ transform: translateY(0);
+ pointer-events: auto;
+ }
+
+ .menu-item {
+ display: flex;
+ align-items: center;
+ padding: 8px 12px;
+ border-radius: var(--radius-sm, 6px);
+ font-size: 13px;
+ cursor: pointer;
+ transition: background 0.1s ease;
+ color: var(--text, #EDE9E6);
+ border: none;
+ background: transparent;
+ width: 100%;
+ text-align: left;
+ }
+
+ .menu-item:hover:not(:disabled) {
+ background: var(--bg-hover, #2A2826);
+ }
+
+ .menu-item:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .menu-item.danger {
+ color: var(--danger, #EF4444);
+ }
+
+ .menu-item.danger:hover:not(:disabled) {
+ background: rgba(239, 68, 68, 0.1);
+ }
+
+ /* Light mode */
+ :host-context([data-theme="light"]) .trigger {
+ background: white;
+ color: var(--foreground);
+ }
+
+ :host-context([data-theme="light"]) .trigger:hover:not(:disabled) {
+ background: var(--bg-hover);
+ }
+
+ :host-context([data-theme="light"]) .menu {
+ background: white;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ }
+
+ :host-context([data-theme="light"]) .menu-item:hover:not(:disabled) {
+ background: var(--bg-hover);
+ }
+ `;
+
+ private handleClickOutside = (e: MouseEvent) => {
+ if (!this.contains(e.target as Node)) {
+ this.open = false;
+ }
+ };
+
+ connectedCallback() {
+ super.connectedCallback();
+ document.addEventListener("click", this.handleClickOutside);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ document.removeEventListener("click", this.handleClickOutside);
+ }
+
+ private toggleMenu(e: Event) {
+ e.stopPropagation();
+ if (!this.disabled) {
+ this.open = !this.open;
+ }
+ }
+
+ private handleItemClick(item: DropdownMenuItem, e: Event) {
+ e.stopPropagation();
+ if (!item.disabled) {
+ this.open = false;
+ this.dispatchEvent(
+ new CustomEvent("select", {
+ detail: { value: item.value },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ }
+
+ render() {
+ return html`
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-dropdown-menu": UiDropdownMenu;
+ }
+}
diff --git a/ui/src/ui/components/icon.ts b/ui/src/ui/components/icon.ts
new file mode 100644
index 000000000..1879160e5
--- /dev/null
+++ b/ui/src/ui/components/icon.ts
@@ -0,0 +1,51 @@
+
+import { LitElement, html, css, type TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { icons, type IconName } from "../icons";
+
+@customElement("ui-icon")
+export class UiIcon extends LitElement {
+ @property({ type: String }) name?: IconName;
+ @property({ type: Number }) size = 16;
+ @property({ type: String }) color = "currentColor";
+
+ static styles = css`
+ :host {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ vertical-align: middle;
+ }
+ svg {
+ width: 100%;
+ height: 100%;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.5px;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ }
+ `;
+
+ render() {
+ const iconTemplate = this.name ? icons[this.name] : undefined;
+ if (!iconTemplate) return html``;
+
+ return html`
+
+ ${iconTemplate}
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-icon": UiIcon;
+ }
+}
diff --git a/ui/src/ui/components/index.ts b/ui/src/ui/components/index.ts
new file mode 100644
index 000000000..3bdaa0130
--- /dev/null
+++ b/ui/src/ui/components/index.ts
@@ -0,0 +1,14 @@
+
+export * from "./icon";
+export * from "./button";
+export * from "./badge";
+export * from "./card";
+export * from "./input";
+export * from "./select";
+export * from "./switch";
+export * from "./tooltip";
+export * from "./segmented-control";
+export * from "./checkbox";
+export * from "./resizable-divider";
+export * from "./textarea";
+export * from "./theme-selector";
diff --git a/ui/src/ui/components/input.ts b/ui/src/ui/components/input.ts
new file mode 100644
index 000000000..ea5f81af2
--- /dev/null
+++ b/ui/src/ui/components/input.ts
@@ -0,0 +1,148 @@
+
+import { LitElement, html, css } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+import { classMap } from "lit/directives/class-map.js";
+import "./icon";
+
+@customElement("ui-input")
+export class UiInput extends LitElement {
+ @property({ type: String }) type = "text";
+ @property({ type: String }) placeholder = "";
+ @property({ type: String }) value = "";
+ @property({ type: String }) label = "";
+ @property({ type: String }) error = "";
+ @property({ type: Boolean }) disabled = false;
+ @property({ type: String }) icon?: string;
+
+ @query("input") inputElement!: HTMLInputElement;
+
+ static styles = css`
+ :host {
+ display: block;
+ width: 100%;
+ }
+
+ .input-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--muted-foreground, #A8A29D);
+ }
+
+ .input-container {
+ position: relative;
+ display: flex;
+ align-items: center;
+ }
+
+ input {
+ width: 100%;
+ height: 36px;
+ padding: 0 12px;
+ padding-left: var(--input-padding-left, 12px);
+ background: var(--input-bg, #1A1816);
+ border: 1px solid var(--input-border, #3E3B38);
+ border-radius: var(--radius-md, 6px);
+ color: var(--foreground, #EDE9E6);
+ font-family: inherit;
+ font-size: 14px;
+ outline: none;
+ transition: all 0.2s ease;
+ }
+
+ input:focus {
+ border-color: var(--accent, #FF9F40);
+ box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1));
+ }
+
+ input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ /* Light mode */
+ :host-context([data-theme="light"]) input {
+ background: white;
+ color: var(--foreground);
+ }
+
+ :host-context([data-theme="light"]) input:focus {
+ background: white;
+ }
+
+ .has-icon input {
+ padding-left: 36px;
+ }
+
+ .icon {
+ position: absolute;
+ left: 10px;
+ color: var(--muted-foreground, #A8A29D);
+ pointer-events: none;
+ display: flex;
+ align-items: center;
+ }
+
+ .error-msg {
+ font-size: 12px;
+ color: var(--danger, #EF4444);
+ }
+
+ .input-error input {
+ border-color: var(--danger, #EF4444);
+ }
+ `;
+
+ handleInput(e: Event) {
+ const target = e.target as HTMLInputElement;
+ this.value = target.value;
+ this.dispatchEvent(new CustomEvent("input", {
+ detail: { value: this.value },
+ bubbles: true,
+ composed: true
+ }));
+ }
+
+ handleChange(e: Event) {
+ const target = e.target as HTMLInputElement;
+ this.value = target.value;
+ this.dispatchEvent(new CustomEvent("change", {
+ detail: { value: this.value },
+ bubbles: true,
+ composed: true
+ }));
+ }
+
+ render() {
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-input": UiInput;
+ }
+}
diff --git a/ui/src/ui/components/resizable-divider.ts b/ui/src/ui/components/resizable-divider.ts
index a14cca35a..feeb4ef8b 100644
--- a/ui/src/ui/components/resizable-divider.ts
+++ b/ui/src/ui/components/resizable-divider.ts
@@ -41,6 +41,19 @@ export class ResizableDivider extends LitElement {
:host(.dragging) {
background: var(--accent, #007bff);
}
+
+ /* Light mode styles */
+ :host-context([data-theme="light"]) {
+ background: var(--border, #e4e4e7);
+ }
+
+ :host-context([data-theme="light"]):hover {
+ background: var(--accent, #FF8C42);
+ }
+
+ :host-context([data-theme="light"]).dragging {
+ background: var(--accent, #FF8C42);
+ }
`;
render() {
diff --git a/ui/src/ui/components/segmented-control.ts b/ui/src/ui/components/segmented-control.ts
new file mode 100644
index 000000000..03ca7c3c8
--- /dev/null
+++ b/ui/src/ui/components/segmented-control.ts
@@ -0,0 +1,98 @@
+
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+export interface SegmentonedOption {
+ label: string;
+ value: string;
+}
+
+@customElement("ui-segmented-control")
+export class UiSegmentedControl extends LitElement {
+ @property({ type: Array }) options: SegmentonedOption[] = [];
+ @property({ type: String }) value = "";
+
+ static styles = css`
+ :host {
+ display: inline-flex;
+ background: var(--input-bg, #2A2723);
+ padding: 3px; // slightly smaller padding for tighter look
+ border-radius: var(--radius-md, 8px);
+ border: 1px solid var(--border, #3E3B38);
+ }
+
+ .segment {
+ padding: 6px 14px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--muted-foreground, #A8A29D);
+ border-radius: var(--radius-sm, 6px);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ user-select: none;
+ text-align: center;
+ flex: 1;
+ }
+
+ .segment:hover {
+ color: var(--foreground, #EDE9E6);
+ background: rgba(255, 255, 255, 0.03);
+ }
+
+ .segment.active {
+ background: var(--card, #1A1816);
+ color: var(--foreground, #EDE9E6);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.2);
+ }
+
+ /* Light mode styles */
+ :host-context([data-theme="light"]) {
+ background: var(--input-bg, #F5F4F2);
+ border-color: var(--border, #e4e4e7);
+ }
+
+ :host-context([data-theme="light"]) .segment {
+ color: var(--muted-foreground, #71717a);
+ }
+
+ :host-context([data-theme="light"]) .segment:hover {
+ color: var(--foreground, #1A1410);
+ background: rgba(0, 0, 0, 0.03);
+ }
+
+ :host-context([data-theme="light"]) .segment.active {
+ background: var(--card, #FFFFFF);
+ color: var(--foreground, #1A1410);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06);
+ }
+ `;
+
+ select(val: string) {
+ if (this.value === val) return;
+ this.value = val;
+ this.dispatchEvent(new CustomEvent("change", {
+ detail: { value: this.value },
+ bubbles: true,
+ composed: true
+ }));
+ }
+
+ render() {
+ return html`
+ ${this.options.map(opt => html`
+ this.select(opt.value)}
+ >
+ ${opt.label}
+
+ `)}
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-segmented-control": UiSegmentedControl;
+ }
+}
diff --git a/ui/src/ui/components/select.ts b/ui/src/ui/components/select.ts
new file mode 100644
index 000000000..4a09e9c85
--- /dev/null
+++ b/ui/src/ui/components/select.ts
@@ -0,0 +1,124 @@
+
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import "./icon";
+
+export interface SelectOption {
+ label: string;
+ value: string;
+}
+
+@customElement("ui-select")
+export class UiSelect extends LitElement {
+ @property({ type: String }) label = "";
+ @property({ type: String }) value = "";
+ @property({ type: Array }) options: SelectOption[] = [];
+ @property({ type: Boolean }) disabled = false;
+
+ static styles = css`
+ :host {
+ display: block;
+ width: 100%;
+ }
+
+ .wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--muted-foreground, #A8A29D);
+ }
+
+ .select-container {
+ position: relative;
+ display: flex;
+ align-items: center;
+ }
+
+ select {
+ width: 100%;
+ height: 36px;
+ padding: 0 12px;
+ padding-right: 36px;
+ background: var(--input-bg, #1A1816);
+ border: 1px solid var(--input-border, #3E3B38);
+ border-radius: var(--radius-md, 6px);
+ color: var(--foreground, #EDE9E6);
+ font-family: inherit;
+ font-size: 14px;
+ outline: none;
+ appearance: none;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ select:focus {
+ border-color: var(--accent, #FF9F40);
+ box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1));
+ }
+
+ select:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ /* Light mode */
+ :host-context([data-theme="light"]) select {
+ background: white;
+ color: var(--foreground);
+ }
+
+ :host-context([data-theme="light"]) select:focus {
+ background: white;
+ }
+
+ .chevron {
+ position: absolute;
+ right: 12px;
+ pointer-events: none;
+ color: var(--muted-foreground, #A8A29D);
+ display: flex;
+ align-items: center;
+ }
+ `;
+
+ handleChange(e: Event) {
+ const target = e.target as HTMLSelectElement;
+ this.value = target.value;
+ this.dispatchEvent(new CustomEvent("change", {
+ detail: { value: this.value },
+ bubbles: true,
+ composed: true
+ }));
+ }
+
+ render() {
+ return html`
+
+ ${this.label ? html`
` : null}
+
+
+
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-select": UiSelect;
+ }
+}
diff --git a/ui/src/ui/components/switch.ts b/ui/src/ui/components/switch.ts
new file mode 100644
index 000000000..b6e5619a0
--- /dev/null
+++ b/ui/src/ui/components/switch.ts
@@ -0,0 +1,100 @@
+
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+@customElement("ui-switch")
+export class UiSwitch extends LitElement {
+ @property({ type: Boolean }) checked = false;
+ @property({ type: Boolean }) disabled = false;
+
+ static styles = css`
+ :host {
+ display: inline-block;
+ vertical-align: middle;
+ }
+
+ input {
+ display: none;
+ }
+
+ .track {
+ width: 36px;
+ height: 20px;
+ background: var(--input-bg, #2A2723);
+ border: 1px solid var(--input-border, #3E3B38);
+ border-radius: 999px;
+ position: relative;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ .track.checked {
+ background: var(--accent, #FF9F40);
+ border-color: var(--accent, #FF9F40);
+ }
+
+ .thumb {
+ width: 16px;
+ height: 16px;
+ background: #A8A29D;
+ border-radius: 50%;
+ position: absolute;
+ top: 1px;
+ left: 1px;
+ transition: transform 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.2);
+ }
+
+ .track.checked .thumb {
+ background: white;
+ transform: translateX(16px);
+ }
+
+ .track.disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ /* Light mode styles */
+ :host-context([data-theme="light"]) .track {
+ background: var(--input-bg, #F5F4F2);
+ border-color: var(--input-border, #e4e4e7);
+ }
+
+ :host-context([data-theme="light"]) .track.checked {
+ background: var(--accent, #FF8C42);
+ border-color: var(--accent, #FF8C42);
+ }
+
+ :host-context([data-theme="light"]) .thumb {
+ background: #71717a;
+ }
+ `;
+
+ toggle() {
+ if (this.disabled) return;
+ this.checked = !this.checked;
+ this.dispatchEvent(new CustomEvent("change", {
+ detail: { checked: this.checked },
+ bubbles: true,
+ composed: true
+ }));
+ }
+
+ render() {
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-switch": UiSwitch;
+ }
+}
diff --git a/ui/src/ui/components/textarea.ts b/ui/src/ui/components/textarea.ts
new file mode 100644
index 000000000..38d109a57
--- /dev/null
+++ b/ui/src/ui/components/textarea.ts
@@ -0,0 +1,124 @@
+
+import { LitElement, html, css } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+
+@customElement("ui-textarea")
+export class UiTextarea extends LitElement {
+ @property({ type: String }) placeholder = "";
+ @property({ type: String }) value = "";
+ @property({ type: String }) label = "";
+ @property({ type: String }) error = "";
+ @property({ type: Boolean }) disabled = false;
+ @property({ type: Number }) rows = 4;
+
+ @query("textarea") textareaElement!: HTMLTextAreaElement;
+
+ static styles = css`
+ :host {
+ display: block;
+ width: 100%;
+ }
+
+ .textarea-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--muted-foreground, #A8A29D);
+ }
+
+ textarea {
+ width: 100%;
+ padding: 12px 14px;
+ background: var(--input-bg, #1A1816);
+ border: 1px solid var(--input-border, #3E3B38);
+ border-radius: var(--radius-md, 8px);
+ color: var(--foreground, #EDE9E6);
+ font-family: var(--font-mono, "JetBrains Mono", monospace);
+ font-size: 13px;
+ line-height: 1.55;
+ outline: none;
+ transition: all 0.2s ease;
+ resize: vertical;
+ min-height: 80px;
+ }
+
+ textarea:focus {
+ border-color: var(--accent, #FF9F40);
+ box-shadow: 0 0 0 2px var(--accent-subtle, rgba(255, 159, 64, 0.1));
+ }
+
+ textarea:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ /* Light mode */
+ :host-context([data-theme="light"]) textarea {
+ background: white;
+ color: var(--foreground);
+ }
+
+ :host-context([data-theme="light"]) textarea:focus {
+ background: white;
+ }
+
+ .error-msg {
+ font-size: 12px;
+ color: var(--danger, #EF4444);
+ }
+
+ .textarea-error textarea {
+ border-color: var(--danger, #EF4444);
+ }
+ `;
+
+ handleInput(e: Event) {
+ const target = e.target as HTMLTextAreaElement;
+ this.value = target.value;
+ this.dispatchEvent(new CustomEvent("input", {
+ detail: { value: this.value },
+ bubbles: true,
+ composed: true
+ }));
+ }
+
+ handleChange(e: Event) {
+ const target = e.target as HTMLTextAreaElement;
+ this.value = target.value;
+ this.dispatchEvent(new CustomEvent("change", {
+ detail: { value: this.value },
+ bubbles: true,
+ composed: true
+ }));
+ }
+
+ render() {
+ return html`
+
+ ${this.label ? html`` : null}
+
+
+
+ ${this.error ? html`${this.error}` : null}
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-textarea": UiTextarea;
+ }
+}
diff --git a/ui/src/ui/components/theme-selector.ts b/ui/src/ui/components/theme-selector.ts
new file mode 100644
index 000000000..2f3e78bda
--- /dev/null
+++ b/ui/src/ui/components/theme-selector.ts
@@ -0,0 +1,213 @@
+import { LitElement, html, css } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { COLOR_THEMES, type ColorTheme } from "../theme";
+import "./icon";
+
+@customElement("theme-selector")
+export class ThemeSelector extends LitElement {
+ @property({ type: String }) colorTheme: ColorTheme = "tangerine";
+ @state() private isOpen = false;
+
+ static styles = css`
+ :host {
+ position: relative;
+ display: inline-block;
+ }
+
+ .trigger {
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-full);
+ background: var(--secondary);
+ color: var(--muted);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ .trigger:hover {
+ color: var(--text);
+ background: var(--bg-hover);
+ border-color: var(--border-strong);
+ }
+
+ .dropdown {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ min-width: 180px;
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ padding: 6px;
+ z-index: 100;
+ opacity: 0;
+ transform: translateY(-4px);
+ pointer-events: none;
+ transition: all 0.2s ease;
+ }
+
+ .dropdown.open {
+ opacity: 1;
+ transform: translateY(0);
+ pointer-events: all;
+ }
+
+ .dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: background 0.15s ease;
+ border: none;
+ background: transparent;
+ width: 100%;
+ text-align: left;
+ color: var(--text);
+ font-size: 13px;
+ font-weight: 500;
+ }
+
+ .dropdown-item:hover {
+ background: var(--bg-hover);
+ }
+
+ .dropdown-item.active {
+ background: var(--accent-subtle);
+ color: var(--accent);
+ }
+
+ .color-preview {
+ width: 16px;
+ height: 16px;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border);
+ flex-shrink: 0;
+ }
+
+ /* Light mode */
+ :host-context([data-theme="light"]) .trigger {
+ background: white;
+ border-color: var(--border);
+ }
+
+ :host-context([data-theme="light"]) .trigger:hover {
+ background: var(--bg-hover);
+ }
+
+ :host-context([data-theme="light"]) .dropdown {
+ background: white;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ }
+
+ :host-context([data-theme="light"]) .dropdown-item:hover {
+ background: var(--bg-hover);
+ }
+
+ /* Mobile optimization */
+ @media (max-width: 600px) {
+ .trigger {
+ width: 26px;
+ height: 26px;
+ }
+
+ .dropdown {
+ min-width: 160px;
+ padding: 4px;
+ }
+
+ .dropdown-item {
+ padding: 6px 8px;
+ font-size: 12px;
+ }
+
+ .color-preview {
+ width: 14px;
+ height: 14px;
+ }
+ }
+
+ @media (max-width: 400px) {
+ .trigger {
+ width: 24px;
+ height: 24px;
+ }
+
+ .dropdown {
+ min-width: 140px;
+ }
+
+ .dropdown-item {
+ padding: 5px 7px;
+ font-size: 11px;
+ gap: 8px;
+ }
+
+ .color-preview {
+ width: 12px;
+ height: 12px;
+ }
+ }
+ `;
+
+ connectedCallback() {
+ super.connectedCallback();
+ document.addEventListener("click", this.handleOutsideClick);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ document.removeEventListener("click", this.handleOutsideClick);
+ }
+
+ private handleOutsideClick = (e: MouseEvent) => {
+ if (!this.contains(e.target as Node)) {
+ this.isOpen = false;
+ }
+ };
+
+ private toggleDropdown(e: Event) {
+ e.stopPropagation();
+ this.isOpen = !this.isOpen;
+ }
+
+ private selectTheme(theme: ColorTheme) {
+ this.dispatchEvent(new CustomEvent("change", {
+ detail: { colorTheme: theme },
+ bubbles: true,
+ composed: true
+ }));
+ this.isOpen = false;
+ }
+
+ render() {
+ return html`
+
+
+ ${Object.entries(COLOR_THEMES).map(([key, { name, preview }]) => html`
+
+ `)}
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "theme-selector": ThemeSelector;
+ }
+}
diff --git a/ui/src/ui/components/tooltip.ts b/ui/src/ui/components/tooltip.ts
new file mode 100644
index 000000000..d45d3aa2c
--- /dev/null
+++ b/ui/src/ui/components/tooltip.ts
@@ -0,0 +1,75 @@
+
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+@customElement("ui-tooltip")
+export class UiTooltip extends LitElement {
+ @property({ type: String }) content = "";
+
+ static styles = css`
+ :host {
+ position: relative;
+ display: inline-block;
+ }
+
+ .tooltip {
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%) translateY(8px) scale(0.95);
+ background: var(--tooltip-bg, #1A1816);
+ color: var(--tooltip-fg, #EDE9E6);
+ padding: 6px 10px;
+ border-radius: 6px;
+ font-size: 12px;
+ white-space: nowrap;
+ opacity: 0;
+ pointer-events: none;
+ transition: all 0.15s ease;
+ border: 1px solid var(--border, #3E3B38);
+ box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0,0,0,0.1));
+ z-index: 50;
+ }
+
+ /* Arrow */
+ .tooltip::after {
+ content: "";
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border-width: 4px;
+ border-style: solid;
+ border-color: var(--tooltip-bg, #1A1816) transparent transparent transparent;
+ }
+
+ :host(:hover) .tooltip {
+ opacity: 1;
+ transform: translateX(-50%) translateY(-8px) scale(1);
+ }
+
+ /* Light mode styles */
+ :host-context([data-theme="light"]) .tooltip {
+ background: var(--tooltip-bg, #FFFFFF);
+ color: var(--tooltip-fg, #1A1410);
+ border-color: var(--border, #e4e4e7);
+ }
+
+ :host-context([data-theme="light"]) .tooltip::after {
+ border-color: var(--tooltip-bg, #FFFFFF) transparent transparent transparent;
+ }
+ `;
+
+ render() {
+ return html`
+
+ ${this.content}
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ui-tooltip": UiTooltip;
+ }
+}
diff --git a/ui/src/ui/controllers/devices.ts b/ui/src/ui/controllers/devices.ts
index f08d7afb6..810d1d625 100644
--- a/ui/src/ui/controllers/devices.ts
+++ b/ui/src/ui/controllers/devices.ts
@@ -75,8 +75,6 @@ export async function approveDevicePairing(state: DevicesState, requestId: strin
export async function rejectDevicePairing(state: DevicesState, requestId: string) {
if (!state.client || !state.connected) return;
- const confirmed = window.confirm("Reject this device pairing request?");
- if (!confirmed) return;
try {
await state.client.request("device.pair.reject", { requestId });
await loadDevices(state);
@@ -118,10 +116,6 @@ export async function revokeDeviceToken(
params: { deviceId: string; role: string },
) {
if (!state.client || !state.connected) return;
- const confirmed = window.confirm(
- `Revoke token for ${params.deviceId} (${params.role})?`,
- );
- if (!confirmed) return;
try {
await state.client.request("device.token.revoke", params);
const identity = await loadOrCreateDeviceIdentity();
diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts
index 5c5077037..282b54736 100644
--- a/ui/src/ui/controllers/sessions.ts
+++ b/ui/src/ui/controllers/sessions.ts
@@ -66,10 +66,6 @@ export async function patchSession(
export async function deleteSession(state: SessionsState, key: string) {
if (!state.client || !state.connected) return;
if (state.sessionsLoading) return;
- const confirmed = window.confirm(
- `Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`,
- );
- if (!confirmed) return;
state.sessionsLoading = true;
state.sessionsError = null;
try {
diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts
index eaf8f0e27..7818463f0 100644
--- a/ui/src/ui/icons.ts
+++ b/ui/src/ui/icons.ts
@@ -19,6 +19,7 @@ export const icons = {
// UI icons
menu: html``,
+ moreVertical: html``,
x: html``,
check: html``,
copy: html``,
diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts
index 4b1836bfb..383a2b1dd 100644
--- a/ui/src/ui/storage.ts
+++ b/ui/src/ui/storage.ts
@@ -1,6 +1,6 @@
const KEY = "moltbot.control.settings.v1";
-import type { ThemeMode } from "./theme";
+import type { ThemeMode, ColorTheme } from "./theme";
export type UiSettings = {
gatewayUrl: string;
@@ -8,6 +8,7 @@ export type UiSettings = {
sessionKey: string;
lastActiveSessionKey: string;
theme: ThemeMode;
+ colorTheme: ColorTheme;
chatFocusMode: boolean;
chatShowThinking: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
@@ -27,6 +28,7 @@ export function loadSettings(): UiSettings {
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
+ colorTheme: "tangerine",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
@@ -61,6 +63,15 @@ export function loadSettings(): UiSettings {
parsed.theme === "system"
? parsed.theme
: defaults.theme,
+ colorTheme:
+ parsed.colorTheme === "tangerine" ||
+ parsed.colorTheme === "ocean" ||
+ parsed.colorTheme === "forest" ||
+ parsed.colorTheme === "sunset" ||
+ parsed.colorTheme === "lavender" ||
+ parsed.colorTheme === "slate"
+ ? parsed.colorTheme
+ : defaults.colorTheme,
chatFocusMode:
typeof parsed.chatFocusMode === "boolean"
? parsed.chatFocusMode
diff --git a/ui/src/ui/theme.ts b/ui/src/ui/theme.ts
index 47bd5670b..8bf18d89c 100644
--- a/ui/src/ui/theme.ts
+++ b/ui/src/ui/theme.ts
@@ -1,5 +1,9 @@
export type ThemeMode = "system" | "light" | "dark";
export type ResolvedTheme = "light" | "dark";
+export type ColorTheme = "tangerine" | "ocean" | "forest" | "sunset" | "lavender" | "slate";
+
+const STORAGE_KEY = "clawdbot-theme";
+const COLOR_THEME_KEY = "clawdbot-color-theme";
export function getSystemTheme(): ResolvedTheme {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
@@ -14,3 +18,107 @@ export function resolveTheme(mode: ThemeMode): ResolvedTheme {
if (mode === "system") return getSystemTheme();
return mode;
}
+
+export function getSavedTheme(): ThemeMode {
+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
+ return "system";
+ }
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved === "light" || saved === "dark" || saved === "system") {
+ return saved;
+ }
+ return "system";
+}
+
+export function saveTheme(mode: ThemeMode): void {
+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
+ return;
+ }
+ localStorage.setItem(STORAGE_KEY, mode);
+}
+
+export function getSavedColorTheme(): ColorTheme {
+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
+ return "tangerine";
+ }
+ const saved = localStorage.getItem(COLOR_THEME_KEY);
+ if (saved === "tangerine" || saved === "ocean" || saved === "forest" ||
+ saved === "sunset" || saved === "lavender" || saved === "slate") {
+ return saved;
+ }
+ return "tangerine";
+}
+
+export function saveColorTheme(colorTheme: ColorTheme): void {
+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
+ return;
+ }
+ localStorage.setItem(COLOR_THEME_KEY, colorTheme);
+}
+
+export function applyColorTheme(colorTheme: ColorTheme): void {
+ if (typeof document === "undefined") return;
+ const root = document.documentElement;
+ root.setAttribute("data-color-theme", colorTheme);
+}
+
+export function applyTheme(mode: ThemeMode): void {
+ if (typeof document === "undefined") return;
+
+ const root = document.documentElement;
+ const resolved = resolveTheme(mode);
+
+ if (mode === "system") {
+ root.removeAttribute("data-theme");
+ } else {
+ root.setAttribute("data-theme", mode);
+ }
+
+ // Update meta theme-color for mobile browsers
+ updateMetaThemeColor(resolved);
+}
+
+function updateMetaThemeColor(theme: ResolvedTheme): void {
+ if (typeof document === "undefined") return;
+
+ let metaThemeColor = document.querySelector('meta[name="theme-color"]');
+
+ if (!metaThemeColor) {
+ metaThemeColor = document.createElement("meta");
+ metaThemeColor.setAttribute("name", "theme-color");
+ document.head.appendChild(metaThemeColor);
+ }
+
+ // Muted backgrounds from design system
+ const color = theme === "light" ? "#FAFAF9" : "#1A1816";
+ metaThemeColor.setAttribute("content", color);
+}
+
+export function initTheme(): ThemeMode {
+ const saved = getSavedTheme();
+ const colorTheme = getSavedColorTheme();
+ applyTheme(saved);
+ applyColorTheme(colorTheme);
+
+ // Watch for system theme changes
+ if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ mediaQuery.addEventListener("change", () => {
+ const current = getSavedTheme();
+ if (current === "system") {
+ applyTheme("system");
+ }
+ });
+ }
+
+ return saved;
+}
+
+export const COLOR_THEMES: Record = {
+ tangerine: { name: "Tangerine", preview: "#FF9F40" },
+ ocean: { name: "Ocean", preview: "#0EA5E9" },
+ forest: { name: "Forest", preview: "#10B981" },
+ sunset: { name: "Sunset", preview: "#F43F5E" },
+ lavender: { name: "Lavender", preview: "#A78BFA" },
+ slate: { name: "Slate", preview: "#64748B" },
+};
diff --git a/ui/src/ui/views/channels.config.ts b/ui/src/ui/views/channels.config.ts
index 3c4d2c7df..eace5a243 100644
--- a/ui/src/ui/views/channels.config.ts
+++ b/ui/src/ui/views/channels.config.ts
@@ -102,7 +102,10 @@ export function renderChannelConfigSection(params: {
const { channelId, props } = params;
const disabled = props.configSaving || props.configSchemaLoading;
return html`
-
+
+
+ Configuration
+
${props.configSchemaLoading
? html`
Loading config schema…
`
: renderChannelConfigForm({
@@ -113,22 +116,6 @@ export function renderChannelConfigSection(params: {
disabled,
onPatch: props.onConfigPatch,
})}
-
-
-
-
`;
}
diff --git a/ui/src/ui/views/channels.discord.ts b/ui/src/ui/views/channels.discord.ts
index 07890e969..b567fbe76 100644
--- a/ui/src/ui/views/channels.discord.ts
+++ b/ui/src/ui/views/channels.discord.ts
@@ -11,51 +11,71 @@ export function renderDiscordCard(params: {
accountCountLabel: unknown;
}) {
const { props, discord, accountCountLabel } = params;
+ const configDisabled = props.configSaving || props.configSchemaLoading;
return html`
-
-
Discord
-
Bot status and channel configuration.
- ${accountCountLabel}
+
+
+
Discord
+
Bot status and channel configuration.
+ ${accountCountLabel}
-
-
- Configured
- ${discord?.configured ? "Yes" : "No"}
-
-
- Running
- ${discord?.running ? "Yes" : "No"}
-
-
- Last start
- ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}
-
-
-
Last probe
-
${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}
+
+
+ Configured
+ ${discord?.configured ? "Yes" : "No"}
+
+
+ Running
+ ${discord?.running ? "Yes" : "No"}
+
+
+ Last start
+ ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}
+
+
+ Last probe
+ ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}
+
+
+ ${discord?.probe
+ ? html`
+ Probe ${discord.probe.ok ? "ok" : "failed"} ·
+ ${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
+
`
+ : nothing}
+
+ ${renderChannelConfigSection({ channelId: "discord", props })}
- ${discord?.lastError
- ? html`
- ${discord.lastError}
-
`
- : nothing}
+
`;
diff --git a/ui/src/ui/views/channels.googlechat.ts b/ui/src/ui/views/channels.googlechat.ts
index a014ac89e..1f463685e 100644
--- a/ui/src/ui/views/channels.googlechat.ts
+++ b/ui/src/ui/views/channels.googlechat.ts
@@ -11,63 +11,83 @@ export function renderGoogleChatCard(params: {
accountCountLabel: unknown;
}) {
const { props, googleChat, accountCountLabel } = params;
+ const configDisabled = props.configSaving || props.configSchemaLoading;
return html`
-
-
Google Chat
-
Chat API webhook status and channel configuration.
- ${accountCountLabel}
+
+
+
Google Chat
+
Chat API webhook status and channel configuration.
+ ${accountCountLabel}
-
-
- Configured
- ${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}
-
-
- Running
- ${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}
-
-
- Credential
- ${googleChat?.credentialSource ?? "n/a"}
-
-
- Audience
-
- ${googleChat?.audienceType
- ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
- : "n/a"}
-
-
-
- Last start
- ${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}
-
-
-
Last probe
-
${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}
+
+
+ Configured
+ ${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}
+
+
+ Running
+ ${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}
+
+
+ Credential
+ ${googleChat?.credentialSource ?? "n/a"}
+
+
+ Audience
+
+ ${googleChat?.audienceType
+ ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
+ : "n/a"}
+
+
+
+ Last start
+ ${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}
+
+
+ Last probe
+ ${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}
+
+
+ ${googleChat?.probe
+ ? html`
+ Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
+ ${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
+
`
+ : nothing}
+
+ ${renderChannelConfigSection({ channelId: "googlechat", props })}
- ${googleChat?.lastError
- ? html`
- ${googleChat.lastError}
-
`
- : nothing}
+
`;
diff --git a/ui/src/ui/views/channels.imessage.ts b/ui/src/ui/views/channels.imessage.ts
index 85fd90d03..7a88eca6c 100644
--- a/ui/src/ui/views/channels.imessage.ts
+++ b/ui/src/ui/views/channels.imessage.ts
@@ -11,51 +11,71 @@ export function renderIMessageCard(params: {
accountCountLabel: unknown;
}) {
const { props, imessage, accountCountLabel } = params;
+ const configDisabled = props.configSaving || props.configSchemaLoading;
return html`
-
-
iMessage
-
macOS bridge status and channel configuration.
- ${accountCountLabel}
+
+
+
iMessage
+
macOS bridge status and channel configuration.
+ ${accountCountLabel}
-
-
- Configured
- ${imessage?.configured ? "Yes" : "No"}
-
-
- Running
- ${imessage?.running ? "Yes" : "No"}
-
-
- Last start
- ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}
-
-
-
Last probe
-
${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}
+
+
+ Configured
+ ${imessage?.configured ? "Yes" : "No"}
+
+
+ Running
+ ${imessage?.running ? "Yes" : "No"}
+
+
+ Last start
+ ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}
+
+
+ Last probe
+ ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}
+
+
+ ${imessage?.probe
+ ? html`
+ Probe ${imessage.probe.ok ? "ok" : "failed"} ·
+ ${imessage.probe.error ?? ""}
+
`
+ : nothing}
+
+ ${renderChannelConfigSection({ channelId: "imessage", props })}
- ${imessage?.lastError
- ? html`
- ${imessage.lastError}
-
`
- : nothing}
+
`;
diff --git a/ui/src/ui/views/channels.nostr-profile-form.ts b/ui/src/ui/views/channels.nostr-profile-form.ts
index 8565d8ef9..2df508bee 100644
--- a/ui/src/ui/views/channels.nostr-profile-form.ts
+++ b/ui/src/ui/views/channels.nostr-profile-form.ts
@@ -235,36 +235,33 @@ export function renderNostrProfileForm(params: {
: nothing}
-
+
-
+
-
+
-
+
${isDirty
diff --git a/ui/src/ui/views/channels.nostr.ts b/ui/src/ui/views/channels.nostr.ts
index 05152d80b..757925441 100644
--- a/ui/src/ui/views/channels.nostr.ts
+++ b/ui/src/ui/views/channels.nostr.ts
@@ -50,6 +50,7 @@ export function renderNostrCard(params: {
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
const hasMultipleAccounts = nostrAccounts.length > 1;
const showingForm = profileFormState !== null && profileFormState !== undefined;
+ const configDisabled = props.configSaving || props.configSchemaLoading;
const renderAccountCard = (account: ChannelAccountSnapshot) => {
const publicKey = (account as { publicKey?: string }).publicKey;
@@ -120,13 +121,12 @@ export function renderNostrCard(params: {
Profile
${summaryConfigured
? html`
-
+
`
: nothing}
@@ -167,50 +167,69 @@ export function renderNostrCard(params: {
};
return html`
-
-
Nostr
-
Decentralized DMs via Nostr relays (NIP-04).
- ${accountCountLabel}
+
+
+
Nostr
+
Decentralized DMs via Nostr relays (NIP-04).
+ ${accountCountLabel}
- ${hasMultipleAccounts
- ? html`
-
- ${nostrAccounts.map((account) => renderAccountCard(account))}
-
- `
- : html`
-
-
-
Configured
-
${summaryConfigured ? "Yes" : "No"}
+ ${hasMultipleAccounts
+ ? html`
+
+ ${nostrAccounts.map((account) => renderAccountCard(account))}
-
-
Running
-
${summaryRunning ? "Yes" : "No"}
+ `
+ : html`
+
+
+ Configured
+ ${summaryConfigured ? "Yes" : "No"}
+
+
+ Running
+ ${summaryRunning ? "Yes" : "No"}
+
+
+ Public Key
+ ${truncatePubkey(summaryPublicKey)}
+
+
+ Last start
+ ${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}
+
-
- Public Key
- ${truncatePubkey(summaryPublicKey)}
-
-
- Last start
- ${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}
-
-
- `}
+ `}
- ${summaryLastError
- ? html`
${summaryLastError}
`
- : nothing}
+ ${renderProfileSection()}
- ${renderProfileSection()}
+ ${renderChannelConfigSection({ channelId: "nostr", props })}
+
- ${renderChannelConfigSection({ channelId: "nostr", props })}
+
`;
diff --git a/ui/src/ui/views/channels.signal.ts b/ui/src/ui/views/channels.signal.ts
index 9d4f6c147..9657aa36b 100644
--- a/ui/src/ui/views/channels.signal.ts
+++ b/ui/src/ui/views/channels.signal.ts
@@ -11,55 +11,75 @@ export function renderSignalCard(params: {
accountCountLabel: unknown;
}) {
const { props, signal, accountCountLabel } = params;
+ const configDisabled = props.configSaving || props.configSchemaLoading;
return html`
-
-
Signal
-
signal-cli status and channel configuration.
- ${accountCountLabel}
+
+
+
Signal
+
signal-cli status and channel configuration.
+ ${accountCountLabel}
-
-
- Configured
- ${signal?.configured ? "Yes" : "No"}
-
-
- Running
- ${signal?.running ? "Yes" : "No"}
-
-
- Base URL
- ${signal?.baseUrl ?? "n/a"}
-
-
- Last start
- ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}
-
-
-
Last probe
-
${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}
+
+
+ Configured
+ ${signal?.configured ? "Yes" : "No"}
+
+
+ Running
+ ${signal?.running ? "Yes" : "No"}
+
+
+ Base URL
+ ${signal?.baseUrl ?? "n/a"}
+
+
+ Last start
+ ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}
+
+
+ Last probe
+ ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}
+
+
+ ${signal?.probe
+ ? html`
+ Probe ${signal.probe.ok ? "ok" : "failed"} ·
+ ${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
+
`
+ : nothing}
+
+ ${renderChannelConfigSection({ channelId: "signal", props })}
- ${signal?.lastError
- ? html`
- ${signal.lastError}
-
`
- : nothing}
+
`;
diff --git a/ui/src/ui/views/channels.slack.ts b/ui/src/ui/views/channels.slack.ts
index eb93ac4c3..b0740a948 100644
--- a/ui/src/ui/views/channels.slack.ts
+++ b/ui/src/ui/views/channels.slack.ts
@@ -11,51 +11,71 @@ export function renderSlackCard(params: {
accountCountLabel: unknown;
}) {
const { props, slack, accountCountLabel } = params;
+ const configDisabled = props.configSaving || props.configSchemaLoading;
return html`
-
-
Slack
-
Socket mode status and channel configuration.
- ${accountCountLabel}
+
+
+
Slack
+
Socket mode status and channel configuration.
+ ${accountCountLabel}
-
-
- Configured
- ${slack?.configured ? "Yes" : "No"}
-
-
- Running
- ${slack?.running ? "Yes" : "No"}
-
-
- Last start
- ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}
-
-
-
Last probe
-
${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}
+
+
+ Configured
+ ${slack?.configured ? "Yes" : "No"}
+
+
+ Running
+ ${slack?.running ? "Yes" : "No"}
+
+
+ Last start
+ ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}
+
+
+ Last probe
+ ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}
+
+
+ ${slack?.probe
+ ? html`
+ Probe ${slack.probe.ok ? "ok" : "failed"} ·
+ ${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
+
`
+ : nothing}
+
+ ${renderChannelConfigSection({ channelId: "slack", props })}
- ${slack?.lastError
- ? html`
- ${slack.lastError}
-
`
- : nothing}
+
`;
diff --git a/ui/src/ui/views/channels.telegram.ts b/ui/src/ui/views/channels.telegram.ts
index 498d98f87..2b91cee62 100644
--- a/ui/src/ui/views/channels.telegram.ts
+++ b/ui/src/ui/views/channels.telegram.ts
@@ -13,6 +13,7 @@ export function renderTelegramCard(params: {
}) {
const { props, telegram, telegramAccounts, accountCountLabel } = params;
const hasMultipleAccounts = telegramAccounts.length > 1;
+ const configDisabled = props.configSaving || props.configSchemaLoading;
const renderAccountCard = (account: ChannelAccountSnapshot) => {
const probe = account.probe as { bot?: { username?: string } } | undefined;
@@ -52,61 +53,80 @@ export function renderTelegramCard(params: {
};
return html`
-
-
Telegram
-
Bot status and channel configuration.
- ${accountCountLabel}
+
+
+
Telegram
+
Bot status and channel configuration.
+ ${accountCountLabel}
- ${hasMultipleAccounts
- ? html`
-
- ${telegramAccounts.map((account) => renderAccountCard(account))}
-
- `
- : html`
-
-
-
Configured
-
${telegram?.configured ? "Yes" : "No"}
+ ${hasMultipleAccounts
+ ? html`
+
+ ${telegramAccounts.map((account) => renderAccountCard(account))}
-
-
Running
-
${telegram?.running ? "Yes" : "No"}
+ `
+ : html`
+
+
+ Configured
+ ${telegram?.configured ? "Yes" : "No"}
+
+
+ Running
+ ${telegram?.running ? "Yes" : "No"}
+
+
+ Mode
+ ${telegram?.mode ?? "n/a"}
+
+
+ Last start
+ ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}
+
+
+ Last probe
+ ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}
+
-
- Mode
- ${telegram?.mode ?? "n/a"}
-
-
- Last start
- ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}
-
-
- Last probe
- ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}
-
-
- `}
+ `}
- ${telegram?.lastError
- ? html`
- ${telegram.lastError}
-
`
- : nothing}
+ ${telegram?.probe
+ ? html`
+ Probe ${telegram.probe.ok ? "ok" : "failed"} ·
+ ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
+
`
+ : nothing}
- ${telegram?.probe
- ? html`
- Probe ${telegram.probe.ok ? "ok" : "failed"} ·
- ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
-
`
- : nothing}
+ ${renderChannelConfigSection({ channelId: "telegram", props })}
+
- ${renderChannelConfigSection({ channelId: "telegram", props })}
+
`;
diff --git a/ui/src/ui/views/channels.whatsapp.ts b/ui/src/ui/views/channels.whatsapp.ts
index eae3be695..e3043d809 100644
--- a/ui/src/ui/views/channels.whatsapp.ts
+++ b/ui/src/ui/views/channels.whatsapp.ts
@@ -5,6 +5,7 @@ import type { WhatsAppStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
import { formatDuration } from "./channels.shared";
+import "../components/dropdown-menu";
export function renderWhatsAppCard(params: {
props: ChannelsProps;
@@ -13,106 +14,132 @@ export function renderWhatsAppCard(params: {
}) {
const { props, whatsapp, accountCountLabel } = params;
+ const menuItems = [
+ { label: "Relink", value: "relink", disabled: props.whatsappBusy },
+ { label: "Wait for scan", value: "wait", disabled: props.whatsappBusy },
+ { label: "Logout", value: "logout", variant: "danger" as const, disabled: props.whatsappBusy },
+ ];
+
+ const handleMenuSelect = (e: CustomEvent) => {
+ const { value } = e.detail;
+ switch (value) {
+ case "relink":
+ props.onWhatsAppStart(true);
+ break;
+ case "wait":
+ props.onWhatsAppWait();
+ break;
+ case "logout":
+ props.onWhatsAppLogout();
+ break;
+ }
+ };
+
+ const configDisabled = props.configSaving || props.configSchemaLoading;
+
return html`
-
-
WhatsApp
-
Link WhatsApp Web and monitor connection health.
- ${accountCountLabel}
+
+
+
WhatsApp
+
Link WhatsApp Web and monitor connection health.
+ ${accountCountLabel}
-
-
- Configured
- ${whatsapp?.configured ? "Yes" : "No"}
-
-
- Linked
- ${whatsapp?.linked ? "Yes" : "No"}
-
-
- Running
- ${whatsapp?.running ? "Yes" : "No"}
-
-
- Connected
- ${whatsapp?.connected ? "Yes" : "No"}
-
-
- Last connect
-
- ${whatsapp?.lastConnectedAt
- ? formatAgo(whatsapp.lastConnectedAt)
- : "n/a"}
-
-
-
- Last message
-
- ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
-
-
-
-
Auth age
-
- ${whatsapp?.authAgeMs != null
- ? formatDuration(whatsapp.authAgeMs)
- : "n/a"}
-
+
+
+ Configured
+ ${whatsapp?.configured ? "Yes" : "No"}
+
+
+ Linked
+ ${whatsapp?.linked ? "Yes" : "No"}
+
+
+ Running
+ ${whatsapp?.running ? "Yes" : "No"}
+
+
+ Connected
+ ${whatsapp?.connected ? "Yes" : "No"}
+
+
+ Last connect
+
+ ${whatsapp?.lastConnectedAt
+ ? formatAgo(whatsapp.lastConnectedAt)
+ : "n/a"}
+
+
+
+ Last message
+
+ ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
+
+
+
+ Auth age
+
+ ${whatsapp?.authAgeMs != null
+ ? formatDuration(whatsapp.authAgeMs)
+ : "n/a"}
+
+
+
+ ${props.whatsappMessage
+ ? html`
+ ${props.whatsappMessage}
+
`
+ : nothing}
+
+ ${props.whatsappQrDataUrl
+ ? html`
+

+
`
+ : nothing}
+
+ ${renderChannelConfigSection({ channelId: "whatsapp", props })}
- ${whatsapp?.lastError
- ? html`
- ${whatsapp.lastError}
-
`
- : nothing}
+
`;
}
diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts
index f5fb6e80b..99ba1137f 100644
--- a/ui/src/ui/views/chat.ts
+++ b/ui/src/ui/views/chat.ts
@@ -16,6 +16,7 @@ import {
} from "../chat/grouped-render";
import { renderMarkdownSidebar } from "./markdown-sidebar";
import "../components/resizable-divider";
+import "../components/button";
export type CompactionIndicatorStatus = {
active: boolean;
diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts
index 9d121d7f1..43faca842 100644
--- a/ui/src/ui/views/config-form.node.ts
+++ b/ui/src/ui/views/config-form.node.ts
@@ -27,13 +27,18 @@ function jsonValue(value: unknown): string {
}
// SVG Icons as template literals
-const icons = {
- chevronDown: html`
`,
- plus: html`
`,
- minus: html`
`,
- trash: html`
`,
- edit: html`
`,
-};
+import "../components";
+
+// Helper to wrap generic patch with component events
+function handlePatch(
+ path: Array
,
+ onPatch: (path: Array, value: unknown) => void
+) {
+ return (e: CustomEvent) => {
+ const { value, checked } = e.detail;
+ onPatch(path, checked ?? value);
+ };
+}
export function renderNode(params: {
schema: JsonSchema;
@@ -83,22 +88,21 @@ export function renderNode(params: {
if (allLiterals && literals.length > 0 && literals.length <= 5) {
// Use segmented control for small sets
const resolvedValue = value ?? schema.default;
+ const options = literals.map((lit) => ({ label: String(lit), value: String(lit) }));
+
return html`
${showLabel ? html`
` : nothing}
${help ? html`
${help}
` : nothing}
-
- ${literals.map((lit, idx) => html`
-
- `)}
-
+
{
+ // Find original literal type
+ const selected = literals.find(l => String(l) === e.detail.value);
+ onPatch(path, selected);
+ }}
+ >
`;
}
@@ -142,22 +146,18 @@ export function renderNode(params: {
const options = schema.enum;
if (options.length <= 5) {
const resolvedValue = value ?? schema.default;
+ const enumOptions = options.map((opt) => ({ label: String(opt), value: String(opt) }));
return html`
${showLabel ? html`
` : nothing}
${help ? html`
${help}
` : nothing}
-
- ${options.map((opt) => html`
-
- `)}
-
+
{
+ onPatch(path, e.detail.value);
+ }}
+ >
`;
}
@@ -178,21 +178,17 @@ export function renderNode(params: {
if (type === "boolean") {
const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false;
return html`
-
@@ -297,29 +293,16 @@ function renderNumberInput(params: {
${showLabel ? html`
${label}` : nothing}
${help ? html`
${help}
` : nothing}
-
- {
- const raw = (e.target as HTMLInputElement).value;
- const parsed = raw === "" ? undefined : Number(raw);
- onPatch(path, parsed);
- }}
- />
-
+ @input=${(e: CustomEvent) => {
+ const raw = e.detail.value;
+ const parsed = raw === "" ? undefined : Number(raw);
+ onPatch(path, parsed);
+ }}
+ >
`;
@@ -350,20 +333,18 @@ function renderSelect(params: {
${showLabel ? html`
${label}` : nothing}
${help ? html`
${help}
` : nothing}
-
+ @change=${(e: CustomEvent) => {
+ const val = e.detail.value;
+ onPatch(path, val === unset ? undefined : options[Number(val)]);
+ }}
+ >
`;
}
@@ -408,26 +389,26 @@ function renderObject(params: {
return html`
${sorted.map(([propKey, node]) =>
- renderNode({
- schema: node,
- value: obj[propKey],
- path: [...path, propKey],
- hints,
- unsupported,
- disabled,
- onPatch,
- })
- )}
+ renderNode({
+ schema: node,
+ value: obj[propKey],
+ path: [...path, propKey],
+ hints,
+ unsupported,
+ disabled,
+ onPatch,
+ })
+ )}
${allowExtra ? renderMapField({
- schema: additional as JsonSchema,
- value: obj,
- path,
- hints,
- unsupported,
- disabled,
- reservedKeys: reserved,
- onPatch,
- }) : nothing}
+ schema: additional as JsonSchema,
+ value: obj,
+ path,
+ hints,
+ unsupported,
+ disabled,
+ reservedKeys: reserved,
+ onPatch,
+ }) : nothing}
`;
}
@@ -437,31 +418,31 @@ function renderObject(params: {
${help ? html`${help}
` : nothing}
${sorted.map(([propKey, node]) =>
- renderNode({
- schema: node,
- value: obj[propKey],
- path: [...path, propKey],
- hints,
- unsupported,
- disabled,
- onPatch,
- })
- )}
+ renderNode({
+ schema: node,
+ value: obj[propKey],
+ path: [...path, propKey],
+ hints,
+ unsupported,
+ disabled,
+ onPatch,
+ })
+ )}
${allowExtra ? renderMapField({
- schema: additional as JsonSchema,
- value: obj,
- path,
- hints,
- unsupported,
- disabled,
- reservedKeys: reserved,
- onPatch,
- }) : nothing}
+ schema: additional as JsonSchema,
+ value: obj,
+ path,
+ hints,
+ unsupported,
+ disabled,
+ reservedKeys: reserved,
+ onPatch,
+ }) : nothing}
`;
@@ -499,19 +480,17 @@ function renderArray(params: {
${help ? html`
${help}
` : nothing}
@@ -525,31 +504,30 @@ function renderArray(params: {
${renderNode({
- schema: itemsSchema,
- value: item,
- path: [...path, idx],
- hints,
- unsupported,
- disabled,
- showLabel: false,
- onPatch,
- })}
+ schema: itemsSchema,
+ value: item,
+ path: [...path, idx],
+ hints,
+ unsupported,
+ disabled,
+ showLabel: false,
+ onPatch,
+ })}
`)}
@@ -577,25 +555,23 @@ function renderMapField(params: {
${entries.length === 0 ? html`
@@ -603,79 +579,75 @@ function renderMapField(params: {
` : html`
${entries.map(([key, entryValue]) => {
- const valuePath = [...path, key];
- const fallback = jsonValue(entryValue);
- return html`
+ const valuePath = [...path, key];
+ const fallback = jsonValue(entryValue);
+ return html`
- {
- const nextKey = (e.target as HTMLInputElement).value.trim();
- if (!nextKey || nextKey === key) return;
- const next = { ...(value ?? {}) };
- if (nextKey in next) return;
- next[nextKey] = next[key];
- delete next[key];
- onPatch(path, next);
- }}
- />
+ @change=${(e: CustomEvent) => {
+ const nextKey = e.detail.value.trim();
+ if (!nextKey || nextKey === key) return;
+ const next = { ...(value ?? {}) };
+ if (nextKey in next) return;
+ next[nextKey] = next[key];
+ delete next[key];
+ onPatch(path, next);
+ }}
+ >
${anySchema
- ? html`
-
+ @change=${(e: CustomEvent) => {
+ const raw = e.detail.value.trim();
+ if (!raw) {
+ onPatch(valuePath, undefined);
+ return;
+ }
+ try {
+ onPatch(valuePath, JSON.parse(raw));
+ } catch {
+ // target.value = fallback; // ui-textarea handles its own value
+ }
+ }}
+ >
`
- : renderNode({
- schema,
- value: entryValue,
- path: valuePath,
- hints,
- unsupported,
- disabled,
- showLabel: false,
- onPatch,
- })}
+ : renderNode({
+ schema,
+ value: entryValue,
+ path: valuePath,
+ hints,
+ unsupported,
+ disabled,
+ showLabel: false,
+ onPatch,
+ })}
-
+ const next = { ...(value ?? {}) };
+ delete next[key];
+ onPatch(path, next);
+ }}
+ >
`;
- })}
+ })}
`}
diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts
index ff6f57f32..1d4c87baf 100644
--- a/ui/src/ui/views/config.ts
+++ b/ui/src/ui/views/config.ts
@@ -7,6 +7,10 @@ import {
schemaType,
type JsonSchema,
} from "./config-form.shared";
+import "../components/button";
+import "../components/badge";
+import "../components/input";
+import "../components/textarea";
export type ConfigProps = {
raw: string;
@@ -261,17 +265,12 @@ export function renderConfig(props: ConfigProps) {
-
-
props.onSearchChange((e.target as HTMLInputElement).value)}
- />
+ icon="search"
+ @input=${(e: CustomEvent) => props.onSearchChange(e.detail.value)}
+ >
${props.searchQuery ? html`
-
+
${props.loading ? "Loading…" : "Reload"}
-
-
+
${props.saving ? "Saving…" : "Save"}
-
-
+
${props.applying ? "Applying…" : "Apply"}
-
-
+
${props.updating ? "Updating…" : "Update"}
-
+
@@ -451,14 +450,15 @@ export function renderConfig(props: ConfigProps) {
: nothing}
`
: html`
-
- Raw JSON5
-
-
+ .rows=${25}
+ @input=${(e: CustomEvent) =>
+ props.onRawChange(e.detail.value)}
+ >
+
`}
diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts
index d25e3eb45..e4aabfabe 100644
--- a/ui/src/ui/views/cron.ts
+++ b/ui/src/ui/views/cron.ts
@@ -64,10 +64,10 @@ export function renderCron(props: CronProps) {
Enabled
${props.status
- ? props.status.enabled
- ? "Yes"
- : "No"
- : "n/a"}
+ ? props.status.enabled
+ ? "Yes"
+ : "No"
+ : "n/a"}
@@ -96,7 +96,7 @@ export function renderCron(props: CronProps) {
- props.onFormChange({ name: (e.target as HTMLInputElement).value })}
+ props.onFormChange({ name: (e.target as HTMLInputElement).value })}
/>
@@ -104,7 +104,7 @@ export function renderCron(props: CronProps) {
- props.onFormChange({ description: (e.target as HTMLInputElement).value })}
+ props.onFormChange({ description: (e.target as HTMLInputElement).value })}
/>
@@ -112,27 +112,26 @@ export function renderCron(props: CronProps) {
- props.onFormChange({ agentId: (e.target as HTMLInputElement).value })}
+ props.onFormChange({ agentId: (e.target as HTMLInputElement).value })}
placeholder="default"
/>
-
- Enabled
-
+
- props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })}
- />
-
+ @change=${(e: CustomEvent) =>
+ props.onFormChange({ enabled: e.detail.checked })}
+ >
+
Schedule
${props.form.payloadKind === "agentTurn"
- ? html`
+ ? html`
Deliver
@@ -202,26 +201,26 @@ export function renderCron(props: CronProps) {
type="checkbox"
.checked=${props.form.deliver}
@change=${(e: Event) =>
- props.onFormChange({
- deliver: (e.target as HTMLInputElement).checked,
- })}
+ props.onFormChange({
+ deliver: (e.target as HTMLInputElement).checked,
+ })}
/>
-
+
Channel
- props.onFormChange({
- channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
- })}
+ props.onFormChange({
+ channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
+ })}
>
${channelOptions.map(
- (channel) =>
- html``,
- )}
+ )}
@@ -229,7 +228,7 @@ export function renderCron(props: CronProps) {
- props.onFormChange({ to: (e.target as HTMLInputElement).value })}
+ props.onFormChange({ to: (e.target as HTMLInputElement).value })}
placeholder="+1555… or chat id"
/>
@@ -238,28 +237,28 @@ export function renderCron(props: CronProps) {
- props.onFormChange({
- timeoutSeconds: (e.target as HTMLInputElement).value,
- })}
+ props.onFormChange({
+ timeoutSeconds: (e.target as HTMLInputElement).value,
+ })}
/>
${props.form.sessionTarget === "isolated"
- ? html`
+ ? html`
Post to main prefix
- props.onFormChange({
- postToMainPrefix: (e.target as HTMLInputElement).value,
- })}
+ props.onFormChange({
+ postToMainPrefix: (e.target as HTMLInputElement).value,
+ })}
/>
`
- : nothing}
+ : nothing}
`
- : nothing}
+ : nothing}
${props.busy ? "Saving…" : "Add job"}
@@ -272,8 +271,8 @@ export function renderCron(props: CronProps) {
Jobs
All scheduled jobs stored in the gateway.
${props.jobs.length === 0
- ? html`No jobs yet.
`
- : html`
+ ? html`No jobs yet.
`
+ : html`
${props.jobs.map((job) => renderJob(job, props))}
@@ -284,14 +283,14 @@ export function renderCron(props: CronProps) {
Run history
Latest runs for ${props.runsJobId ?? "(select a job)"}.
${props.runsJobId == null
- ? html`
+ ? html`
Select a job to inspect run history.
`
- : props.runs.length === 0
- ? html`No runs yet.
`
- : html`
+ : props.runs.length === 0
+ ? html`No runs yet.
`
+ : html`
${props.runs.map((entry) => renderRun(entry))}
@@ -310,9 +309,9 @@ function renderScheduleFields(props: CronProps) {
type="datetime-local"
.value=${form.scheduleAt}
@input=${(e: Event) =>
- props.onFormChange({
- scheduleAt: (e.target as HTMLInputElement).value,
- })}
+ props.onFormChange({
+ scheduleAt: (e.target as HTMLInputElement).value,
+ })}
/>
`;
@@ -325,9 +324,9 @@ function renderScheduleFields(props: CronProps) {
- props.onFormChange({
- everyAmount: (e.target as HTMLInputElement).value,
- })}
+ props.onFormChange({
+ everyAmount: (e.target as HTMLInputElement).value,
+ })}
/>
@@ -335,9 +334,9 @@ function renderScheduleFields(props: CronProps) {
- props.onFormChange({
- everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
- })}
+ props.onFormChange({
+ everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
+ })}
>
@@ -354,7 +353,7 @@ function renderScheduleFields(props: CronProps) {
- props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })}
+ props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })}
/>
@@ -362,7 +361,7 @@ function renderScheduleFields(props: CronProps) {
- props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })}
+ props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })}
/>
@@ -392,9 +391,9 @@ function renderJob(job: CronJob, props: CronProps) {
class="btn"
?disabled=${props.busy}
@click=${(event: Event) => {
- event.stopPropagation();
- props.onToggle(job, !job.enabled);
- }}
+ event.stopPropagation();
+ props.onToggle(job, !job.enabled);
+ }}
>
${job.enabled ? "Disable" : "Enable"}
@@ -402,9 +401,9 @@ function renderJob(job: CronJob, props: CronProps) {
class="btn"
?disabled=${props.busy}
@click=${(event: Event) => {
- event.stopPropagation();
- props.onRun(job);
- }}
+ event.stopPropagation();
+ props.onRun(job);
+ }}
>
Run
@@ -412,9 +411,9 @@ function renderJob(job: CronJob, props: CronProps) {
class="btn"
?disabled=${props.busy}
@click=${(event: Event) => {
- event.stopPropagation();
- props.onLoadRuns(job.id);
- }}
+ event.stopPropagation();
+ props.onLoadRuns(job.id);
+ }}
>
Runs
@@ -422,9 +421,9 @@ function renderJob(job: CronJob, props: CronProps) {
class="btn danger"
?disabled=${props.busy}
@click=${(event: Event) => {
- event.stopPropagation();
- props.onRemove(job);
- }}
+ event.stopPropagation();
+ props.onRemove(job);
+ }}
>
Remove
diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts
index 822c508a1..0f90862b9 100644
--- a/ui/src/ui/views/debug.ts
+++ b/ui/src/ui/views/debug.ts
@@ -2,6 +2,8 @@ import { html, nothing } from "lit";
import { formatEventPayload } from "../presenter";
import type { EventLogEntry } from "../app-events";
+import "../components/button";
+import "../components/input";
export type DebugProps = {
loading: boolean;
@@ -45,9 +47,9 @@ export function renderDebug(props: DebugProps) {
Snapshots
Status, health, and heartbeat data.
-
+
${props.loading ? "Refreshing…" : "Refresh"}
-
+
@@ -74,28 +76,26 @@ export function renderDebug(props: DebugProps) {
Manual RPC
Send a raw gateway method with JSON params.
-