diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts new file mode 100644 index 000000000..531379739 --- /dev/null +++ b/ui/src/ui/app-chat.ts @@ -0,0 +1,131 @@ +import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat"; +import { loadSessions } from "./controllers/sessions"; +import { generateUUID } from "./uuid"; +import { resetToolStream } from "./app-tool-stream"; +import { scheduleChatScroll } from "./app-scroll"; +import { setLastActiveSessionKey } from "./app-settings"; +import type { ClawdbotApp } from "./app"; + +type ChatHost = { + connected: boolean; + chatMessage: string; + chatQueue: Array<{ id: string; text: string; createdAt: number }>; + chatRunId: string | null; + chatSending: boolean; + sessionKey: string; +}; + +export function isChatBusy(host: ChatHost) { + return host.chatSending || Boolean(host.chatRunId); +} + +export function isChatStopCommand(text: string) { + const trimmed = text.trim(); + if (!trimmed) return false; + const normalized = trimmed.toLowerCase(); + if (normalized === "/stop") return true; + return ( + normalized === "stop" || + normalized === "esc" || + normalized === "abort" || + normalized === "wait" || + normalized === "exit" + ); +} + +export async function handleAbortChat(host: ChatHost) { + if (!host.connected) return; + host.chatMessage = ""; + await abortChatRun(host as unknown as ClawdbotApp); +} + +function enqueueChatMessage(host: ChatHost, text: string) { + const trimmed = text.trim(); + if (!trimmed) return; + host.chatQueue = [ + ...host.chatQueue, + { + id: generateUUID(), + text: trimmed, + createdAt: Date.now(), + }, + ]; +} + +async function sendChatMessageNow( + host: ChatHost, + message: string, + opts?: { previousDraft?: string; restoreDraft?: boolean }, +) { + resetToolStream(host as unknown as Parameters[0]); + const ok = await sendChatMessage(host as unknown as ClawdbotApp, message); + if (!ok && opts?.previousDraft != null) { + host.chatMessage = opts.previousDraft; + } + if (ok) { + setLastActiveSessionKey(host as unknown as Parameters[0], host.sessionKey); + } + if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) { + host.chatMessage = opts.previousDraft; + } + scheduleChatScroll(host as unknown as Parameters[0]); + if (ok && !host.chatRunId) { + void flushChatQueue(host); + } + return ok; +} + +async function flushChatQueue(host: ChatHost) { + if (!host.connected || isChatBusy(host)) return; + const [next, ...rest] = host.chatQueue; + if (!next) return; + host.chatQueue = rest; + const ok = await sendChatMessageNow(host, next.text); + if (!ok) { + host.chatQueue = [next, ...host.chatQueue]; + } +} + +export function removeQueuedMessage(host: ChatHost, id: string) { + host.chatQueue = host.chatQueue.filter((item) => item.id !== id); +} + +export async function handleSendChat( + host: ChatHost, + messageOverride?: string, + opts?: { restoreDraft?: boolean }, +) { + if (!host.connected) return; + const previousDraft = host.chatMessage; + const message = (messageOverride ?? host.chatMessage).trim(); + if (!message) return; + + if (isChatStopCommand(message)) { + await handleAbortChat(host); + return; + } + + if (messageOverride == null) { + host.chatMessage = ""; + } + + if (isChatBusy(host)) { + enqueueChatMessage(host, message); + return; + } + + await sendChatMessageNow(host, message, { + previousDraft: messageOverride == null ? previousDraft : undefined, + restoreDraft: Boolean(messageOverride && opts?.restoreDraft), + }); +} + +export async function refreshChat(host: ChatHost) { + await Promise.all([ + loadChatHistory(host as unknown as ClawdbotApp), + loadSessions(host as unknown as ClawdbotApp), + ]); + scheduleChatScroll(host as unknown as Parameters[0], true); +} + +export const flushChatQueueForEvent = flushChatQueue; diff --git a/ui/src/ui/app-connections.ts b/ui/src/ui/app-connections.ts new file mode 100644 index 000000000..a43c1948c --- /dev/null +++ b/ui/src/ui/app-connections.ts @@ -0,0 +1,58 @@ +import { + loadChannels, + logoutWhatsApp, + saveDiscordConfig, + saveIMessageConfig, + saveSlackConfig, + saveSignalConfig, + saveTelegramConfig, + startWhatsAppLogin, + waitWhatsAppLogin, +} from "./controllers/connections"; +import { loadConfig } from "./controllers/config"; +import type { ClawdbotApp } from "./app"; + +export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) { + await startWhatsAppLogin(host, force); + await loadChannels(host, true); +} + +export async function handleWhatsAppWait(host: ClawdbotApp) { + await waitWhatsAppLogin(host); + await loadChannels(host, true); +} + +export async function handleWhatsAppLogout(host: ClawdbotApp) { + await logoutWhatsApp(host); + await loadChannels(host, true); +} + +export async function handleTelegramSave(host: ClawdbotApp) { + await saveTelegramConfig(host); + await loadConfig(host); + await loadChannels(host, true); +} + +export async function handleDiscordSave(host: ClawdbotApp) { + await saveDiscordConfig(host); + await loadConfig(host); + await loadChannels(host, true); +} + +export async function handleSlackSave(host: ClawdbotApp) { + await saveSlackConfig(host); + await loadConfig(host); + await loadChannels(host, true); +} + +export async function handleSignalSave(host: ClawdbotApp) { + await saveSignalConfig(host); + await loadConfig(host); + await loadChannels(host, true); +} + +export async function handleIMessageSave(host: ClawdbotApp) { + await saveIMessageConfig(host); + await loadConfig(host); + await loadChannels(host, true); +} diff --git a/ui/src/ui/app-defaults.ts b/ui/src/ui/app-defaults.ts new file mode 100644 index 000000000..d863fa863 --- /dev/null +++ b/ui/src/ui/app-defaults.ts @@ -0,0 +1,33 @@ +import type { LogLevel } from "./types"; +import type { CronFormState } from "./ui-types"; + +export const DEFAULT_LOG_LEVEL_FILTERS: Record = { + trace: true, + debug: true, + info: true, + warn: true, + error: true, + fatal: true, +}; + +export const DEFAULT_CRON_FORM: CronFormState = { + name: "", + description: "", + agentId: "", + enabled: true, + scheduleKind: "every", + scheduleAt: "", + everyAmount: "30", + everyUnit: "minutes", + cronExpr: "0 7 * * *", + cronTz: "", + sessionTarget: "main", + wakeMode: "next-heartbeat", + payloadKind: "systemEvent", + payloadText: "", + deliver: false, + channel: "last", + to: "", + timeoutSeconds: "", + postToMainPrefix: "", +}; diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts new file mode 100644 index 000000000..076275793 --- /dev/null +++ b/ui/src/ui/app-gateway.ts @@ -0,0 +1,124 @@ +import { loadChatHistory } from "./controllers/chat"; +import { loadNodes } from "./controllers/nodes"; +import type { GatewayEventFrame, GatewayHelloOk } from "./gateway"; +import { GatewayBrowserClient } from "./gateway"; +import type { EventLogEntry } from "./app-events"; +import type { PresenceEntry, HealthSnapshot, StatusSummary } from "./types"; +import type { Tab } from "./navigation"; +import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream"; +import { flushChatQueueForEvent } from "./app-chat"; +import { loadCron, refreshActiveTab, setLastActiveSessionKey } from "./app-settings"; +import { handleChatEvent, type ChatEventPayload } from "./controllers/chat"; +import type { ClawdbotApp } from "./app"; + +type GatewayHost = { + settings: { gatewayUrl: string; token: string }; + password: string; + client: GatewayBrowserClient | null; + connected: boolean; + hello: GatewayHelloOk | null; + lastError: string | null; + eventLogBuffer: EventLogEntry[]; + eventLog: EventLogEntry[]; + tab: Tab; + presenceEntries: PresenceEntry[]; + presenceError: string | null; + presenceStatus: StatusSummary | null; + debugHealth: HealthSnapshot | null; + sessionKey: string; + chatRunId: string | null; +}; + +export function connectGateway(host: GatewayHost) { + host.lastError = null; + host.hello = null; + host.connected = false; + + host.client?.stop(); + host.client = new GatewayBrowserClient({ + url: host.settings.gatewayUrl, + token: host.settings.token.trim() ? host.settings.token : undefined, + password: host.password.trim() ? host.password : undefined, + clientName: "clawdbot-control-ui", + mode: "webchat", + onHello: (hello) => { + host.connected = true; + host.hello = hello; + applySnapshot(host, hello); + void loadNodes(host as unknown as ClawdbotApp, { quiet: true }); + void refreshActiveTab(host as unknown as Parameters[0]); + }, + onClose: ({ code, reason }) => { + host.connected = false; + host.lastError = `disconnected (${code}): ${reason || "no reason"}`; + }, + onEvent: (evt) => handleGatewayEvent(host, evt), + onGap: ({ expected, received }) => { + host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`; + }, + }); + host.client.start(); +} + +export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) { + host.eventLogBuffer = [ + { ts: Date.now(), event: evt.event, payload: evt.payload }, + ...host.eventLogBuffer, + ].slice(0, 250); + if (host.tab === "debug") { + host.eventLog = host.eventLogBuffer; + } + + if (evt.event === "agent") { + handleAgentEvent( + host as unknown as Parameters[0], + evt.payload as AgentEventPayload | undefined, + ); + return; + } + + if (evt.event === "chat") { + const payload = evt.payload as ChatEventPayload | undefined; + if (payload?.sessionKey) { + setLastActiveSessionKey( + host as unknown as Parameters[0], + payload.sessionKey, + ); + } + const state = handleChatEvent(host as unknown as ClawdbotApp, payload); + if (state === "final" || state === "error" || state === "aborted") { + resetToolStream(host as unknown as Parameters[0]); + void flushChatQueueForEvent( + host as unknown as Parameters[0], + ); + } + if (state === "final") void loadChatHistory(host as unknown as ClawdbotApp); + return; + } + + if (evt.event === "presence") { + const payload = evt.payload as { presence?: PresenceEntry[] } | undefined; + if (payload?.presence && Array.isArray(payload.presence)) { + host.presenceEntries = payload.presence; + host.presenceError = null; + host.presenceStatus = null; + } + return; + } + + if (evt.event === "cron" && host.tab === "cron") { + void loadCron(host as unknown as Parameters[0]); + } +} + +export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { + const snapshot = hello.snapshot as + | { presence?: PresenceEntry[]; health?: HealthSnapshot } + | undefined; + if (snapshot?.presence && Array.isArray(snapshot.presence)) { + host.presenceEntries = snapshot.presence; + } + if (snapshot?.health) { + host.debugHealth = snapshot.health; + } +} diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts new file mode 100644 index 000000000..817151d5f --- /dev/null +++ b/ui/src/ui/app-lifecycle.ts @@ -0,0 +1,105 @@ +import type { Tab } from "./navigation"; +import { connectGateway } from "./app-gateway"; +import { + applySettingsFromUrl, + attachThemeListener, + detachThemeListener, + inferBasePath, + syncTabWithLocation, + syncThemeWithSettings, +} from "./app-settings"; +import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll"; +import { + startLogsPolling, + startNodesPolling, + stopLogsPolling, + stopNodesPolling, +} from "./app-polling"; + +type LifecycleHost = { + basePath: string; + tab: Tab; + chatHasAutoScrolled: boolean; + chatLoading: boolean; + chatMessages: unknown[]; + chatToolMessages: unknown[]; + chatStream: string; + logsAutoFollow: boolean; + logsAtBottom: boolean; + logsEntries: unknown[]; + popStateHandler: () => void; + topbarObserver: ResizeObserver | null; +}; + +export function handleConnected(host: LifecycleHost) { + host.basePath = inferBasePath(); + syncTabWithLocation( + host as unknown as Parameters[0], + true, + ); + syncThemeWithSettings( + host as unknown as Parameters[0], + ); + attachThemeListener( + host as unknown as Parameters[0], + ); + window.addEventListener("popstate", host.popStateHandler); + applySettingsFromUrl( + host as unknown as Parameters[0], + ); + connectGateway(host as unknown as Parameters[0]); + startNodesPolling(host as unknown as Parameters[0]); + if (host.tab === "logs") { + startLogsPolling(host as unknown as Parameters[0]); + } +} + +export function handleFirstUpdated(host: LifecycleHost) { + observeTopbar(host as unknown as Parameters[0]); +} + +export function handleDisconnected(host: LifecycleHost) { + window.removeEventListener("popstate", host.popStateHandler); + stopNodesPolling(host as unknown as Parameters[0]); + stopLogsPolling(host as unknown as Parameters[0]); + detachThemeListener( + host as unknown as Parameters[0], + ); + host.topbarObserver?.disconnect(); + host.topbarObserver = null; +} + +export function handleUpdated( + host: LifecycleHost, + changed: Map, +) { + if ( + host.tab === "chat" && + (changed.has("chatMessages") || + changed.has("chatToolMessages") || + changed.has("chatStream") || + changed.has("chatLoading") || + changed.has("tab")) + ) { + const forcedByTab = changed.has("tab"); + const forcedByLoad = + changed.has("chatLoading") && + changed.get("chatLoading") === true && + host.chatLoading === false; + scheduleChatScroll( + host as unknown as Parameters[0], + forcedByTab || forcedByLoad || !host.chatHasAutoScrolled, + ); + } + if ( + host.tab === "logs" && + (changed.has("logsEntries") || changed.has("logsAutoFollow") || changed.has("tab")) + ) { + if (host.logsAutoFollow && host.logsAtBottom) { + scheduleLogsScroll( + host as unknown as Parameters[0], + changed.has("tab") || changed.has("logsAutoFollow"), + ); + } + } +} diff --git a/ui/src/ui/app-polling.ts b/ui/src/ui/app-polling.ts new file mode 100644 index 000000000..53d7b2296 --- /dev/null +++ b/ui/src/ui/app-polling.ts @@ -0,0 +1,37 @@ +import { loadLogs } from "./controllers/logs"; +import { loadNodes } from "./controllers/nodes"; +import type { ClawdbotApp } from "./app"; + +type PollingHost = { + nodesPollInterval: number | null; + logsPollInterval: number | null; + tab: string; +}; + +export function startNodesPolling(host: PollingHost) { + if (host.nodesPollInterval != null) return; + host.nodesPollInterval = window.setInterval( + () => void loadNodes(host as unknown as ClawdbotApp, { quiet: true }), + 5000, + ); +} + +export function stopNodesPolling(host: PollingHost) { + if (host.nodesPollInterval == null) return; + clearInterval(host.nodesPollInterval); + host.nodesPollInterval = null; +} + +export function startLogsPolling(host: PollingHost) { + if (host.logsPollInterval != null) return; + host.logsPollInterval = window.setInterval(() => { + if (host.tab !== "logs") return; + void loadLogs(host as unknown as ClawdbotApp, { quiet: true }); + }, 2000); +} + +export function stopLogsPolling(host: PollingHost) { + if (host.logsPollInterval == null) return; + clearInterval(host.logsPollInterval); + host.logsPollInterval = null; +} diff --git a/ui/src/ui/app-scroll.ts b/ui/src/ui/app-scroll.ts new file mode 100644 index 000000000..5955e80b8 --- /dev/null +++ b/ui/src/ui/app-scroll.ts @@ -0,0 +1,122 @@ +type ScrollHost = { + updateComplete: Promise; + querySelector: (selectors: string) => Element | null; + style: CSSStyleDeclaration; + chatScrollFrame: number | null; + chatScrollTimeout: number | null; + chatHasAutoScrolled: boolean; + chatUserNearBottom: boolean; + logsScrollFrame: number | null; + logsAtBottom: boolean; + topbarObserver: ResizeObserver | null; +}; + +export function scheduleChatScroll(host: ScrollHost, force = false) { + if (host.chatScrollFrame) cancelAnimationFrame(host.chatScrollFrame); + if (host.chatScrollTimeout != null) { + clearTimeout(host.chatScrollTimeout); + host.chatScrollTimeout = null; + } + const pickScrollTarget = () => { + const container = host.querySelector(".chat-thread") as HTMLElement | null; + if (container) { + const overflowY = getComputedStyle(container).overflowY; + const canScroll = + overflowY === "auto" || + overflowY === "scroll" || + container.scrollHeight - container.clientHeight > 1; + if (canScroll) return container; + } + return (document.scrollingElement ?? document.documentElement) as HTMLElement | null; + }; + // Wait for Lit render to complete, then scroll + void host.updateComplete.then(() => { + host.chatScrollFrame = requestAnimationFrame(() => { + host.chatScrollFrame = null; + const target = pickScrollTarget(); + if (!target) return; + const distanceFromBottom = + target.scrollHeight - target.scrollTop - target.clientHeight; + const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200; + if (!shouldStick) return; + if (force) host.chatHasAutoScrolled = true; + target.scrollTop = target.scrollHeight; + host.chatUserNearBottom = true; + const retryDelay = force ? 150 : 120; + host.chatScrollTimeout = window.setTimeout(() => { + host.chatScrollTimeout = null; + const latest = pickScrollTarget(); + if (!latest) return; + const latestDistanceFromBottom = + latest.scrollHeight - latest.scrollTop - latest.clientHeight; + const shouldStickRetry = + force || host.chatUserNearBottom || latestDistanceFromBottom < 200; + if (!shouldStickRetry) return; + latest.scrollTop = latest.scrollHeight; + host.chatUserNearBottom = true; + }, retryDelay); + }); + }); +} + +export function scheduleLogsScroll(host: ScrollHost, force = false) { + if (host.logsScrollFrame) cancelAnimationFrame(host.logsScrollFrame); + void host.updateComplete.then(() => { + host.logsScrollFrame = requestAnimationFrame(() => { + host.logsScrollFrame = null; + const container = host.querySelector(".log-stream") as HTMLElement | null; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + const shouldStick = force || distanceFromBottom < 80; + if (!shouldStick) return; + container.scrollTop = container.scrollHeight; + }); + }); +} + +export function handleChatScroll(host: ScrollHost, event: Event) { + const container = event.currentTarget as HTMLElement | null; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + host.chatUserNearBottom = distanceFromBottom < 200; +} + +export function handleLogsScroll(host: ScrollHost, event: Event) { + const container = event.currentTarget as HTMLElement | null; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + host.logsAtBottom = distanceFromBottom < 80; +} + +export function resetChatScroll(host: ScrollHost) { + host.chatHasAutoScrolled = false; + host.chatUserNearBottom = true; +} + +export function exportLogs(lines: string[], label: string) { + if (lines.length === 0) return; + const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); + anchor.href = url; + anchor.download = `clawdbot-logs-${label}-${stamp}.log`; + anchor.click(); + URL.revokeObjectURL(url); +} + +export function observeTopbar(host: ScrollHost) { + if (typeof ResizeObserver === "undefined") return; + const topbar = host.querySelector(".topbar"); + if (!topbar) return; + const update = () => { + const { height } = topbar.getBoundingClientRect(); + host.style.setProperty("--topbar-height", `${height}px`); + }; + update(); + host.topbarObserver = new ResizeObserver(() => update()); + host.topbarObserver.observe(topbar); +} diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts new file mode 100644 index 000000000..f3b9d4a7f --- /dev/null +++ b/ui/src/ui/app-settings.ts @@ -0,0 +1,271 @@ +import { loadConfig, loadConfigSchema } from "./controllers/config"; +import { loadCronJobs, loadCronStatus } from "./controllers/cron"; +import { loadChannels } from "./controllers/connections"; +import { loadDebug } from "./controllers/debug"; +import { loadLogs } from "./controllers/logs"; +import { loadNodes } from "./controllers/nodes"; +import { loadPresence } from "./controllers/presence"; +import { loadSessions } from "./controllers/sessions"; +import { loadSkills } from "./controllers/skills"; +import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation"; +import { saveSettings, type UiSettings } from "./storage"; +import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme"; +import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition"; +import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll"; +import { startLogsPolling, stopLogsPolling } from "./app-polling"; +import { refreshChat } from "./app-chat"; +import type { ClawdbotApp } from "./app"; + +type SettingsHost = { + settings: UiSettings; + theme: ThemeMode; + themeResolved: ResolvedTheme; + applySessionKey: string; + sessionKey: string; + tab: Tab; + connected: boolean; + chatHasAutoScrolled: boolean; + logsAtBottom: boolean; + eventLog: unknown[]; + eventLogBuffer: unknown[]; + basePath: string; + themeMedia: MediaQueryList | null; + themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; +}; + +export function applySettings(host: SettingsHost, next: UiSettings) { + const normalized = { + ...next, + lastActiveSessionKey: next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main", + }; + host.settings = normalized; + saveSettings(normalized); + if (next.theme !== host.theme) { + host.theme = next.theme; + applyResolvedTheme(host, resolveTheme(next.theme)); + } + host.applySessionKey = host.settings.lastActiveSessionKey; +} + +export function setLastActiveSessionKey(host: SettingsHost, next: string) { + const trimmed = next.trim(); + if (!trimmed) return; + if (host.settings.lastActiveSessionKey === trimmed) return; + applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed }); +} + +export function applySettingsFromUrl(host: SettingsHost) { + if (!window.location.search) return; + const params = new URLSearchParams(window.location.search); + const tokenRaw = params.get("token"); + const passwordRaw = params.get("password"); + const sessionRaw = params.get("session"); + let shouldCleanUrl = false; + + if (tokenRaw != null) { + const token = tokenRaw.trim(); + if (token && !host.settings.token) { + applySettings(host, { ...host.settings, token }); + } + params.delete("token"); + shouldCleanUrl = true; + } + + if (passwordRaw != null) { + const password = passwordRaw.trim(); + if (password) { + (host as { password: string }).password = password; + } + params.delete("password"); + shouldCleanUrl = true; + } + + if (sessionRaw != null) { + const session = sessionRaw.trim(); + if (session) { + host.sessionKey = session; + } + params.delete("session"); + shouldCleanUrl = true; + } + + if (!shouldCleanUrl) return; + const url = new URL(window.location.href); + url.search = params.toString(); + window.history.replaceState({}, "", url.toString()); +} + +export function setTab(host: SettingsHost, next: Tab) { + if (host.tab !== next) host.tab = next; + if (next === "chat") host.chatHasAutoScrolled = false; + if (next === "logs") + startLogsPolling(host as unknown as Parameters[0]); + else stopLogsPolling(host as unknown as Parameters[0]); + void refreshActiveTab(host); + syncUrlWithTab(host, next, false); +} + +export function setTheme( + host: SettingsHost, + next: ThemeMode, + context?: ThemeTransitionContext, +) { + const applyTheme = () => { + host.theme = next; + applySettings(host, { ...host.settings, theme: next }); + applyResolvedTheme(host, resolveTheme(next)); + }; + startThemeTransition({ + nextTheme: next, + applyTheme, + context, + currentTheme: host.theme, + }); +} + +export async function refreshActiveTab(host: SettingsHost) { + if (host.tab === "overview") await loadOverview(host); + if (host.tab === "connections") await loadConnections(host); + if (host.tab === "instances") await loadPresence(host as unknown as ClawdbotApp); + if (host.tab === "sessions") await loadSessions(host as unknown as ClawdbotApp); + if (host.tab === "cron") await loadCron(host); + if (host.tab === "skills") await loadSkills(host as unknown as ClawdbotApp); + if (host.tab === "nodes") await loadNodes(host as unknown as ClawdbotApp); + if (host.tab === "chat") { + await refreshChat(host as unknown as Parameters[0]); + scheduleChatScroll( + host as unknown as Parameters[0], + !host.chatHasAutoScrolled, + ); + } + if (host.tab === "config") { + await loadConfigSchema(host as unknown as ClawdbotApp); + await loadConfig(host as unknown as ClawdbotApp); + } + if (host.tab === "debug") { + await loadDebug(host as unknown as ClawdbotApp); + host.eventLog = host.eventLogBuffer; + } + if (host.tab === "logs") { + host.logsAtBottom = true; + await loadLogs(host as unknown as ClawdbotApp, { reset: true }); + scheduleLogsScroll( + host as unknown as Parameters[0], + true, + ); + } +} + +export function inferBasePath() { + if (typeof window === "undefined") return ""; + const configured = window.__CLAWDBOT_CONTROL_UI_BASE_PATH__; + if (typeof configured === "string" && configured.trim()) { + return normalizeBasePath(configured); + } + return inferBasePathFromPathname(window.location.pathname); +} + +export function syncThemeWithSettings(host: SettingsHost) { + host.theme = host.settings.theme ?? "system"; + applyResolvedTheme(host, resolveTheme(host.theme)); +} + +export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) { + host.themeResolved = resolved; + if (typeof document === "undefined") return; + const root = document.documentElement; + root.dataset.theme = resolved; + root.style.colorScheme = resolved; +} + +export function attachThemeListener(host: SettingsHost) { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)"); + host.themeMediaHandler = (event) => { + if (host.theme !== "system") return; + applyResolvedTheme(host, event.matches ? "dark" : "light"); + }; + if (typeof host.themeMedia.addEventListener === "function") { + host.themeMedia.addEventListener("change", host.themeMediaHandler); + return; + } + const legacy = host.themeMedia as MediaQueryList & { + addListener: (cb: (event: MediaQueryListEvent) => void) => void; + }; + legacy.addListener(host.themeMediaHandler); +} + +export function detachThemeListener(host: SettingsHost) { + if (!host.themeMedia || !host.themeMediaHandler) return; + if (typeof host.themeMedia.removeEventListener === "function") { + host.themeMedia.removeEventListener("change", host.themeMediaHandler); + return; + } + const legacy = host.themeMedia as MediaQueryList & { + removeListener: (cb: (event: MediaQueryListEvent) => void) => void; + }; + legacy.removeListener(host.themeMediaHandler); + host.themeMedia = null; + host.themeMediaHandler = null; +} + +export function syncTabWithLocation(host: SettingsHost, replace: boolean) { + if (typeof window === "undefined") return; + const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat"; + setTabFromRoute(host, resolved); + syncUrlWithTab(host, resolved, replace); +} + +export function onPopState(host: SettingsHost) { + if (typeof window === "undefined") return; + const resolved = tabFromPath(window.location.pathname, host.basePath); + if (!resolved) return; + setTabFromRoute(host, resolved); +} + +export function setTabFromRoute(host: SettingsHost, next: Tab) { + if (host.tab !== next) host.tab = next; + if (next === "chat") host.chatHasAutoScrolled = false; + if (next === "logs") + startLogsPolling(host as unknown as Parameters[0]); + else stopLogsPolling(host as unknown as Parameters[0]); + if (host.connected) void refreshActiveTab(host); +} + +export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) { + if (typeof window === "undefined") return; + const targetPath = normalizePath(pathForTab(tab, host.basePath)); + const currentPath = normalizePath(window.location.pathname); + if (currentPath === targetPath) return; + const url = new URL(window.location.href); + url.pathname = targetPath; + if (replace) { + window.history.replaceState({}, "", url.toString()); + } else { + window.history.pushState({}, "", url.toString()); + } +} + +export async function loadOverview(host: SettingsHost) { + await Promise.all([ + loadChannels(host as unknown as ClawdbotApp, false), + loadPresence(host as unknown as ClawdbotApp), + loadSessions(host as unknown as ClawdbotApp), + loadCronStatus(host as unknown as ClawdbotApp), + loadDebug(host as unknown as ClawdbotApp), + ]); +} + +export async function loadConnections(host: SettingsHost) { + await Promise.all([ + loadChannels(host as unknown as ClawdbotApp, true), + loadConfig(host as unknown as ClawdbotApp), + ]); +} + +export async function loadCron(host: SettingsHost) { + await Promise.all([ + loadCronStatus(host as unknown as ClawdbotApp), + loadCronJobs(host as unknown as ClawdbotApp), + ]); +} diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts new file mode 100644 index 000000000..42e5961a4 --- /dev/null +++ b/ui/src/ui/app-tool-stream.ts @@ -0,0 +1,202 @@ +import { truncateText } from "./format"; + +const TOOL_STREAM_LIMIT = 50; +const TOOL_STREAM_THROTTLE_MS = 80; +const TOOL_OUTPUT_CHAR_LIMIT = 120_000; + +export type AgentEventPayload = { + runId: string; + seq: number; + stream: string; + ts: number; + sessionKey?: string; + data: Record; +}; + +export type ToolStreamEntry = { + toolCallId: string; + runId: string; + sessionKey?: string; + name: string; + args?: unknown; + output?: string; + startedAt: number; + updatedAt: number; + message: Record; +}; + +type ToolStreamHost = { + sessionKey: string; + chatRunId: string | null; + toolStreamById: Map; + toolStreamOrder: string[]; + chatToolMessages: Record[]; + toolOutputExpanded: Set; + toolStreamSyncTimer: number | null; +}; + +function extractToolOutputText(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if (typeof record.text === "string") return record.text; + const content = record.content; + if (!Array.isArray(content)) return null; + const parts = content + .map((item) => { + if (!item || typeof item !== "object") return null; + const entry = item as Record; + if (entry.type === "text" && typeof entry.text === "string") return entry.text; + return null; + }) + .filter((part): part is string => Boolean(part)); + if (parts.length === 0) return null; + return parts.join("\n"); +} + +function formatToolOutput(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + const contentText = extractToolOutputText(value); + let text: string; + if (typeof value === "string") { + text = value; + } else if (contentText) { + text = contentText; + } else { + try { + text = JSON.stringify(value, null, 2); + } catch { + text = String(value); + } + } + const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT); + if (!truncated.truncated) return truncated.text; + return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`; +} + +function buildToolStreamMessage(entry: ToolStreamEntry): Record { + const content: Array> = []; + content.push({ + type: "toolcall", + name: entry.name, + arguments: entry.args ?? {}, + }); + if (entry.output) { + content.push({ + type: "toolresult", + name: entry.name, + text: entry.output, + }); + } + return { + role: "assistant", + toolCallId: entry.toolCallId, + runId: entry.runId, + content, + timestamp: entry.startedAt, + }; +} + +function trimToolStream(host: ToolStreamHost) { + if (host.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return; + const overflow = host.toolStreamOrder.length - TOOL_STREAM_LIMIT; + const removed = host.toolStreamOrder.splice(0, overflow); + for (const id of removed) host.toolStreamById.delete(id); +} + +function syncToolStreamMessages(host: ToolStreamHost) { + host.chatToolMessages = host.toolStreamOrder + .map((id) => host.toolStreamById.get(id)?.message) + .filter((msg): msg is Record => Boolean(msg)); +} + +export function flushToolStreamSync(host: ToolStreamHost) { + if (host.toolStreamSyncTimer != null) { + clearTimeout(host.toolStreamSyncTimer); + host.toolStreamSyncTimer = null; + } + syncToolStreamMessages(host); +} + +export function scheduleToolStreamSync(host: ToolStreamHost, force = false) { + if (force) { + flushToolStreamSync(host); + return; + } + if (host.toolStreamSyncTimer != null) return; + host.toolStreamSyncTimer = window.setTimeout( + () => flushToolStreamSync(host), + TOOL_STREAM_THROTTLE_MS, + ); +} + +export function resetToolStream(host: ToolStreamHost) { + host.toolStreamById.clear(); + host.toolStreamOrder = []; + host.chatToolMessages = []; + host.toolOutputExpanded = new Set(); + flushToolStreamSync(host); +} + +export function toggleToolOutput(host: ToolStreamHost, id: string, expanded: boolean) { + const next = new Set(host.toolOutputExpanded); + if (expanded) { + next.add(id); + } else { + next.delete(id); + } + host.toolOutputExpanded = next; +} + +export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) { + if (!payload || payload.stream !== "tool") return; + const sessionKey = + typeof payload.sessionKey === "string" ? payload.sessionKey : undefined; + if (sessionKey && sessionKey !== host.sessionKey) return; + // Fallback: only accept session-less events for the active run. + if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) return; + if (host.chatRunId && payload.runId !== host.chatRunId) return; + if (!host.chatRunId) return; + + const data = payload.data ?? {}; + const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : ""; + if (!toolCallId) return; + const name = typeof data.name === "string" ? data.name : "tool"; + const phase = typeof data.phase === "string" ? data.phase : ""; + const args = phase === "start" ? data.args : undefined; + const output = + phase === "update" + ? formatToolOutput(data.partialResult) + : phase === "result" + ? formatToolOutput(data.result) + : undefined; + + const now = Date.now(); + let entry = host.toolStreamById.get(toolCallId); + if (!entry) { + entry = { + toolCallId, + runId: payload.runId, + sessionKey, + name, + args, + output, + startedAt: typeof payload.ts === "number" ? payload.ts : now, + updatedAt: now, + message: {}, + }; + host.toolStreamById.set(toolCallId, entry); + host.toolStreamOrder.push(toolCallId); + } else { + entry.name = name; + if (args !== undefined) entry.args = args; + if (output !== undefined) entry.output = output; + entry.updatedAt = now; + } + + entry.message = buildToolStreamMessage(entry); + trimToolStream(host); + scheduleToolStreamSync(host, phase === "result"); +} diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 42d879c82..17e39a3a3 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -1,28 +1,11 @@ import { LitElement, html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { GatewayBrowserClient, type GatewayEventFrame, type GatewayHelloOk } from "./gateway"; -import { loadSettings, saveSettings, type UiSettings } from "./storage"; +import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; +import { loadSettings, type UiSettings } from "./storage"; import { renderApp } from "./app-render"; -import { - inferBasePathFromPathname, - normalizeBasePath, - normalizePath, - pathForTab, - tabFromPath, - type Tab, -} from "./navigation"; -import { - resolveTheme, - type ResolvedTheme, - type ThemeMode, -} from "./theme"; -import { truncateText } from "./format"; -import { generateUUID } from "./uuid"; -import { - startThemeTransition, - type ThemeTransitionContext, -} from "./theme-transition"; +import type { Tab } from "./navigation"; +import type { ResolvedTheme, ThemeMode } from "./theme"; import type { ConfigSnapshot, ConfigUiHints, @@ -49,117 +32,49 @@ import { type SignalForm, type TelegramForm, } from "./ui-types"; -import { - loadChatHistory, - sendChatMessage, - abortChatRun, - handleChatEvent, - type ChatEventPayload, -} from "./controllers/chat"; -import { loadNodes } from "./controllers/nodes"; -import { - loadConfig, - loadConfigSchema, - updateConfigFormValue, -} from "./controllers/config"; -import { - loadChannels, - logoutWhatsApp, - saveDiscordConfig, - saveIMessageConfig, - saveSlackConfig, - saveSignalConfig, - saveTelegramConfig, - startWhatsAppLogin, - waitWhatsAppLogin, -} from "./controllers/connections"; -import { loadPresence } from "./controllers/presence"; -import { loadSessions } from "./controllers/sessions"; -import { - loadCronJobs, - loadCronStatus, -} from "./controllers/cron"; -import { - loadSkills, - type SkillMessage, -} from "./controllers/skills"; -import { loadDebug } from "./controllers/debug"; -import { loadLogs } from "./controllers/logs"; import type { EventLogEntry } from "./app-events"; - -const TOOL_STREAM_LIMIT = 50; -const TOOL_STREAM_THROTTLE_MS = 80; -const TOOL_OUTPUT_CHAR_LIMIT = 120_000; -const DEFAULT_LOG_LEVEL_FILTERS: Record = { - trace: true, - debug: true, - info: true, - warn: true, - error: true, - fatal: true, -}; - -type AgentEventPayload = { - runId: string; - seq: number; - stream: string; - ts: number; - sessionKey?: string; - data: Record; -}; - -type ToolStreamEntry = { - toolCallId: string; - runId: string; - sessionKey?: string; - name: string; - args?: unknown; - output?: string; - startedAt: number; - updatedAt: number; - message: Record; -}; - -function extractToolOutputText(value: unknown): string | null { - if (!value || typeof value !== "object") return null; - const record = value as Record; - if (typeof record.text === "string") return record.text; - const content = record.content; - if (!Array.isArray(content)) return null; - const parts = content - .map((item) => { - if (!item || typeof item !== "object") return null; - const entry = item as Record; - if (entry.type === "text" && typeof entry.text === "string") return entry.text; - return null; - }) - .filter((part): part is string => Boolean(part)); - if (parts.length === 0) return null; - return parts.join("\n"); -} - -function formatToolOutput(value: unknown): string | null { - if (value === null || value === undefined) return null; - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - const contentText = extractToolOutputText(value); - let text: string; - if (typeof value === "string") { - text = value; - } else if (contentText) { - text = contentText; - } else { - try { - text = JSON.stringify(value, null, 2); - } catch { - text = String(value); - } - } - const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT); - if (!truncated.truncated) return truncated.text; - return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`; -} +import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; +import { + resetToolStream as resetToolStreamInternal, + toggleToolOutput as toggleToolOutputInternal, + type ToolStreamEntry, +} from "./app-tool-stream"; +import { + exportLogs as exportLogsInternal, + handleChatScroll as handleChatScrollInternal, + handleLogsScroll as handleLogsScrollInternal, + resetChatScroll as resetChatScrollInternal, +} from "./app-scroll"; +import { connectGateway as connectGatewayInternal } from "./app-gateway"; +import { + handleConnected, + handleDisconnected, + handleFirstUpdated, + handleUpdated, +} from "./app-lifecycle"; +import { + applySettings as applySettingsInternal, + loadCron as loadCronInternal, + loadOverview as loadOverviewInternal, + setTab as setTabInternal, + setTheme as setThemeInternal, + onPopState as onPopStateInternal, +} from "./app-settings"; +import { + handleAbortChat as handleAbortChatInternal, + handleSendChat as handleSendChatInternal, + removeQueuedMessage as removeQueuedMessageInternal, +} from "./app-chat"; +import { + handleDiscordSave as handleDiscordSaveInternal, + handleIMessageSave as handleIMessageSaveInternal, + handleSignalSave as handleSignalSaveInternal, + handleSlackSave as handleSlackSaveInternal, + handleTelegramSave as handleTelegramSaveInternal, + handleWhatsAppLogout as handleWhatsAppLogoutInternal, + handleWhatsAppStart as handleWhatsAppStartInternal, + handleWhatsAppWait as handleWhatsAppWaitInternal, +} from "./app-connections"; declare global { interface Window { @@ -167,28 +82,6 @@ declare global { } } -const DEFAULT_CRON_FORM: CronFormState = { - name: "", - description: "", - agentId: "", - enabled: true, - scheduleKind: "every", - scheduleAt: "", - everyAmount: "30", - everyUnit: "minutes", - cronExpr: "0 7 * * *", - cronTz: "", - sessionTarget: "main", - wakeMode: "next-heartbeat", - payloadKind: "systemEvent", - payloadText: "", - deliver: false, - channel: "last", - to: "", - timeoutSeconds: "", - postToMainPrefix: "", -}; - @customElement("clawdbot-app") export class ClawdbotApp extends LitElement { @state() settings: UiSettings = loadSettings(); @@ -403,7 +296,10 @@ export class ClawdbotApp extends LitElement { private toolStreamById = new Map(); private toolStreamOrder: string[] = []; basePath = ""; - private popStateHandler = () => this.onPopState(); + private popStateHandler = () => + onPopStateInternal( + this as unknown as Parameters[0], + ); private themeMedia: MediaQueryList | null = null; private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null; private topbarObserver: ResizeObserver | null = null; @@ -414,775 +310,153 @@ export class ClawdbotApp extends LitElement { connectedCallback() { super.connectedCallback(); - this.basePath = this.inferBasePath(); - this.syncTabWithLocation(true); - this.syncThemeWithSettings(); - this.attachThemeListener(); - window.addEventListener("popstate", this.popStateHandler); - this.applySettingsFromUrl(); - this.connect(); - this.startNodesPolling(); - if (this.tab === "logs") this.startLogsPolling(); + handleConnected(this as unknown as Parameters[0]); } protected firstUpdated() { - this.observeTopbar(); + handleFirstUpdated(this as unknown as Parameters[0]); } disconnectedCallback() { - window.removeEventListener("popstate", this.popStateHandler); - this.stopNodesPolling(); - this.stopLogsPolling(); - this.detachThemeListener(); - this.topbarObserver?.disconnect(); - this.topbarObserver = null; + handleDisconnected(this as unknown as Parameters[0]); super.disconnectedCallback(); } protected updated(changed: Map) { - if ( - this.tab === "chat" && - (changed.has("chatMessages") || - changed.has("chatToolMessages") || - changed.has("chatStream") || - changed.has("chatLoading") || - changed.has("tab")) - ) { - const forcedByTab = changed.has("tab"); - const forcedByLoad = - changed.has("chatLoading") && - changed.get("chatLoading") === true && - this.chatLoading === false; - this.scheduleChatScroll(forcedByTab || forcedByLoad || !this.chatHasAutoScrolled); - } - if ( - this.tab === "logs" && - (changed.has("logsEntries") || changed.has("logsAutoFollow") || changed.has("tab")) - ) { - if (this.logsAutoFollow && this.logsAtBottom) { - this.scheduleLogsScroll(changed.has("tab") || changed.has("logsAutoFollow")); - } - } + handleUpdated( + this as unknown as Parameters[0], + changed, + ); } connect() { - this.lastError = null; - this.hello = null; - this.connected = false; - - this.client?.stop(); - this.client = new GatewayBrowserClient({ - url: this.settings.gatewayUrl, - token: this.settings.token.trim() ? this.settings.token : undefined, - password: this.password.trim() ? this.password : undefined, - clientName: "clawdbot-control-ui", - mode: "webchat", - onHello: (hello) => { - this.connected = true; - this.hello = hello; - this.applySnapshot(hello); - void loadNodes(this, { quiet: true }); - void this.refreshActiveTab(); - }, - onClose: ({ code, reason }) => { - this.connected = false; - this.lastError = `disconnected (${code}): ${reason || "no reason"}`; - }, - onEvent: (evt) => this.onEvent(evt), - onGap: ({ expected, received }) => { - this.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`; - }, - }); - this.client.start(); - } - - private scheduleChatScroll(force = false) { - if (this.chatScrollFrame) cancelAnimationFrame(this.chatScrollFrame); - if (this.chatScrollTimeout != null) { - clearTimeout(this.chatScrollTimeout); - this.chatScrollTimeout = null; - } - const pickScrollTarget = () => { - const container = this.querySelector(".chat-thread") as HTMLElement | null; - if (container) { - const overflowY = getComputedStyle(container).overflowY; - const canScroll = - overflowY === "auto" || - overflowY === "scroll" || - container.scrollHeight - container.clientHeight > 1; - if (canScroll) return container; - } - return (document.scrollingElement ?? document.documentElement) as HTMLElement | null; - }; - // Wait for Lit render to complete, then scroll - void this.updateComplete.then(() => { - this.chatScrollFrame = requestAnimationFrame(() => { - this.chatScrollFrame = null; - const target = pickScrollTarget(); - if (!target) return; - const distanceFromBottom = - target.scrollHeight - target.scrollTop - target.clientHeight; - const shouldStick = - force || this.chatUserNearBottom || distanceFromBottom < 200; - if (!shouldStick) return; - if (force) this.chatHasAutoScrolled = true; - target.scrollTop = target.scrollHeight; - this.chatUserNearBottom = true; - const retryDelay = force ? 150 : 120; - this.chatScrollTimeout = window.setTimeout(() => { - this.chatScrollTimeout = null; - const latest = pickScrollTarget(); - if (!latest) return; - const latestDistanceFromBottom = - latest.scrollHeight - latest.scrollTop - latest.clientHeight; - const shouldStickRetry = - force || this.chatUserNearBottom || latestDistanceFromBottom < 200; - if (!shouldStickRetry) return; - latest.scrollTop = latest.scrollHeight; - this.chatUserNearBottom = true; - }, retryDelay); - }); - }); - } - - private observeTopbar() { - if (typeof ResizeObserver === "undefined") return; - const topbar = this.querySelector(".topbar"); - if (!topbar) return; - const update = () => { - const { height } = topbar.getBoundingClientRect(); - this.style.setProperty("--topbar-height", `${height}px`); - }; - update(); - this.topbarObserver = new ResizeObserver(() => update()); - this.topbarObserver.observe(topbar); - } - - private startNodesPolling() { - if (this.nodesPollInterval != null) return; - this.nodesPollInterval = window.setInterval( - () => void loadNodes(this, { quiet: true }), - 5000, + connectGatewayInternal( + this as unknown as Parameters[0], ); } - private stopNodesPolling() { - if (this.nodesPollInterval == null) return; - clearInterval(this.nodesPollInterval); - this.nodesPollInterval = null; - } - - private startLogsPolling() { - if (this.logsPollInterval != null) return; - this.logsPollInterval = window.setInterval(() => { - if (this.tab !== "logs") return; - void loadLogs(this, { quiet: true }); - }, 2000); - } - - private stopLogsPolling() { - if (this.logsPollInterval == null) return; - clearInterval(this.logsPollInterval); - this.logsPollInterval = null; - } - - private scheduleLogsScroll(force = false) { - if (this.logsScrollFrame) cancelAnimationFrame(this.logsScrollFrame); - void this.updateComplete.then(() => { - this.logsScrollFrame = requestAnimationFrame(() => { - this.logsScrollFrame = null; - const container = this.querySelector(".log-stream") as HTMLElement | null; - if (!container) return; - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - const shouldStick = force || distanceFromBottom < 80; - if (!shouldStick) return; - container.scrollTop = container.scrollHeight; - }); - }); - } - handleChatScroll(event: Event) { - const container = event.currentTarget as HTMLElement | null; - if (!container) return; - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - this.chatUserNearBottom = distanceFromBottom < 200; - } - - handleLogsScroll(event: Event) { - const container = event.currentTarget as HTMLElement | null; - if (!container) return; - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - this.logsAtBottom = distanceFromBottom < 80; - } - - exportLogs(lines: string[], label: string) { - if (lines.length === 0) return; - const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); - anchor.href = url; - anchor.download = `clawdbot-logs-${label}-${stamp}.log`; - anchor.click(); - URL.revokeObjectURL(url); - } - - resetToolStream() { - this.toolStreamById.clear(); - this.toolStreamOrder = []; - this.chatToolMessages = []; - this.toolOutputExpanded = new Set(); - this.flushToolStreamSync(); - } - - resetChatScroll() { - this.chatHasAutoScrolled = false; - this.chatUserNearBottom = true; - } - - toggleToolOutput(id: string, expanded: boolean) { - const next = new Set(this.toolOutputExpanded); - if (expanded) { - next.add(id); - } else { - next.delete(id); - } - this.toolOutputExpanded = next; - } - - private trimToolStream() { - if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return; - const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT; - const removed = this.toolStreamOrder.splice(0, overflow); - for (const id of removed) this.toolStreamById.delete(id); - } - - private syncToolStreamMessages() { - this.chatToolMessages = this.toolStreamOrder - .map((id) => this.toolStreamById.get(id)?.message) - .filter((msg): msg is Record => Boolean(msg)); - } - - private scheduleToolStreamSync(force = false) { - if (force) { - this.flushToolStreamSync(); - return; - } - if (this.toolStreamSyncTimer != null) return; - this.toolStreamSyncTimer = window.setTimeout( - () => this.flushToolStreamSync(), - TOOL_STREAM_THROTTLE_MS, + handleChatScrollInternal( + this as unknown as Parameters[0], + event, ); } - private flushToolStreamSync() { - if (this.toolStreamSyncTimer != null) { - clearTimeout(this.toolStreamSyncTimer); - this.toolStreamSyncTimer = null; - } - this.syncToolStreamMessages(); + handleLogsScroll(event: Event) { + handleLogsScrollInternal( + this as unknown as Parameters[0], + event, + ); } - private buildToolStreamMessage(entry: ToolStreamEntry): Record { - const content: Array> = []; - content.push({ - type: "toolcall", - name: entry.name, - arguments: entry.args ?? {}, - }); - if (entry.output) { - content.push({ - type: "toolresult", - name: entry.name, - text: entry.output, - }); - } - return { - role: "assistant", - toolCallId: entry.toolCallId, - runId: entry.runId, - content, - timestamp: entry.startedAt, - }; + exportLogs(lines: string[], label: string) { + exportLogsInternal(lines, label); } - private handleAgentEvent(payload?: AgentEventPayload) { - if (!payload || payload.stream !== "tool") return; - const sessionKey = - typeof payload.sessionKey === "string" ? payload.sessionKey : undefined; - if (sessionKey && sessionKey !== this.sessionKey) return; - // Fallback: only accept session-less events for the active run. - if (!sessionKey && this.chatRunId && payload.runId !== this.chatRunId) return; - if (this.chatRunId && payload.runId !== this.chatRunId) return; - if (!this.chatRunId) return; - - const data = payload.data ?? {}; - const toolCallId = - typeof data.toolCallId === "string" ? data.toolCallId : ""; - if (!toolCallId) return; - const name = typeof data.name === "string" ? data.name : "tool"; - const phase = typeof data.phase === "string" ? data.phase : ""; - const args = phase === "start" ? data.args : undefined; - const output = - phase === "update" - ? formatToolOutput(data.partialResult) - : phase === "result" - ? formatToolOutput(data.result) - : undefined; - - const now = Date.now(); - let entry = this.toolStreamById.get(toolCallId); - if (!entry) { - entry = { - toolCallId, - runId: payload.runId, - sessionKey, - name, - args, - output, - startedAt: typeof payload.ts === "number" ? payload.ts : now, - updatedAt: now, - message: {}, - }; - this.toolStreamById.set(toolCallId, entry); - this.toolStreamOrder.push(toolCallId); - } else { - entry.name = name; - if (args !== undefined) entry.args = args; - if (output !== undefined) entry.output = output; - entry.updatedAt = now; - } - - entry.message = this.buildToolStreamMessage(entry); - this.trimToolStream(); - this.scheduleToolStreamSync(phase === "result"); + resetToolStream() { + resetToolStreamInternal( + this as unknown as Parameters[0], + ); } - private onEvent(evt: GatewayEventFrame) { - this.eventLogBuffer = [ - { ts: Date.now(), event: evt.event, payload: evt.payload }, - ...this.eventLogBuffer, - ].slice(0, 250); - if (this.tab === "debug") { - this.eventLog = this.eventLogBuffer; - } - - if (evt.event === "agent") { - this.handleAgentEvent(evt.payload as AgentEventPayload | undefined); - return; - } - - if (evt.event === "chat") { - const payload = evt.payload as ChatEventPayload | undefined; - if (payload?.sessionKey) { - this.setLastActiveSessionKey(payload.sessionKey); - } - const state = handleChatEvent(this, payload); - if (state === "final" || state === "error" || state === "aborted") { - this.resetToolStream(); - void this.flushChatQueue(); - } - if (state === "final") void loadChatHistory(this); - return; - } - - if (evt.event === "presence") { - const payload = evt.payload as { presence?: PresenceEntry[] } | undefined; - if (payload?.presence && Array.isArray(payload.presence)) { - this.presenceEntries = payload.presence; - this.presenceError = null; - this.presenceStatus = null; - } - return; - } - - if (evt.event === "cron" && this.tab === "cron") { - void this.loadCron(); - } + resetChatScroll() { + resetChatScrollInternal( + this as unknown as Parameters[0], + ); } - private applySnapshot(hello: GatewayHelloOk) { - const snapshot = hello.snapshot as - | { presence?: PresenceEntry[]; health?: HealthSnapshot } - | undefined; - if (snapshot?.presence && Array.isArray(snapshot.presence)) { - this.presenceEntries = snapshot.presence; - } - if (snapshot?.health) { - this.debugHealth = snapshot.health; - } + toggleToolOutput(id: string, expanded: boolean) { + toggleToolOutputInternal( + this as unknown as Parameters[0], + id, + expanded, + ); } - applySettings(next: UiSettings) { - const normalized = { - ...next, - lastActiveSessionKey: - next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main", - }; - this.settings = normalized; - saveSettings(normalized); - if (next.theme !== this.theme) { - this.theme = next.theme; - this.applyResolvedTheme(resolveTheme(next.theme)); - } - this.applySessionKey = this.settings.lastActiveSessionKey; - } - - private setLastActiveSessionKey(next: string) { - const trimmed = next.trim(); - if (!trimmed) return; - if (this.settings.lastActiveSessionKey === trimmed) return; - this.applySettings({ ...this.settings, lastActiveSessionKey: trimmed }); - } - - private applySettingsFromUrl() { - if (!window.location.search) return; - const params = new URLSearchParams(window.location.search); - const tokenRaw = params.get("token"); - const passwordRaw = params.get("password"); - const sessionRaw = params.get("session"); - let shouldCleanUrl = false; - - if (tokenRaw != null) { - const token = tokenRaw.trim(); - if (token && !this.settings.token) { - this.applySettings({ ...this.settings, token }); - } - params.delete("token"); - shouldCleanUrl = true; - } - - if (passwordRaw != null) { - const password = passwordRaw.trim(); - if (password) { - this.password = password; - } - params.delete("password"); - shouldCleanUrl = true; - } - - if (sessionRaw != null) { - const session = sessionRaw.trim(); - if (session) { - this.sessionKey = session; - } - params.delete("session"); - shouldCleanUrl = true; - } - - if (!shouldCleanUrl) return; - const url = new URL(window.location.href); - url.search = params.toString(); - window.history.replaceState({}, "", url.toString()); + applySettingsInternal( + this as unknown as Parameters[0], + next, + ); } setTab(next: Tab) { - if (this.tab !== next) this.tab = next; - if (next === "chat") this.chatHasAutoScrolled = false; - if (next === "logs") this.startLogsPolling(); - else this.stopLogsPolling(); - void this.refreshActiveTab(); - this.syncUrlWithTab(next, false); + setTabInternal(this as unknown as Parameters[0], next); } - setTheme(next: ThemeMode, context?: ThemeTransitionContext) { - const applyTheme = () => { - this.theme = next; - this.applySettings({ ...this.settings, theme: next }); - this.applyResolvedTheme(resolveTheme(next)); - }; - startThemeTransition({ - nextTheme: next, - applyTheme, + setTheme(next: ThemeMode, context?: Parameters[2]) { + setThemeInternal( + this as unknown as Parameters[0], + next, context, - currentTheme: this.theme, - }); - } - - private async refreshActiveTab() { - if (this.tab === "overview") await this.loadOverview(); - if (this.tab === "connections") await this.loadConnections(); - if (this.tab === "instances") await loadPresence(this); - if (this.tab === "sessions") await loadSessions(this); - if (this.tab === "cron") await this.loadCron(); - if (this.tab === "skills") await loadSkills(this); - if (this.tab === "nodes") await loadNodes(this); - if (this.tab === "chat") { - await Promise.all([loadChatHistory(this), loadSessions(this)]); - this.scheduleChatScroll(!this.chatHasAutoScrolled); - } - if (this.tab === "config") { - await loadConfigSchema(this); - await loadConfig(this); - } - if (this.tab === "debug") { - await loadDebug(this); - this.eventLog = this.eventLogBuffer; - } - if (this.tab === "logs") { - this.logsAtBottom = true; - await loadLogs(this, { reset: true }); - this.scheduleLogsScroll(true); - } - } - - private inferBasePath() { - if (typeof window === "undefined") return ""; - const configured = window.__CLAWDBOT_CONTROL_UI_BASE_PATH__; - if (typeof configured === "string" && configured.trim()) { - return normalizeBasePath(configured); - } - return inferBasePathFromPathname(window.location.pathname); - } - - private syncThemeWithSettings() { - this.theme = this.settings.theme ?? "system"; - this.applyResolvedTheme(resolveTheme(this.theme)); - } - - private applyResolvedTheme(resolved: ResolvedTheme) { - this.themeResolved = resolved; - if (typeof document === "undefined") return; - const root = document.documentElement; - root.dataset.theme = resolved; - root.style.colorScheme = resolved; - } - - private attachThemeListener() { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") - return; - this.themeMedia = window.matchMedia("(prefers-color-scheme: dark)"); - this.themeMediaHandler = (event) => { - if (this.theme !== "system") return; - this.applyResolvedTheme(event.matches ? "dark" : "light"); - }; - if (typeof this.themeMedia.addEventListener === "function") { - this.themeMedia.addEventListener("change", this.themeMediaHandler); - return; - } - const legacy = this.themeMedia as MediaQueryList & { - addListener: (cb: (event: MediaQueryListEvent) => void) => void; - }; - legacy.addListener(this.themeMediaHandler); - } - - private detachThemeListener() { - if (!this.themeMedia || !this.themeMediaHandler) return; - if (typeof this.themeMedia.removeEventListener === "function") { - this.themeMedia.removeEventListener("change", this.themeMediaHandler); - return; - } - const legacy = this.themeMedia as MediaQueryList & { - removeListener: (cb: (event: MediaQueryListEvent) => void) => void; - }; - legacy.removeListener(this.themeMediaHandler); - this.themeMedia = null; - this.themeMediaHandler = null; - } - - private syncTabWithLocation(replace: boolean) { - if (typeof window === "undefined") return; - const resolved = tabFromPath(window.location.pathname, this.basePath) ?? "chat"; - this.setTabFromRoute(resolved); - this.syncUrlWithTab(resolved, replace); - } - - private onPopState() { - if (typeof window === "undefined") return; - const resolved = tabFromPath(window.location.pathname, this.basePath); - if (!resolved) return; - this.setTabFromRoute(resolved); - } - - private setTabFromRoute(next: Tab) { - if (this.tab !== next) this.tab = next; - if (next === "chat") this.chatHasAutoScrolled = false; - if (next === "logs") this.startLogsPolling(); - else this.stopLogsPolling(); - if (this.connected) void this.refreshActiveTab(); - } - - private syncUrlWithTab(tab: Tab, replace: boolean) { - if (typeof window === "undefined") return; - const targetPath = normalizePath(pathForTab(tab, this.basePath)); - const currentPath = normalizePath(window.location.pathname); - if (currentPath === targetPath) return; - const url = new URL(window.location.href); - url.pathname = targetPath; - if (replace) { - window.history.replaceState({}, "", url.toString()); - } else { - window.history.pushState({}, "", url.toString()); - } + ); } async loadOverview() { - await Promise.all([ - loadChannels(this, false), - loadPresence(this), - loadSessions(this), - loadCronStatus(this), - loadDebug(this), - ]); - } - - private async loadConnections() { - await Promise.all([loadChannels(this, true), loadConfig(this)]); + await loadOverviewInternal( + this as unknown as Parameters[0], + ); } async loadCron() { - await Promise.all([loadCronStatus(this), loadCronJobs(this)]); - } - - private isChatBusy() { - return this.chatSending || Boolean(this.chatRunId); - } - - private isChatStopCommand(text: string) { - const trimmed = text.trim(); - if (!trimmed) return false; - const normalized = trimmed.toLowerCase(); - if (normalized === "/stop") return true; - return ( - normalized === "stop" || - normalized === "esc" || - normalized === "abort" || - normalized === "wait" || - normalized === "exit" + await loadCronInternal( + this as unknown as Parameters[0], ); } async handleAbortChat() { - if (!this.connected) return; - this.chatMessage = ""; - await abortChatRun(this); - } - - private enqueueChatMessage(text: string) { - const trimmed = text.trim(); - if (!trimmed) return; - this.chatQueue = [ - ...this.chatQueue, - { - id: generateUUID(), - text: trimmed, - createdAt: Date.now(), - }, - ]; - } - - private async sendChatMessageNow( - message: string, - opts?: { previousDraft?: string; restoreDraft?: boolean }, - ) { - this.resetToolStream(); - const ok = await sendChatMessage(this, message); - if (!ok && opts?.previousDraft != null) { - this.chatMessage = opts.previousDraft; - } - if (ok) { - this.setLastActiveSessionKey(this.sessionKey); - } - if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) { - this.chatMessage = opts.previousDraft; - } - this.scheduleChatScroll(); - if (ok && !this.chatRunId) { - void this.flushChatQueue(); - } - return ok; - } - - private async flushChatQueue() { - if (!this.connected || this.isChatBusy()) return; - const [next, ...rest] = this.chatQueue; - if (!next) return; - this.chatQueue = rest; - const ok = await this.sendChatMessageNow(next.text); - if (!ok) { - this.chatQueue = [next, ...this.chatQueue]; - } + await handleAbortChatInternal( + this as unknown as Parameters[0], + ); } removeQueuedMessage(id: string) { - this.chatQueue = this.chatQueue.filter((item) => item.id !== id); + removeQueuedMessageInternal( + this as unknown as Parameters[0], + id, + ); } async handleSendChat( messageOverride?: string, - opts?: { restoreDraft?: boolean }, + opts?: Parameters[2], ) { - if (!this.connected) return; - const previousDraft = this.chatMessage; - const message = (messageOverride ?? this.chatMessage).trim(); - if (!message) return; - - if (this.isChatStopCommand(message)) { - await this.handleAbortChat(); - return; - } - - if (messageOverride == null) { - this.chatMessage = ""; - } - - if (this.isChatBusy()) { - this.enqueueChatMessage(message); - return; - } - - await this.sendChatMessageNow(message, { - previousDraft: messageOverride == null ? previousDraft : undefined, - restoreDraft: Boolean(messageOverride && opts?.restoreDraft), - }); + await handleSendChatInternal( + this as unknown as Parameters[0], + messageOverride, + opts, + ); } async handleWhatsAppStart(force: boolean) { - await startWhatsAppLogin(this, force); - await loadChannels(this, true); + await handleWhatsAppStartInternal(this, force); } async handleWhatsAppWait() { - await waitWhatsAppLogin(this); - await loadChannels(this, true); + await handleWhatsAppWaitInternal(this); } async handleWhatsAppLogout() { - await logoutWhatsApp(this); - await loadChannels(this, true); + await handleWhatsAppLogoutInternal(this); } async handleTelegramSave() { - await saveTelegramConfig(this); - await loadConfig(this); - await loadChannels(this, true); + await handleTelegramSaveInternal(this); } async handleDiscordSave() { - await saveDiscordConfig(this); - await loadConfig(this); - await loadChannels(this, true); + await handleDiscordSaveInternal(this); } async handleSlackSave() { - await saveSlackConfig(this); - await loadConfig(this); - await loadChannels(this, true); + await handleSlackSaveInternal(this); } async handleSignalSave() { - await saveSignalConfig(this); - await loadConfig(this); - await loadChannels(this, true); + await handleSignalSaveInternal(this); } async handleIMessageSave() { - await saveIMessageConfig(this); - await loadConfig(this); - await loadChannels(this, true); + await handleIMessageSaveInternal(this); } // Sidebar handlers for tool output viewing