From dbe2a97e802a4ba86ee1cedc662f2982d14f00c6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 10 Apr 2026 11:03:08 +0100 Subject: [PATCH] fix(cycles): remove qa-lab and ui runtime seams --- extensions/qa-lab/src/character-eval.ts | 5 +- extensions/qa-lab/src/cli.runtime.test.ts | 18 ++-- extensions/qa-lab/src/cli.runtime.ts | 4 +- extensions/qa-lab/src/lab-server.ts | 4 +- extensions/qa-lab/src/suite-launch.runtime.ts | 19 ++-- extensions/qa-lab/src/suite.ts | 37 ++++---- ui/src/ui/app-channels.ts | 67 ++++++++------ ui/src/ui/app-chat.ts | 22 +++-- ui/src/ui/app-gateway.ts | 48 +++++----- ui/src/ui/app-last-active-session.ts | 14 +++ ui/src/ui/app-polling.ts | 10 ++- ui/src/ui/app-render.helpers.ts | 24 +++-- ui/src/ui/app-settings.ts | 90 ++++++++++++------- 13 files changed, 225 insertions(+), 137 deletions(-) create mode 100644 ui/src/ui/app-last-active-session.ts diff --git a/extensions/qa-lab/src/character-eval.ts b/extensions/qa-lab/src/character-eval.ts index a6d45659912..2ab6b221aaa 100644 --- a/extensions/qa-lab/src/character-eval.ts +++ b/extensions/qa-lab/src/character-eval.ts @@ -4,7 +4,8 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { runQaManualLane } from "./manual-lane.runtime.js"; import { isQaFastModeModelRef, type QaProviderMode } from "./model-selection.js"; import { type QaThinkingLevel } from "./qa-gateway-config.js"; -import { runQaSuite, type QaSuiteResult } from "./suite.js"; +import { runQaSuiteFromRuntime } from "./suite-launch.runtime.js"; +import type { QaSuiteResult } from "./suite.js"; const DEFAULT_CHARACTER_SCENARIO_ID = "character-vibes-gollum"; const DEFAULT_CHARACTER_EVAL_MODELS = Object.freeze([ @@ -518,7 +519,7 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) { const runsDir = path.join(outputDir, "runs"); await fs.mkdir(runsDir, { recursive: true }); - const runSuite = params.runSuite ?? runQaSuite; + const runSuite = params.runSuite ?? runQaSuiteFromRuntime; const candidateConcurrency = normalizeConcurrency( params.candidateConcurrency, DEFAULT_CHARACTER_EVAL_CONCURRENCY, diff --git a/extensions/qa-lab/src/cli.runtime.test.ts b/extensions/qa-lab/src/cli.runtime.test.ts index c1c199e11f6..00352cb63fc 100644 --- a/extensions/qa-lab/src/cli.runtime.test.ts +++ b/extensions/qa-lab/src/cli.runtime.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { runQaManualLane, - runQaSuite, + runQaSuiteFromRuntime, runQaCharacterEval, runQaMultipass, startQaLabServer, @@ -12,7 +12,7 @@ const { runQaDockerUp, } = vi.hoisted(() => ({ runQaManualLane: vi.fn(), - runQaSuite: vi.fn(), + runQaSuiteFromRuntime: vi.fn(), runQaCharacterEval: vi.fn(), runQaMultipass: vi.fn(), startQaLabServer: vi.fn(), @@ -25,8 +25,8 @@ vi.mock("./manual-lane.runtime.js", () => ({ runQaManualLane, })); -vi.mock("./suite.js", () => ({ - runQaSuite, +vi.mock("./suite-launch.runtime.js", () => ({ + runQaSuiteFromRuntime, })); vi.mock("./character-eval.js", () => ({ @@ -65,7 +65,7 @@ describe("qa cli runtime", () => { beforeEach(() => { stdoutWrite = vi.spyOn(process.stdout, "write").mockReturnValue(true); - runQaSuite.mockReset(); + runQaSuiteFromRuntime.mockReset(); runQaCharacterEval.mockReset(); runQaManualLane.mockReset(); runQaMultipass.mockReset(); @@ -73,7 +73,7 @@ describe("qa cli runtime", () => { writeQaDockerHarnessFiles.mockReset(); buildQaDockerHarnessImage.mockReset(); runQaDockerUp.mockReset(); - runQaSuite.mockResolvedValue({ + runQaSuiteFromRuntime.mockResolvedValue({ watchUrl: "http://127.0.0.1:43124", reportPath: "/tmp/report.md", summaryPath: "/tmp/summary.json", @@ -135,7 +135,7 @@ describe("qa cli runtime", () => { scenarioIds: ["approval-turn-tool-followthrough"], }); - expect(runQaSuite).toHaveBeenCalledWith({ + expect(runQaSuiteFromRuntime).toHaveBeenCalledWith({ repoRoot: path.resolve("/tmp/openclaw-repo"), outputDir: path.resolve("/tmp/openclaw-repo", ".artifacts/qa/frontier"), providerMode: "live-frontier", @@ -153,7 +153,7 @@ describe("qa cli runtime", () => { scenarioIds: ["approval-turn-tool-followthrough"], }); - expect(runQaSuite).toHaveBeenCalledWith( + expect(runQaSuiteFromRuntime).toHaveBeenCalledWith( expect.objectContaining({ repoRoot: path.resolve("/tmp/openclaw-repo"), providerMode: "live-frontier", @@ -310,7 +310,7 @@ describe("qa cli runtime", () => { memory: "4G", disk: "24G", }); - expect(runQaSuite).not.toHaveBeenCalled(); + expect(runQaSuiteFromRuntime).not.toHaveBeenCalled(); }); it("passes live suite selection through to the multipass runner", async () => { diff --git a/extensions/qa-lab/src/cli.runtime.ts b/extensions/qa-lab/src/cli.runtime.ts index a6b628d7518..a395d3e7851 100644 --- a/extensions/qa-lab/src/cli.runtime.ts +++ b/extensions/qa-lab/src/cli.runtime.ts @@ -13,7 +13,7 @@ import { type QaProviderMode, type QaProviderModeInput, } from "./run-config.js"; -import { runQaSuite } from "./suite.js"; +import { runQaSuiteFromRuntime } from "./suite-launch.runtime.js"; type InterruptibleServer = { baseUrl: string; @@ -241,7 +241,7 @@ export async function runQaSuiteCommand(opts: { process.stdout.write(`QA Multipass bootstrap log: ${result.bootstrapLogPath}\n`); return; } - const result = await runQaSuite({ + const result = await runQaSuiteFromRuntime({ repoRoot, outputDir: opts.outputDir ? path.resolve(repoRoot, opts.outputDir) : undefined, providerMode, diff --git a/extensions/qa-lab/src/lab-server.ts b/extensions/qa-lab/src/lab-server.ts index c9389f01f2f..440814452be 100644 --- a/extensions/qa-lab/src/lab-server.ts +++ b/extensions/qa-lab/src/lab-server.ts @@ -659,8 +659,8 @@ export async function startQaLabServer( }; activeSuiteRun = (async () => { try { - const { runQaSuiteFromRuntime } = await import("./suite-launch.runtime.js"); - const result = await runQaSuiteFromRuntime({ + const { runQaSuite } = await import("./suite.js"); + const result = await runQaSuite({ lab: labHandle ?? undefined, outputDir: createQaRunOutputDir(repoRoot), providerMode: selection.providerMode, diff --git a/extensions/qa-lab/src/suite-launch.runtime.ts b/extensions/qa-lab/src/suite-launch.runtime.ts index 0dd13869a9f..67b083d2efa 100644 --- a/extensions/qa-lab/src/suite-launch.runtime.ts +++ b/extensions/qa-lab/src/suite-launch.runtime.ts @@ -1,6 +1,15 @@ -export async function runQaSuiteFromRuntime( - ...args: Parameters -) { - const { runQaSuite } = await import("./suite.js"); - return await runQaSuite(...args); +import type { QaSuiteRunParams } from "./suite.js"; + +async function loadQaLabServerRuntime() { + const { startQaLabServer } = await import("./lab-server.js"); + return startQaLabServer; +} + +export async function runQaSuiteFromRuntime(...args: [QaSuiteRunParams?]) { + const { runQaSuite } = await import("./suite.js"); + const params = args[0]; + return await runQaSuite({ + ...params, + startLab: params?.startLab ?? (await loadQaLabServerRuntime()), + }); } diff --git a/extensions/qa-lab/src/suite.ts b/extensions/qa-lab/src/suite.ts index 6abc47daf5e..5c0ac671b54 100644 --- a/extensions/qa-lab/src/suite.ts +++ b/extensions/qa-lab/src/suite.ts @@ -68,12 +68,20 @@ type QaSuiteEnvironment = { alternateModel: string; }; -async function startQaLabServerRuntime( - params?: QaLabServerStartParams, -): Promise { - const { startQaLabServer } = await import("./lab-server.js"); - return await startQaLabServer(params); -} +export type QaSuiteStartLabFn = (params?: QaLabServerStartParams) => Promise; + +export type QaSuiteRunParams = { + repoRoot?: string; + outputDir?: string; + providerMode?: QaProviderMode | "live-openai"; + primaryModel?: string; + alternateModel?: string; + fastMode?: boolean; + thinkingDefault?: QaThinkingLevel; + scenarioIds?: string[]; + lab?: QaLabServerHandle; + startLab?: QaSuiteStartLabFn; +}; const _QA_IMAGE_UNDERSTANDING_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAAAklEQVR4AewaftIAAAK4SURBVO3BAQEAMAwCIG//znsQgXfJBZjUALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsBpjVALMaYFYDzGqAWQ0wqwFmNcCsl9wFmNQAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwGmNUAsxpgVgPMaoBZDTCrAWY1wKwP4TIF+7ciPkoAAAAASUVORK5CYII="; @@ -1188,17 +1196,7 @@ async function runScenarioDefinition( }); } -export async function runQaSuite(params?: { - repoRoot?: string; - outputDir?: string; - providerMode?: QaProviderMode | "live-openai"; - primaryModel?: string; - alternateModel?: string; - fastMode?: boolean; - thinkingDefault?: QaThinkingLevel; - scenarioIds?: string[]; - lab?: QaLabServerHandle; -}) { +export async function runQaSuite(params?: QaSuiteRunParams) { const startedAt = new Date(); const repoRoot = path.resolve(params?.repoRoot ?? process.cwd()); const providerMode = normalizeQaProviderMode(params?.providerMode ?? "mock-openai"); @@ -1217,12 +1215,15 @@ export async function runQaSuite(params?: { const ownsLab = !params?.lab; const lab = params?.lab ?? - (await startQaLabServerRuntime({ + (await params?.startLab?.({ repoRoot, host: "127.0.0.1", port: 0, embeddedGateway: "disabled", })); + if (!lab) { + throw new Error("QA suite requires lab or startLab runtime"); + } const mock = providerMode === "mock-openai" ? await startQaMockOpenAiServer({ diff --git a/ui/src/ui/app-channels.ts b/ui/src/ui/app-channels.ts index fa63085667c..f1204b49861 100644 --- a/ui/src/ui/app-channels.ts +++ b/ui/src/ui/app-channels.ts @@ -1,39 +1,50 @@ -import type { OpenClawApp } from "./app.ts"; import { loadChannels, logoutWhatsApp, startWhatsAppLogin, waitWhatsAppLogin, + type ChannelsState, } from "./controllers/channels.ts"; -import { loadConfig, saveConfig } from "./controllers/config.ts"; +import { loadConfig, saveConfig, type ConfigState } from "./controllers/config.ts"; import { normalizeOptionalString } from "./string-coerce.ts"; import type { NostrProfile } from "./types.ts"; import { createNostrProfileFormState } from "./views/channels.nostr-profile-form.ts"; -export async function handleWhatsAppStart(host: OpenClawApp, force: boolean) { - await startWhatsAppLogin(host, force); - await loadChannels(host, true); +type NostrProfileFormState = ReturnType | null; + +type ChannelsActionHost = ChannelsState & + ConfigState & { + hello?: { auth?: { deviceToken?: string | null } | null } | null; + password?: string; + settings: { token?: string }; + nostrProfileFormState: NostrProfileFormState; + nostrProfileAccountId: string | null; + }; + +export async function handleWhatsAppStart(host: ChannelsActionHost, force: boolean) { + await startWhatsAppLogin(host as ChannelsState, force); + await loadChannels(host as ChannelsState, true); } -export async function handleWhatsAppWait(host: OpenClawApp) { - await waitWhatsAppLogin(host); - await loadChannels(host, true); +export async function handleWhatsAppWait(host: ChannelsActionHost) { + await waitWhatsAppLogin(host as ChannelsState); + await loadChannels(host as ChannelsState, true); } -export async function handleWhatsAppLogout(host: OpenClawApp) { - await logoutWhatsApp(host); - await loadChannels(host, true); +export async function handleWhatsAppLogout(host: ChannelsActionHost) { + await logoutWhatsApp(host as ChannelsState); + await loadChannels(host as ChannelsState, true); } -export async function handleChannelConfigSave(host: OpenClawApp) { - await saveConfig(host); - await loadConfig(host); - await loadChannels(host, true); +export async function handleChannelConfigSave(host: ChannelsActionHost) { + await saveConfig(host as ConfigState); + await loadConfig(host as ConfigState); + await loadChannels(host as ChannelsState, true); } -export async function handleChannelConfigReload(host: OpenClawApp) { - await loadConfig(host); - await loadChannels(host, true); +export async function handleChannelConfigReload(host: ChannelsActionHost) { + await loadConfig(host as ConfigState); + await loadChannels(host as ChannelsState, true); } function parseValidationErrors(details: unknown): Record { @@ -58,7 +69,7 @@ function parseValidationErrors(details: unknown): Record { return errors; } -function resolveNostrAccountId(host: OpenClawApp): string { +function resolveNostrAccountId(host: ChannelsActionHost): string { const accounts = host.channelsSnapshot?.channelAccounts?.nostr ?? []; return accounts[0]?.accountId ?? host.nostrProfileAccountId ?? "default"; } @@ -67,7 +78,7 @@ function buildNostrProfileUrl(accountId: string, suffix = ""): string { return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`; } -function resolveGatewayHttpAuthHeader(host: OpenClawApp): string | null { +function resolveGatewayHttpAuthHeader(host: ChannelsActionHost): string | null { const deviceToken = normalizeOptionalString(host.hello?.auth?.deviceToken); if (deviceToken) { return `Bearer ${deviceToken}`; @@ -83,13 +94,13 @@ function resolveGatewayHttpAuthHeader(host: OpenClawApp): string | null { return null; } -function buildGatewayHttpHeaders(host: OpenClawApp): Record { +function buildGatewayHttpHeaders(host: ChannelsActionHost): Record { const authorization = resolveGatewayHttpAuthHeader(host); return authorization ? { Authorization: authorization } : {}; } export function handleNostrProfileEdit( - host: OpenClawApp, + host: ChannelsActionHost, accountId: string, profile: NostrProfile | null, ) { @@ -97,13 +108,13 @@ export function handleNostrProfileEdit( host.nostrProfileFormState = createNostrProfileFormState(profile ?? undefined); } -export function handleNostrProfileCancel(host: OpenClawApp) { +export function handleNostrProfileCancel(host: ChannelsActionHost) { host.nostrProfileFormState = null; host.nostrProfileAccountId = null; } export function handleNostrProfileFieldChange( - host: OpenClawApp, + host: ChannelsActionHost, field: keyof NostrProfile, value: string, ) { @@ -124,7 +135,7 @@ export function handleNostrProfileFieldChange( }; } -export function handleNostrProfileToggleAdvanced(host: OpenClawApp) { +export function handleNostrProfileToggleAdvanced(host: ChannelsActionHost) { const state = host.nostrProfileFormState; if (!state) { return; @@ -135,7 +146,7 @@ export function handleNostrProfileToggleAdvanced(host: OpenClawApp) { }; } -export async function handleNostrProfileSave(host: OpenClawApp) { +export async function handleNostrProfileSave(host: ChannelsActionHost) { const state = host.nostrProfileFormState; if (!state || state.saving) { return; @@ -196,7 +207,7 @@ export async function handleNostrProfileSave(host: OpenClawApp) { fieldErrors: {}, original: { ...state.values }, }; - await loadChannels(host, true); + await loadChannels(host as ChannelsState, true); } catch (err) { host.nostrProfileFormState = { ...state, @@ -207,7 +218,7 @@ export async function handleNostrProfileSave(host: OpenClawApp) { } } -export async function handleNostrProfileImport(host: OpenClawApp) { +export async function handleNostrProfileImport(host: ChannelsActionHost) { const state = host.nostrProfileFormState; if (!state || state.importing) { return; diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 831ccfa440e..cde3114d852 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -1,12 +1,16 @@ +import { setLastActiveSessionKey } from "./app-last-active-session.ts"; import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts"; -import { setLastActiveSessionKey } from "./app-settings.ts"; import { resetToolStream } from "./app-tool-stream.ts"; -import type { OpenClawApp } from "./app.ts"; import { executeSlashCommand } from "./chat/slash-command-executor.ts"; import { parseSlashCommand } from "./chat/slash-commands.ts"; -import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts"; +import { + abortChatRun, + loadChatHistory, + sendChatMessage, + type ChatState, +} from "./controllers/chat.ts"; import { loadModels } from "./controllers/models.ts"; -import { loadSessions } from "./controllers/sessions.ts"; +import { loadSessions, type SessionsState } from "./controllers/sessions.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; import { parseAgentSessionKey } from "./session-key.ts"; @@ -82,7 +86,7 @@ export async function handleAbortChat(host: ChatHost) { return; } host.chatMessage = ""; - await abortChatRun(host as unknown as OpenClawApp); + await abortChatRun(host as unknown as ChatState); } function enqueueChatMessage( @@ -142,7 +146,7 @@ async function sendChatMessageNow( resetToolStream(host as unknown as Parameters[0]); // Reset scroll state before sending to ensure auto-scroll works for the response resetChatScroll(host as unknown as Parameters[0]); - const runId = await sendChatMessage(host as unknown as OpenClawApp, message, opts?.attachments); + const runId = await sendChatMessage(host as unknown as ChatState, message, opts?.attachments); const ok = Boolean(runId); if (!ok && opts?.previousDraft != null) { host.chatMessage = opts.previousDraft; @@ -375,7 +379,7 @@ async function clearChatHistory(host: ChatHost) { host.chatMessages = []; host.chatStream = null; host.chatRunId = null; - await loadChatHistory(host as unknown as OpenClawApp); + await loadChatHistory(host as unknown as ChatState); } catch (err) { host.lastError = String(err); } @@ -395,8 +399,8 @@ function injectCommandResult(host: ChatHost, content: string) { export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) { await Promise.all([ - loadChatHistory(host as unknown as OpenClawApp), - loadSessions(host as unknown as OpenClawApp, { + loadChatHistory(host as unknown as ChatState), + loadSessions(host as unknown as SessionsState, { activeMinutes: 0, limit: 0, includeGlobal: true, diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index bff166eece9..a0964e78630 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -15,14 +15,20 @@ import { setLastActiveSessionKey, } from "./app-settings.ts"; import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts"; -import type { OpenClawApp } from "./app.ts"; import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts"; import { formatConnectError } from "./connect-error.ts"; -import { loadAgents } from "./controllers/agents.ts"; -import { loadAssistantIdentity } from "./controllers/assistant-identity.ts"; -import { loadChatHistory } from "./controllers/chat.ts"; -import { handleChatEvent, type ChatEventPayload } from "./controllers/chat.ts"; -import { loadDevices } from "./controllers/devices.ts"; +import { loadAgents, type AgentsState } from "./controllers/agents.ts"; +import { + loadAssistantIdentity, + type AssistantIdentityState, +} from "./controllers/assistant-identity.ts"; +import { + loadChatHistory, + handleChatEvent, + type ChatEventPayload, + type ChatState, +} from "./controllers/chat.ts"; +import { loadDevices, type DevicesState } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import { addExecApproval, @@ -32,9 +38,9 @@ import { pruneExecApprovalQueue, removeExecApproval, } from "./controllers/exec-approval.ts"; -import { loadHealthState } from "./controllers/health.ts"; -import { loadNodes } from "./controllers/nodes.ts"; -import { loadSessions, subscribeSessions } from "./controllers/sessions.ts"; +import { loadHealthState, type HealthState } from "./controllers/health.ts"; +import { loadNodes, type NodesState } from "./controllers/nodes.ts"; +import { loadSessions, subscribeSessions, type SessionsState } from "./controllers/sessions.ts"; import { resolveGatewayErrorDetailCode, type GatewayEventFrame, @@ -248,12 +254,12 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption host as unknown as Parameters[0], ); } - void subscribeSessions(host as unknown as OpenClawApp); - void loadAssistantIdentity(host as unknown as OpenClawApp); - void loadAgents(host as unknown as OpenClawApp); - void loadHealthState(host as unknown as OpenClawApp); - void loadNodes(host as unknown as OpenClawApp, { quiet: true }); - void loadDevices(host as unknown as OpenClawApp, { quiet: true }); + void subscribeSessions(host as unknown as SessionsState); + void loadAssistantIdentity(host as unknown as AssistantIdentityState); + void loadAgents(host as unknown as AgentsState); + void loadHealthState(host as unknown as HealthState); + void loadNodes(host as unknown as NodesState, { quiet: true }); + void loadDevices(host as unknown as DevicesState, { quiet: true }); void refreshActiveTab(host as unknown as Parameters[0]); }, onClose: ({ code, reason, error }) => { @@ -333,7 +339,7 @@ function handleTerminalChatEvent( if (runId && host.refreshSessionsAfterChat.has(runId)) { host.refreshSessionsAfterChat.delete(runId); if (state === "final") { - void loadSessions(host as unknown as OpenClawApp, { + void loadSessions(host as unknown as SessionsState, { activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, }); } @@ -341,7 +347,7 @@ function handleTerminalChatEvent( // Reload history when tools were used so the persisted tool results // replace the now-cleared streaming state. if (hadToolEvents && state === "final") { - void loadChatHistory(host as unknown as OpenClawApp); + void loadChatHistory(host as unknown as ChatState); return true; } return false; @@ -354,10 +360,10 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u payload.sessionKey, ); } - const state = handleChatEvent(host as unknown as OpenClawApp, payload); + const state = handleChatEvent(host as unknown as ChatState, payload); const historyReloaded = handleTerminalChatEvent(host, payload, state); if (state === "final" && !historyReloaded && shouldReloadHistoryForFinalEvent(payload)) { - void loadChatHistory(host as unknown as OpenClawApp); + void loadChatHistory(host as unknown as ChatState); } } @@ -410,7 +416,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { } if (evt.event === "sessions.changed") { - void loadSessions(host as unknown as OpenClawApp); + void loadSessions(host as unknown as SessionsState); return; } @@ -419,7 +425,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { } if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") { - void loadDevices(host as unknown as OpenClawApp, { quiet: true }); + void loadDevices(host as unknown as DevicesState, { quiet: true }); } if (evt.event === "exec.approval.requested") { diff --git a/ui/src/ui/app-last-active-session.ts b/ui/src/ui/app-last-active-session.ts new file mode 100644 index 00000000000..555601450f2 --- /dev/null +++ b/ui/src/ui/app-last-active-session.ts @@ -0,0 +1,14 @@ +import type { UiSettings } from "./storage.ts"; + +type LastActiveSessionHost = { + settings: UiSettings; + applySettings(next: UiSettings): void; +}; + +export function setLastActiveSessionKey(host: LastActiveSessionHost, next: string) { + const trimmed = next.trim(); + if (!trimmed || host.settings.lastActiveSessionKey === trimmed) { + return; + } + host.applySettings({ ...host.settings, lastActiveSessionKey: trimmed }); +} diff --git a/ui/src/ui/app-polling.ts b/ui/src/ui/app-polling.ts index 59f22568a1b..2607a231d7f 100644 --- a/ui/src/ui/app-polling.ts +++ b/ui/src/ui/app-polling.ts @@ -1,6 +1,8 @@ -import type { OpenClawApp } from "./app.ts"; +import type { DebugState } from "./controllers/debug.ts"; import { loadDebug } from "./controllers/debug.ts"; +import type { LogsState } from "./controllers/logs.ts"; import { loadLogs } from "./controllers/logs.ts"; +import type { NodesState } from "./controllers/nodes.ts"; import { loadNodes } from "./controllers/nodes.ts"; type PollingHost = { @@ -15,7 +17,7 @@ export function startNodesPolling(host: PollingHost) { return; } host.nodesPollInterval = window.setInterval( - () => void loadNodes(host as unknown as OpenClawApp, { quiet: true }), + () => void loadNodes(host as unknown as NodesState, { quiet: true }), 5000, ); } @@ -36,7 +38,7 @@ export function startLogsPolling(host: PollingHost) { if (host.tab !== "logs") { return; } - void loadLogs(host as unknown as OpenClawApp, { quiet: true }); + void loadLogs(host as unknown as LogsState, { quiet: true }); }, 2000); } @@ -56,7 +58,7 @@ export function startDebugPolling(host: PollingHost) { if (host.tab !== "debug") { return; } - void loadDebug(host as unknown as OpenClawApp); + void loadDebug(host as unknown as DebugState); }, 3000); } diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index e0933ef3a81..538e616d625 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -4,7 +4,6 @@ import { t } from "../i18n/index.ts"; import { refreshChat, refreshChatAvatar } from "./app-chat.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; -import { OpenClawApp } from "./app.ts"; import { createChatModelOverride } from "./chat-model-ref.ts"; import { resolveChatModelOverrideValue, @@ -30,6 +29,20 @@ type SessionDefaultsSnapshot = { mainKey?: string; }; +type SessionSwitchHost = AppViewState & { + chatStreamStartedAt: number | null; + resetToolStream(): void; + resetChatScroll(): void; +}; + +type ChatRefreshHost = AppViewState & { + chatManualRefreshInFlight: boolean; + chatNewMessagesBelow: boolean; + resetToolStream(): void; + scrollToBottom(opts?: { smooth?: boolean }): void; + updateComplete?: Promise; +}; + function resolveSidebarChatSessionKey(state: AppViewState): string { const snapshot = state.hello?.snapshot as | { sessionDefaults?: SessionDefaultsSnapshot } @@ -46,6 +59,7 @@ function resolveSidebarChatSessionKey(state: AppViewState): string { } function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) { + const host = state as unknown as SessionSwitchHost; state.sessionKey = sessionKey; state.chatMessage = ""; state.chatAttachments = []; @@ -59,10 +73,10 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) state.fallbackStatus = null; state.chatAvatarUrl = null; state.chatQueue = []; - (state as unknown as OpenClawApp).chatStreamStartedAt = null; + host.chatStreamStartedAt = null; state.chatRunId = null; - (state as unknown as OpenClawApp).resetToolStream(); - (state as unknown as OpenClawApp).resetChatScroll(); + host.resetToolStream(); + host.resetChatScroll(); state.applySettings({ ...state.settings, sessionKey, @@ -252,7 +266,7 @@ export function renderChatControls(state: AppViewState) { class="btn btn--sm btn--icon" ?disabled=${state.chatLoading || !state.connected} @click=${async () => { - const app = state as unknown as OpenClawApp; + const app = state as unknown as ChatRefreshHost; app.chatManualRefreshInFlight = true; app.chatNewMessagesBelow = false; await app.updateComplete; diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index d192cbc13f0..f6aecfea6ab 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -7,24 +7,32 @@ import { stopDebugPolling, } from "./app-polling.ts"; import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts"; -import type { OpenClawApp } from "./app.ts"; -import { loadAgentFiles } from "./controllers/agent-files.ts"; -import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; -import { loadAgentSkills } from "./controllers/agent-skills.ts"; -import { loadAgents } from "./controllers/agents.ts"; -import { loadChannels } from "./controllers/channels.ts"; -import { loadConfig, loadConfigSchema } from "./controllers/config.ts"; -import { loadCronJobsPage, loadCronRuns, loadCronStatus } from "./controllers/cron.ts"; -import { loadDebug } from "./controllers/debug.ts"; -import { loadDevices } from "./controllers/devices.ts"; -import { loadDreamDiary, loadDreamingStatus } from "./controllers/dreaming.ts"; -import { loadExecApprovals } from "./controllers/exec-approvals.ts"; -import { loadLogs } from "./controllers/logs.ts"; -import { loadNodes } from "./controllers/nodes.ts"; -import { loadPresence } from "./controllers/presence.ts"; -import { loadSessions } from "./controllers/sessions.ts"; -import { loadSkills } from "./controllers/skills.ts"; -import { loadUsage } from "./controllers/usage.ts"; +import { loadAgentFiles, type AgentFilesState } from "./controllers/agent-files.ts"; +import { + loadAgentIdentities, + loadAgentIdentity, + type AgentIdentityState, +} from "./controllers/agent-identity.ts"; +import { loadAgentSkills, type AgentSkillsState } from "./controllers/agent-skills.ts"; +import { loadAgents, type AgentsState } from "./controllers/agents.ts"; +import { loadChannels, type ChannelsState } from "./controllers/channels.ts"; +import { loadConfig, loadConfigSchema, type ConfigState } from "./controllers/config.ts"; +import { + loadCronJobsPage, + loadCronRuns, + loadCronStatus, + type CronState, +} from "./controllers/cron.ts"; +import { loadDebug, type DebugState } from "./controllers/debug.ts"; +import { loadDevices, type DevicesState } from "./controllers/devices.ts"; +import { loadDreamDiary, loadDreamingStatus, type DreamingState } from "./controllers/dreaming.ts"; +import { loadExecApprovals, type ExecApprovalsState } from "./controllers/exec-approvals.ts"; +import { loadLogs, type LogsState } from "./controllers/logs.ts"; +import { loadNodes, type NodesState } from "./controllers/nodes.ts"; +import { loadPresence, type PresenceState } from "./controllers/presence.ts"; +import { loadSessions, type SessionsState } from "./controllers/sessions.ts"; +import { loadSkills, type SkillsState } from "./controllers/skills.ts"; +import { loadUsage, type UsageState } from "./controllers/usage.ts"; import { inferBasePathFromPathname, normalizeBasePath, @@ -40,6 +48,8 @@ import { resolveTheme, type ResolvedTheme, type ThemeMode, type ThemeName } from import type { AgentsListResult, AttentionItem } from "./types.ts"; import { resetChatViewState } from "./views/chat.ts"; +export { setLastActiveSessionKey } from "./app-last-active-session.ts"; + type SettingsHost = { settings: UiSettings; password?: string; @@ -71,6 +81,30 @@ type SettingsHost = { dreamDiaryContent: string | null; }; +type SettingsAppHost = SettingsHost & + AgentFilesState & + AgentIdentityState & + AgentSkillsState & + AgentsState & + ChannelsState & + ConfigState & + CronState & + DebugState & + DevicesState & + DreamingState & + ExecApprovalsState & + LogsState & + NodesState & + PresenceState & + SessionsState & + SkillsState & + UsageState & { + overviewLogCursor: number | null; + overviewLogLines: string[]; + attentionItems: AttentionItem[]; + hello: { auth?: { role?: string; scopes?: string[] } } | null; + }; + export function applySettings(host: SettingsHost, next: UiSettings) { const normalized = { ...next, @@ -90,14 +124,6 @@ export function applySettings(host: SettingsHost, next: UiSettings) { host.applySessionKey = host.settings.lastActiveSessionKey; } -export function setLastActiveSessionKey(host: SettingsHost, next: string) { - const trimmed = next.trim(); - if (!trimmed || host.settings.lastActiveSessionKey === trimmed) { - return; - } - applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed }); -} - function applySessionSelection(host: SettingsHost, session: string) { host.sessionKey = session; applySettings(host, { @@ -231,7 +257,7 @@ export function setThemeMode( ); } -async function refreshAgentsTab(host: SettingsHost, app: OpenClawApp) { +async function refreshAgentsTab(host: SettingsHost, app: SettingsAppHost) { await loadAgents(app); await loadConfig(app); const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? []; @@ -257,7 +283,7 @@ async function refreshAgentsTab(host: SettingsHost, app: OpenClawApp) { } export async function refreshActiveTab(host: SettingsHost) { - const app = host as unknown as OpenClawApp; + const app = host as unknown as SettingsAppHost; switch (host.tab) { case "config": case "communications": @@ -547,7 +573,7 @@ export function hasMissingSkillDependencies( return Object.values(missing).some((value) => Array.isArray(value) && value.length > 0); } -async function loadOverviewLogs(host: OpenClawApp) { +async function loadOverviewLogs(host: SettingsAppHost) { if (!host.client || !host.connected) { return; } @@ -573,7 +599,7 @@ async function loadOverviewLogs(host: OpenClawApp) { } } -function buildAttentionItems(host: OpenClawApp) { +function buildAttentionItems(host: SettingsAppHost) { const items: AttentionItem[] = []; if (host.lastError) { @@ -650,12 +676,12 @@ function buildAttentionItems(host: OpenClawApp) { } export async function loadChannelsTab(host: SettingsHost) { - const app = host as unknown as OpenClawApp; + const app = host as unknown as SettingsAppHost; await Promise.all([loadChannels(app, true), loadConfigSchema(app), loadConfig(app)]); } export async function loadCron(host: SettingsHost) { - const app = host as unknown as OpenClawApp; + const app = host as unknown as SettingsAppHost; const activeCronJobId = app.cronRunsScope === "job" ? app.cronRunsJobId : null; await Promise.all([ loadChannels(app, false),