import { html, nothing } from "lit"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; import { TAB_GROUPS, pathForTab, subtitleForTab, titleForTab, type Tab, } from "./navigation"; import type { UiSettings } from "./storage"; import type { ThemeMode } from "./theme"; import type { ThemeTransitionContext } from "./theme-transition"; import type { ConfigSnapshot, CronJob, CronRunLogEntry, CronStatus, HealthSnapshot, PresenceEntry, ProvidersStatusSnapshot, SessionsListResult, SkillStatusReport, StatusSummary, } from "./types"; import type { CronFormState, DiscordForm, IMessageForm, SlackForm, SignalForm, TelegramForm, } from "./ui-types"; import { renderChat } from "./views/chat"; import { renderConfig } from "./views/config"; import { renderConnections } from "./views/connections"; import { renderCron } from "./views/cron"; import { renderDebug } from "./views/debug"; import { renderInstances } from "./views/instances"; import { renderNodes } from "./views/nodes"; import { renderOverview } from "./views/overview"; import { renderSessions } from "./views/sessions"; import { renderSkills } from "./views/skills"; import { loadProviders, updateDiscordForm, updateIMessageForm, updateSlackForm, updateSignalForm, updateTelegramForm, } from "./controllers/connections"; import { loadPresence } from "./controllers/presence"; import { loadSessions, patchSession } from "./controllers/sessions"; import { installSkill, loadSkills, saveSkillApiKey, updateSkillEdit, updateSkillEnabled, } from "./controllers/skills"; import { loadNodes } from "./controllers/nodes"; import { loadChatHistory } from "./controllers/chat"; import { applyConfig, loadConfig, runUpdate, saveConfig, updateConfigFormValue, } from "./controllers/config"; import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; import { loadDebug, callDebugMethod } from "./controllers/debug"; export type EventLogEntry = { ts: number; event: string; payload?: unknown; }; export type AppViewState = { settings: UiSettings; password: string; tab: Tab; basePath: string; connected: boolean; theme: ThemeMode; themeResolved: "light" | "dark"; hello: GatewayHelloOk | null; lastError: string | null; eventLog: EventLogEntry[]; sessionKey: string; chatLoading: boolean; chatSending: boolean; chatMessage: string; chatMessages: unknown[]; chatToolMessages: unknown[]; chatStream: string | null; chatRunId: string | null; chatThinkingLevel: string | null; nodesLoading: boolean; nodes: Array>; configLoading: boolean; configRaw: string; configValid: boolean | null; configIssues: unknown[]; configSaving: boolean; configApplying: boolean; updateRunning: boolean; configSnapshot: ConfigSnapshot | null; configSchema: unknown | null; configSchemaLoading: boolean; configUiHints: Record; configForm: Record | null; configFormMode: "form" | "raw"; providersLoading: boolean; providersSnapshot: ProvidersStatusSnapshot | null; providersError: string | null; providersLastSuccess: number | null; whatsappLoginMessage: string | null; whatsappLoginQrDataUrl: string | null; whatsappLoginConnected: boolean | null; whatsappBusy: boolean; telegramForm: TelegramForm; telegramSaving: boolean; telegramTokenLocked: boolean; telegramConfigStatus: string | null; discordForm: DiscordForm; discordSaving: boolean; discordTokenLocked: boolean; discordConfigStatus: string | null; slackForm: SlackForm; slackSaving: boolean; slackTokenLocked: boolean; slackAppTokenLocked: boolean; slackConfigStatus: string | null; signalForm: SignalForm; signalSaving: boolean; signalConfigStatus: string | null; imessageForm: IMessageForm; imessageSaving: boolean; imessageConfigStatus: string | null; presenceLoading: boolean; presenceEntries: PresenceEntry[]; presenceError: string | null; presenceStatus: string | null; sessionsLoading: boolean; sessionsResult: SessionsListResult | null; sessionsError: string | null; sessionsFilterActive: string; sessionsFilterLimit: string; sessionsIncludeGlobal: boolean; sessionsIncludeUnknown: boolean; cronLoading: boolean; cronJobs: CronJob[]; cronStatus: CronStatus | null; cronError: string | null; cronForm: CronFormState; cronRunsJobId: string | null; cronRuns: CronRunLogEntry[]; cronBusy: boolean; skillsLoading: boolean; skillsReport: SkillStatusReport | null; skillsError: string | null; skillsFilter: string; skillEdits: Record; skillsBusyKey: string | null; debugLoading: boolean; debugStatus: StatusSummary | null; debugHealth: HealthSnapshot | null; debugModels: unknown[]; debugHeartbeat: unknown | null; debugCallMethod: string; debugCallParams: string; debugCallResult: string | null; debugCallError: string | null; client: GatewayBrowserClient | null; connect: () => void; setTab: (tab: Tab) => void; setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void; applySettings: (next: UiSettings) => void; loadOverview: () => Promise; loadCron: () => Promise; handleWhatsAppStart: (force: boolean) => Promise; handleWhatsAppWait: () => Promise; handleWhatsAppLogout: () => Promise; handleTelegramSave: () => Promise; handleSendChat: () => Promise; resetToolStream: () => void; }; export function renderApp(state: AppViewState) { const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null; const chatDisabledReason = state.connected ? null : "Disconnected from gateway."; const isChat = state.tab === "chat"; const chatFocus = isChat && state.settings.chatFocusMode; return html`
Clawdbot Control
Gateway dashboard
Health ${state.connected ? "OK" : "Offline"}
${renderThemeToggle(state)}
${titleForTab(state.tab)}
${subtitleForTab(state.tab)}
${state.lastError ? html`
${state.lastError}
` : 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, lastProvidersRefresh: state.providersLastSuccess, 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, }); }, onConnect: () => state.connect(), onRefresh: () => state.loadOverview(), }) : nothing} ${state.tab === "connections" ? renderConnections({ connected: state.connected, loading: state.providersLoading, snapshot: state.providersSnapshot, lastError: state.providersError, lastSuccessAt: state.providersLastSuccess, whatsappMessage: state.whatsappLoginMessage, whatsappQrDataUrl: state.whatsappLoginQrDataUrl, whatsappConnected: state.whatsappLoginConnected, whatsappBusy: state.whatsappBusy, telegramForm: state.telegramForm, telegramTokenLocked: state.telegramTokenLocked, telegramSaving: state.telegramSaving, telegramStatus: state.telegramConfigStatus, discordForm: state.discordForm, discordTokenLocked: state.discordTokenLocked, discordSaving: state.discordSaving, discordStatus: state.discordConfigStatus, slackForm: state.slackForm, slackTokenLocked: state.slackTokenLocked, slackAppTokenLocked: state.slackAppTokenLocked, slackSaving: state.slackSaving, slackStatus: state.slackConfigStatus, signalForm: state.signalForm, signalSaving: state.signalSaving, signalStatus: state.signalConfigStatus, imessageForm: state.imessageForm, imessageSaving: state.imessageSaving, imessageStatus: state.imessageConfigStatus, onRefresh: (probe) => loadProviders(state, probe), onWhatsAppStart: (force) => state.handleWhatsAppStart(force), onWhatsAppWait: () => state.handleWhatsAppWait(), onWhatsAppLogout: () => state.handleWhatsAppLogout(), onTelegramChange: (patch) => updateTelegramForm(state, patch), onTelegramSave: () => state.handleTelegramSave(), onDiscordChange: (patch) => updateDiscordForm(state, patch), onDiscordSave: () => state.handleDiscordSave(), onSlackChange: (patch) => updateSlackForm(state, patch), onSlackSave: () => state.handleSlackSave(), onSignalChange: (patch) => updateSignalForm(state, patch), onSignalSave: () => state.handleSignalSave(), onIMessageChange: (patch) => updateIMessageForm(state, patch), onIMessageSave: () => state.handleIMessageSave(), }) : nothing} ${state.tab === "instances" ? 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, 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), }) : nothing} ${state.tab === "cron" ? renderCron({ loading: state.cronLoading, status: state.cronStatus, jobs: state.cronJobs, error: state.cronError, busy: state.cronBusy, form: state.cronForm, 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, busyKey: state.skillsBusyKey, onFilterChange: (next) => (state.skillsFilter = next), onRefresh: () => loadSkills(state), onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), onEdit: (key, value) => updateSkillEdit(state, key, value), onSaveKey: (key) => saveSkillApiKey(state, key), onInstall: (name, installId) => installSkill(state, name, installId), }) : nothing} ${state.tab === "nodes" ? renderNodes({ loading: state.nodesLoading, nodes: state.nodes, onRefresh: () => loadNodes(state), }) : nothing} ${state.tab === "chat" ? renderChat({ sessionKey: state.sessionKey, onSessionKeyChange: (next) => { state.sessionKey = next; state.chatMessage = ""; state.chatStream = null; state.chatStreamStartedAt = null; state.chatRunId = null; state.resetToolStream(); state.resetChatScroll(); state.applySettings({ ...state.settings, sessionKey: next, lastActiveSessionKey: next, }); void loadChatHistory(state); }, thinkingLevel: state.chatThinkingLevel, loading: state.chatLoading, sending: state.chatSending, messages: state.chatMessages, toolMessages: state.chatToolMessages, stream: state.chatStream, streamStartedAt: state.chatStreamStartedAt, draft: state.chatMessage, connected: state.connected, canSend: state.connected, disabledReason: chatDisabledReason, error: state.lastError, sessions: state.sessionsResult, focusMode: state.settings.chatFocusMode, onRefresh: () => { state.resetToolStream(); return loadChatHistory(state); }, onToggleFocusMode: () => state.applySettings({ ...state.settings, chatFocusMode: !state.settings.chatFocusMode, }), onDraftChange: (next) => (state.chatMessage = next), onSend: () => state.handleSendChat(), }) : nothing} ${state.tab === "config" ? renderConfig({ raw: state.configRaw, 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, onRawChange: (next) => (state.configRaw = next), onFormModeChange: (mode) => (state.configFormMode = mode), onFormPatch: (path, value) => updateConfigFormValue(state, path, value), 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}
`; } function renderTab(state: AppViewState, tab: Tab) { const href = pathForTab(tab, state.basePath); return html` { if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) { return; } event.preventDefault(); state.setTab(tab); }} > ${titleForTab(tab)} `; } const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"]; function renderThemeToggle(state: AppViewState) { const index = Math.max(0, THEME_ORDER.indexOf(state.theme)); const applyTheme = (next: ThemeMode) => (event: MouseEvent) => { const element = event.currentTarget as HTMLElement; const context: ThemeTransitionContext = { element }; if (event.clientX || event.clientY) { context.pointerClientX = event.clientX; context.pointerClientY = event.clientY; } state.setTheme(next, context); }; return html`
`; } function renderSunIcon() { return html` `; } function renderMoonIcon() { return html` `; } function renderMonitorIcon() { return html` `; }