From 7129db1960fee79f212263df1801e01b8b635cfd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 08:36:45 +0100 Subject: [PATCH 01/19] perf: lighten gateway watch startup --- scripts/run-node-watch-paths.mjs | 63 ++++++++++++++++++++++++++++++ scripts/run-node.mjs | 67 ++++++-------------------------- scripts/watch-node.mjs | 67 +++++++++++++++++++++++--------- src/infra/watch-node.test.ts | 57 +++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 74 deletions(-) create mode 100644 scripts/run-node-watch-paths.mjs diff --git a/scripts/run-node-watch-paths.mjs b/scripts/run-node-watch-paths.mjs new file mode 100644 index 00000000000..c04af8e25d9 --- /dev/null +++ b/scripts/run-node-watch-paths.mjs @@ -0,0 +1,63 @@ +import path from "node:path"; +import { + BUNDLED_PLUGIN_PATH_PREFIX, + BUNDLED_PLUGIN_ROOT_DIR, +} from "./lib/bundled-plugin-paths.mjs"; + +export const runNodeSourceRoots = ["src", BUNDLED_PLUGIN_ROOT_DIR]; +export const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; +export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; +export const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]); + +const ignoredRunNodeRepoPaths = new Set([ + "src/canvas-host/a2ui/.bundle.hash", + "src/canvas-host/a2ui/a2ui.bundle.js", +]); +const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/; + +export const normalizeRunNodePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); + +const isIgnoredSourcePath = (relativePath) => { + const normalizedPath = normalizeRunNodePath(relativePath); + return ( + normalizedPath.endsWith(".test.ts") || + normalizedPath.endsWith(".test.tsx") || + normalizedPath.endsWith("test-helpers.ts") + ); +}; + +const isBuildRelevantSourcePath = (relativePath) => { + const normalizedPath = normalizeRunNodePath(relativePath); + return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath); +}; + +const isRestartRelevantExtensionPath = (relativePath) => { + const normalizedPath = normalizeRunNodePath(relativePath); + if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) { + return true; + } + return isBuildRelevantSourcePath(normalizedPath); +}; + +const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => { + const normalizedPath = normalizeRunNodePath(repoPath).replace(/^\.\/+/, ""); + if (ignoredRunNodeRepoPaths.has(normalizedPath)) { + return false; + } + if (runNodeConfigFiles.includes(normalizedPath)) { + return true; + } + if (normalizedPath.startsWith("src/")) { + return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); + } + if (normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) { + return isRelevantBundledPluginPath(normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length)); + } + return false; +}; + +export const isBuildRelevantRunNodePath = (repoPath) => + isRelevantRunNodePath(repoPath, isBuildRelevantSourcePath); + +export const isRestartRelevantRunNodePath = (repoPath) => + isRelevantRunNodePath(repoPath, isRestartRelevantExtensionPath); diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 0f536e3772a..7b485805527 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -16,14 +16,22 @@ import { writeRuntimePostBuildStamp as writeDistRuntimePostBuildStamp, } from "./lib/local-build-metadata.mjs"; import { listStaticExtensionAssetSources } from "./lib/static-extension-assets.mjs"; +import { + extensionRestartMetadataFiles, + isBuildRelevantRunNodePath, + isRestartRelevantRunNodePath, + normalizeRunNodePath as normalizePath, + runNodeConfigFiles, + runNodeSourceRoots, + runNodeWatchedPaths, +} from "./run-node-watch-paths.mjs"; import { runRuntimePostBuild } from "./runtime-postbuild.mjs"; +export { isBuildRelevantRunNodePath, isRestartRelevantRunNodePath, runNodeWatchedPaths }; + const buildScript = "scripts/tsdown-build.mjs"; const compilerArgs = [buildScript, "--no-clean"]; -const runNodeSourceRoots = ["src", BUNDLED_PLUGIN_ROOT_DIR]; -const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; -export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; const runtimePostBuildWatchedPaths = [ "scripts/copy-bundled-plugin-metadata.mjs", "scripts/copy-plugin-sdk-root-alias.mjs", @@ -40,63 +48,10 @@ const runtimePostBuildWatchedPaths = [ "src/plugin-sdk/root-alias.cjs", BUNDLED_PLUGIN_ROOT_DIR, ]; -const ignoredRunNodeRepoPaths = new Set([ - "src/canvas-host/a2ui/.bundle.hash", - "src/canvas-host/a2ui/a2ui.bundle.js", -]); const runtimePostBuildScriptPaths = new Set( runtimePostBuildWatchedPaths.filter((entry) => entry.startsWith("scripts/")), ); const runtimePostBuildStaticAssetPaths = new Set(listStaticExtensionAssetSources()); -const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/; -const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]); - -const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); - -const isIgnoredSourcePath = (relativePath) => { - const normalizedPath = normalizePath(relativePath); - return ( - normalizedPath.endsWith(".test.ts") || - normalizedPath.endsWith(".test.tsx") || - normalizedPath.endsWith("test-helpers.ts") - ); -}; - -const isBuildRelevantSourcePath = (relativePath) => { - const normalizedPath = normalizePath(relativePath); - return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath); -}; - -const isRestartRelevantExtensionPath = (relativePath) => { - const normalizedPath = normalizePath(relativePath); - if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) { - return true; - } - return isBuildRelevantSourcePath(normalizedPath); -}; - -const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => { - const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); - if (ignoredRunNodeRepoPaths.has(normalizedPath)) { - return false; - } - if (runNodeConfigFiles.includes(normalizedPath)) { - return true; - } - if (normalizedPath.startsWith("src/")) { - return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); - } - if (normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) { - return isRelevantBundledPluginPath(normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length)); - } - return false; -}; - -export const isBuildRelevantRunNodePath = (repoPath) => - isRelevantRunNodePath(repoPath, isBuildRelevantSourcePath); - -export const isRestartRelevantRunNodePath = (repoPath) => - isRelevantRunNodePath(repoPath, isRestartRelevantExtensionPath); const statMtime = (filePath, fsImpl = fs) => { try { diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index 92131ff39de..6cb65d6dbc9 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -5,7 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node.mjs"; +import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node-watch-paths.mjs"; const WATCH_NODE_RUNNER = "scripts/run-node.mjs"; const WATCH_RESTART_SIGNAL = "SIGTERM"; @@ -255,19 +255,6 @@ const releaseWatchLock = (lockHandle) => { * }} [params] */ export async function runWatchMain(params = {}) { - let createWatcher = params.createWatcher; - if (!createWatcher) { - try { - const chokidarModule = await (params.loadChokidar ?? loadChokidar)(); - createWatcher = (watchPaths, options) => chokidarModule.watch(watchPaths, options); - } catch (err) { - if (isInvalidPackageConfigError(err)) { - printFriendlyWatchStartupError(err); - } - throw err; - } - } - const deps = { spawn: params.spawn ?? spawn, process: params.process ?? process, @@ -278,7 +265,8 @@ export async function runWatchMain(params = {}) { sleep: params.sleep ?? sleep, signalProcess: params.signalProcess ?? ((pid, signal) => process.kill(pid, signal)), lockDisabled: params.lockDisabled === true, - createWatcher, + createWatcher: params.createWatcher, + loadChokidar: params.loadChokidar ?? loadChokidar, watchPaths: params.watchPaths ?? runNodeWatchedPaths, }; @@ -293,7 +281,7 @@ export async function runWatchMain(params = {}) { childEnv.OPENCLAW_WATCH_COMMAND = deps.args.join(" "); } - return await new Promise((resolve) => { + return await new Promise((resolve, reject) => { let settled = false; let shuttingDown = false; let restartRequested = false; @@ -357,6 +345,38 @@ export async function runWatchMain(params = {}) { settle(1); }; + const rejectWatcherStartupError = (err) => { + if (settled) { + return; + } + settled = true; + shuttingDown = true; + if (watchProcess && typeof watchProcess.kill === "function") { + watchProcess.kill(WATCH_RESTART_SIGNAL); + } + releaseWatchLock(lockHandle); + watcher?.close?.().catch?.(() => {}); + if (onSigInt) { + deps.process.off("SIGINT", onSigInt); + } + if (onSigTerm) { + deps.process.off("SIGTERM", onSigTerm); + } + reject(err); + }; + + const resolveCreateWatcher = async () => { + try { + const chokidarModule = await deps.loadChokidar(); + return (watchPaths, options) => chokidarModule.watch(watchPaths, options); + } catch (err) { + if (isInvalidPackageConfigError(err)) { + printFriendlyWatchStartupError(err); + } + throw err; + } + }; + const runAutoDoctorAndRestart = () => { autoDoctorAttempted = true; logWatcher( @@ -405,8 +425,11 @@ export async function runWatchMain(params = {}) { } }; - const startWatcher = () => { - watcher = deps.createWatcher(deps.watchPaths, { + const attachWatcher = (createWatcher) => { + if (settled) { + return; + } + watcher = createWatcher(deps.watchPaths, { ignoreInitial: true, ignored: (watchPath, stats) => isIgnoredWatchPath(watchPath, deps.cwd, deps.watchPaths, stats), @@ -417,6 +440,14 @@ export async function runWatchMain(params = {}) { watcher.on("error", handleWatcherError); }; + const startWatcher = () => { + if (deps.createWatcher) { + attachWatcher(deps.createWatcher); + return; + } + void resolveCreateWatcher().then(attachWatcher).catch(rejectWatcherStartupError); + }; + onSigInt = () => { shuttingDown = true; if (watchProcess && typeof watchProcess.kill === "function") { diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index 3c573da5e43..3e6f787e3c9 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -155,6 +155,49 @@ describe("watch-node script", () => { }); }); + it("starts the runner before loading chokidar", async () => { + const child = Object.assign(new EventEmitter(), { + kill: vi.fn(() => {}), + }); + const spawn = vi.fn(() => child); + const watcher = Object.assign(new EventEmitter(), { + close: vi.fn(async () => {}), + }); + const watch = vi.fn(() => watcher); + let resolveLoadChokidar: (value: { watch: typeof watch }) => void = () => {}; + const loadChokidar = vi.fn( + () => + new Promise<{ watch: typeof watch }>((resolve) => { + resolveLoadChokidar = resolve; + }), + ); + const fakeProcess = createFakeProcess(); + + const runPromise = runWatch({ + args: ["gateway", "--force"], + loadChokidar, + lockDisabled: true, + process: fakeProcess, + spawn, + }); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(loadChokidar).toHaveBeenCalledTimes(1); + expect(spawn.mock.invocationCallOrder[0]).toBeLessThan( + loadChokidar.mock.invocationCallOrder[0], + ); + + resolveLoadChokidar({ watch }); + await new Promise((resolve) => setImmediate(resolve)); + expect(watch).toHaveBeenCalledTimes(1); + + fakeProcess.emit("SIGINT"); + const exitCode = await runPromise; + expect(exitCode).toBe(130); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(watcher.close).toHaveBeenCalledTimes(1); + }); + it("terminates child on SIGINT and returns shell interrupt code", async () => { const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness(); @@ -412,6 +455,10 @@ describe("watch-node script", () => { ), { code: "ERR_INVALID_PACKAGE_CONFIG" }, ); + const child = Object.assign(new EventEmitter(), { + kill: vi.fn(() => {}), + }); + const spawn = vi.fn(() => child); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { @@ -423,9 +470,12 @@ describe("watch-node script", () => { throw error; }), process: createFakeProcess(), + spawn, }), ).rejects.toBe(error); + expect(spawn).toHaveBeenCalledTimes(1); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); expect(errorSpy.mock.calls).toEqual([ [""], [ @@ -450,6 +500,10 @@ describe("watch-node script", () => { const error = Object.assign(new Error("Cannot find package 'chokidar'"), { code: "ERR_MODULE_NOT_FOUND", }); + const child = Object.assign(new EventEmitter(), { + kill: vi.fn(() => {}), + }); + const spawn = vi.fn(() => child); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { @@ -459,9 +513,12 @@ describe("watch-node script", () => { throw error; }), process: createFakeProcess(), + spawn, }), ).rejects.toBe(error); + expect(spawn).toHaveBeenCalledTimes(1); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); expect(errorSpy).not.toHaveBeenCalled(); } finally { errorSpy.mockRestore(); From e5248789986c5df234629a818969349605b0e65e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 08:40:23 +0100 Subject: [PATCH 02/19] fix(googlechat): normalize auth response headers --- CHANGELOG.md | 1 + .../googlechat/src/google-auth.runtime.test.ts | 17 +++++++++++++++++ .../googlechat/src/google-auth.runtime.ts | 16 ++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 872d271cffa..44c516e7a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -205,6 +205,7 @@ Docs: https://docs.openclaw.ai - Feishu: accept and honor `channels.feishu.blockStreaming` at the top level and per account, while keeping the legacy default off so Feishu cards no longer reject documented config or silently drop block replies. Fixes #75555. Thanks @vincentkoc. - Gateway/update: avoid `launchctl kickstart -k` immediately after fresh macOS update bootstraps, and unlink dangling global plugin-runtime symlinks during packaged postinstall and `doctor --fix` so upgrades no longer SIGTERM the newly booted Gateway or leave bundled plugin imports pointed at pruned `plugin-runtime-deps` trees. Completes #76261 and fixes #76466. (#76929) - Google Chat: normalize custom Google auth transport headers before google-auth/gaxios interceptors run, restoring webhook token verification when certificate retrieval expects Fetch `Headers`. Fixes #76742. Thanks @donbowman. +- Google Chat: normalize Google auth certificate response headers before google-auth-library reads cache-control, so inbound webhook auth no longer rejects with `res?.headers.get is not a function`. Fixes #76880. Thanks @donbowman. - Doctor/plugins: reset stale `plugins.slots.memory` and `plugins.slots.contextEngine` references during `doctor --fix`, so cleanup of missing plugin config does not leave unrecoverable slot owners behind. Fixes #76550 and #76551. Thanks @vincentkoc. - Docs/WhatsApp: merge the duplicate top-level `web` objects in the gateway channel config example so copy-pasted WhatsApp config keeps both `web.whatsapp` and reconnect settings. Fixes #76619. Thanks @WadydX. - Plugins/Anthropic: expose Claude thinking profiles from the bundled provider-policy artifact so non-runtime callers keep Opus 4.7 `adaptive`, `xhigh`, and `max` instead of downgrading to `high`. Fixes #76779. Thanks @tomascupr and @iAbhi001. diff --git a/extensions/googlechat/src/google-auth.runtime.test.ts b/extensions/googlechat/src/google-auth.runtime.test.ts index cb072ce96f7..1a583af9496 100644 --- a/extensions/googlechat/src/google-auth.runtime.test.ts +++ b/extensions/googlechat/src/google-auth.runtime.test.ts @@ -348,6 +348,9 @@ describe("googlechat google auth runtime", () => { expect(transport.interceptors.request.add).toHaveBeenCalledWith({ resolved: expect.any(Function), }); + expect(transport.interceptors.response.add).toHaveBeenCalledWith({ + resolved: expect.any(Function), + }); expect("window" in globalThis).toBe(false); } finally { if (originalWindowDescriptor) { @@ -369,6 +372,20 @@ describe("googlechat google auth runtime", () => { expect(normalized.headers.get("x-test")).toBe("1"); }); + it("normalizes Google auth response headers before upstream cache-control reads", () => { + const response = { + data: {}, + headers: { + "cache-control": "public, max-age=3600", + }, + }; + + const normalized = __testing.normalizeGoogleAuthResponseHeaders(response); + + expect(normalized.headers).toBeInstanceOf(Headers); + expect(normalized.headers.get("cache-control")).toBe("public, max-age=3600"); + }); + it("rejects service-account credentials that override Google auth endpoints", async () => { await expect( resolveValidatedGoogleChatCredentials({ diff --git a/extensions/googlechat/src/google-auth.runtime.ts b/extensions/googlechat/src/google-auth.runtime.ts index d7c621442c8..171526884fb 100644 --- a/extensions/googlechat/src/google-auth.runtime.ts +++ b/extensions/googlechat/src/google-auth.runtime.ts @@ -23,6 +23,9 @@ type GoogleAuthTransport = InstanceType; type GoogleAuthRequestWithUnknownHeaders = RequestInit & { headers?: unknown; }; +type GoogleAuthResponseWithUnknownHeaders = { + headers?: unknown; +}; type GuardedGoogleAuthRequestInit = RequestInit & { agent?: unknown; cert?: unknown; @@ -79,12 +82,24 @@ function normalizeGoogleAuthPreparedRequestHeaders( + response: T, +): T & { headers: Headers } { + if (!(response.headers instanceof Headers)) { + response.headers = new Headers(response.headers as HeadersInit | undefined); + } + return response as T & { headers: Headers }; +} + function installGoogleAuthHeaderCompatibilityInterceptor( transport: GoogleAuthTransport, ): GoogleAuthTransport { transport.interceptors.request.add({ resolved: async (config) => normalizeGoogleAuthPreparedRequestHeaders(config), }); + transport.interceptors.response.add({ + resolved: async (response) => normalizeGoogleAuthResponseHeaders(response), + }); return transport; } @@ -558,6 +573,7 @@ export const __testing = { googleAuthTransportPromise = null; }, normalizeGoogleAuthPreparedRequestHeaders, + normalizeGoogleAuthResponseHeaders, resolveGoogleAuthEnvProxyUrl, validateGoogleChatServiceAccountCredentials, }; From cf03fe6b6a136284f55eff9eed90babe94a6a5e6 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 4 May 2026 02:40:52 -0500 Subject: [PATCH 03/19] fix(control-ui): contain access settings fields (#77171) * fix(control-ui): contain access settings fields * docs: update changelog for access overflow fix * fix(control-ui): preserve archived session defaults --- CHANGELOG.md | 1 + ui/src/styles/components.css | 13 ++++++++++++- ui/src/styles/components.test.ts | 14 ++++++++++++++ ui/src/ui/app-render.helpers.ts | 4 ++-- ui/src/ui/chat/session-controls.ts | 2 +- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c516e7a78..9c13b08e56f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai - Media/images: keep HEIC/HEIF attachments fail-closed when optional Sharp conversion is unavailable instead of sending originals that still need conversion. Thanks @vincentkoc. - Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting. - iOS/mobile pairing: reject non-loopback `ws://` setup URLs before QR/setup-code issuance and let the iOS Gateway settings screen scan QR codes or paste full setup-code messages. Thanks @BunsDev. +- Control UI: keep Gateway Access inputs and locale picker contained inside the card at narrow and tablet widths. - Telegram/streaming: sanitize tool-progress draft preview backticks before shared compaction, so long backtick-heavy progress text still renders inside the safe code-formatted preview instead of collapsing to an ellipsis. - UI/chat: remove the unsupported `line-clamp` declaration from the chat queue text rule to eliminate Firefox console noise without changing visible truncation behavior. Thanks @ZanderH-code. - Agents/Pi: suppress persistence for synthetic mid-turn overflow continuation prompts, so transcript-retry recovery does not write the "continue from transcript" prompt as a new user turn. Thanks @vincentkoc. diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 3a0233a499a..56cff23b021 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -5552,7 +5552,18 @@ td.data-table-key-col { .ov-access-grid { display: grid; gap: 12px; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); +} + +.ov-access-grid .field { + min-width: 0; +} + +.ov-access-grid .field input, +.ov-access-grid .field select { + box-sizing: border-box; + min-width: 0; + width: 100%; } .ov-access-grid__full { diff --git a/ui/src/styles/components.test.ts b/ui/src/styles/components.test.ts index 12b05734409..a0a42249a8b 100644 --- a/ui/src/styles/components.test.ts +++ b/ui/src/styles/components.test.ts @@ -34,3 +34,17 @@ describe("sessions filter styles", () => { expect(css).toContain(".sessions-filter-bar {\n flex-direction: column;"); }); }); + +describe("overview access grid styles", () => { + it("keeps access fields and native controls within the card", () => { + const css = readComponentsCss(); + + expect(css).toContain( + "grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr));", + ); + expect(css).toContain(".ov-access-grid .field {\n min-width: 0;"); + expect(css).toContain(".ov-access-grid .field input,\n.ov-access-grid .field select {"); + expect(css).toContain("box-sizing: border-box;"); + expect(css).toContain("width: 100%;"); + }); +}); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index ca377befc81..28efcccce82 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -661,7 +661,7 @@ export async function createChatSession(state: AppViewState) { limit: 0, includeGlobal: true, includeUnknown: true, - showArchived: state.sessionsShowArchived, + showArchived: state.sessionsShowArchived ?? false, }, ); if ( @@ -692,7 +692,7 @@ async function refreshSessionOptions(state: AppViewState) { limit: 0, includeGlobal: true, includeUnknown: true, - showArchived: state.sessionsShowArchived, + showArchived: state.sessionsShowArchived ?? false, }); } diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index fa098a19aaa..99cbd0edd2c 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -77,7 +77,7 @@ async function refreshSessionOptions(state: AppViewState) { limit: 0, includeGlobal: true, includeUnknown: true, - showArchived: state.sessionsShowArchived, + showArchived: state.sessionsShowArchived ?? false, }); } From f2e7f33d690cbc0041a4f273555324c1906715fa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 00:31:28 -0700 Subject: [PATCH 04/19] fix(ui): cap responsiveness event logs --- CHANGELOG.md | 1 + ui/src/ui/control-ui-performance.test.ts | 34 +++++++++++++++++++++-- ui/src/ui/control-ui-performance.ts | 35 ++++++++++++++++++++++-- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c13b08e56f..0a4389b6e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis. +- Control UI/performance: cap long-task and long-animation-frame diagnostics in the shared event log, so slow-render telemetry does not evict gateway/plugin events from the Debug and Overview views. Thanks @vincentkoc. - Web fetch: late-bind `web_fetch` config and provider fallback metadata from the active runtime snapshot, matching `web_search` so long-lived tools do not use stale fetch provider settings. Thanks @vincentkoc. - Discord: clear stale startup probe bot/application status when the async bot probe throws, not just when it returns a degraded probe result. Thanks @vincentkoc. - Web search: scope explicit bundled `web_search` provider runtime loading through manifest ownership, so selecting DuckDuckGo/Gemini/etc. does not import unrelated bundled providers or log their optional dependency failures. Thanks @vincentkoc. diff --git a/ui/src/ui/control-ui-performance.test.ts b/ui/src/ui/control-ui-performance.test.ts index d8688639e8b..80368a47af6 100644 --- a/ui/src/ui/control-ui-performance.test.ts +++ b/ui/src/ui/control-ui-performance.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { EventLogEntry } from "./app-events.ts"; import { recordControlUiPerformanceEvent, startControlUiResponsivenessObserver, @@ -46,8 +47,8 @@ function installPerformanceObserverMock(options: { function createHost() { return { tab: "chat" as const, - eventLog: [] as Array<{ payload: Record }>, - eventLogBuffer: [] as Array<{ payload: Record }>, + eventLog: [] as EventLogEntry[], + eventLogBuffer: [] as EventLogEntry[], }; } @@ -161,6 +162,35 @@ describe("startControlUiResponsivenessObserver", () => { ]); }); + it("caps responsiveness events so gateway events stay visible", () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + const mock = installPerformanceObserverMock({ + supportedEntryTypes: ["longtask"], + }); + const host = createHost(); + + for (let i = 0; i < 225; i += 1) { + recordControlUiPerformanceEvent(host, "gateway.event", { i }, { console: false }); + } + + startControlUiResponsivenessObserver(host); + for (let i = 0; i < 80; i += 1) { + mock.emit([ + { + name: "self", + startTime: i, + duration: 51, + } as unknown as PerformanceEntry, + ]); + } + + expect(host.eventLogBuffer).toHaveLength(250); + expect( + host.eventLogBuffer.filter((entry) => entry.event === "control-ui.longtask"), + ).toHaveLength(50); + expect(host.eventLogBuffer.some((entry) => entry.event === "gateway.event")).toBe(true); + }); + it("returns null when responsiveness entries are unsupported or observe fails", () => { installPerformanceObserverMock({ supportedEntryTypes: [] }); expect(startControlUiResponsivenessObserver(createHost())).toBeNull(); diff --git a/ui/src/ui/control-ui-performance.ts b/ui/src/ui/control-ui-performance.ts index 32cfb7a96ca..e34e38b892a 100644 --- a/ui/src/ui/control-ui-performance.ts +++ b/ui/src/ui/control-ui-performance.ts @@ -22,6 +22,7 @@ export type ControlUiRefreshRun = { const EVENT_LOG_LIMIT = 250; const SLOW_RPC_MS = 1_000; const RESPONSIVENESS_ENTRY_MS = 50; +const RESPONSIVENESS_EVENT_LOG_LIMIT = 50; type ControlUiResponsivenessObserver = { disconnect: () => void; @@ -86,11 +87,19 @@ export function recordControlUiPerformanceEvent( host: ControlUiPerformanceHost, event: string, payload: Record, - opts?: { warn?: boolean; console?: boolean }, + opts?: { warn?: boolean; console?: boolean; maxBufferedEventsForType?: number }, ) { const entry: EventLogEntry = { ts: Date.now(), event, payload }; if (Array.isArray(host.eventLogBuffer)) { - host.eventLogBuffer = [entry, ...host.eventLogBuffer].slice(0, EVENT_LOG_LIMIT); + const existingBuffer = + typeof opts?.maxBufferedEventsForType === "number" + ? keepLatestBufferedEventsForType( + host.eventLogBuffer, + event, + Math.max(0, opts.maxBufferedEventsForType - 1), + ) + : host.eventLogBuffer; + host.eventLogBuffer = [entry, ...existingBuffer].slice(0, EVENT_LOG_LIMIT); if (host.tab === "debug" || host.tab === "overview") { host.eventLog = host.eventLogBuffer; } @@ -101,6 +110,26 @@ export function recordControlUiPerformanceEvent( logPerformanceEvent(event, payload, opts?.warn === true); } +function keepLatestBufferedEventsForType( + entries: unknown[], + event: string, + maxExistingForType: number, +): unknown[] { + let keptForType = 0; + return entries.filter((entry) => { + if ( + !entry || + typeof entry !== "object" || + !("event" in entry) || + (entry as { event?: unknown }).event !== event + ) { + return true; + } + keptForType += 1; + return keptForType <= maxExistingForType; + }); +} + export function scheduleControlUiTabVisibleTiming( host: ControlUiPerformanceHost, previousTab: Tab, @@ -256,7 +285,7 @@ function recordResponsivenessEntry( scriptCount: Array.isArray(entry.scripts) ? entry.scripts.length : undefined, topScript: getTopLongAnimationFrameScript(entry.scripts), }, - { warn: true }, + { warn: true, maxBufferedEventsForType: RESPONSIVENESS_EVENT_LOG_LIMIT }, ); } From 87e3b1a241bddd2dd8f306290fccadeb5b912796 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 00:36:17 -0700 Subject: [PATCH 05/19] fix(ui): clean archived session state reads --- ui/src/ui/app-render.helpers.node.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index f0682191ef4..634393041e3 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -770,6 +770,7 @@ describe("switchChatSession", () => { chatQueue: [{ id: "queued", text: "message B", createdAt: 1 }], chatQueueBySession: {}, chatRunId: "run-1", + sessionsShowArchived: false, chatSideResultTerminalRuns: new Set(["btw-run-1"]), chatStreamStartedAt: 1, sessionsShowArchived: false, @@ -835,6 +836,7 @@ describe("switchChatSession", () => { chatQueue: [{ id: "queued-1", text: "message B", createdAt: 1 }], chatQueueBySession: {}, chatRunId: "run-1", + sessionsShowArchived: false, chatSideResultTerminalRuns: new Set(), chatStreamStartedAt: 1, settings, @@ -879,6 +881,7 @@ describe("switchChatSession", () => { chatQueue: [], chatQueueBySession: {}, chatRunId: null, + sessionsShowArchived: false, chatSideResultTerminalRuns: new Set(), chatStreamStartedAt: null, settings, From e8d0cf75ea0e6c0db5a1468cb0715746fa3ad75e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 00:38:38 -0700 Subject: [PATCH 06/19] test(ui): remove duplicate archived fixture key --- ui/src/ui/app-render.helpers.node.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index 634393041e3..8b531f6fafd 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -773,7 +773,6 @@ describe("switchChatSession", () => { sessionsShowArchived: false, chatSideResultTerminalRuns: new Set(["btw-run-1"]), chatStreamStartedAt: 1, - sessionsShowArchived: false, settings, applySettings(next: typeof settings) { state.settings = next; From e622223bcd13e9dcb2ceb16101c4a0fa01429d2c Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 4 May 2026 02:46:48 -0500 Subject: [PATCH 07/19] feat(control-ui): collapse cron new job panel Add a collapsible Control UI cron New Job panel so operators can reclaim list space while keeping create/edit one click away. Verification: - pnpm exec oxfmt --check --threads=1 CHANGELOG.md ui/src/styles/components.css ui/src/ui/controllers/cron.ts ui/src/ui/controllers/cron.test.ts ui/src/ui/views/cron.ts ui/src/ui/views/cron.test.ts ui/src/ui/app.ts ui/src/ui/app-render.ts ui/src/ui/app-view-state.ts - pnpm test ui/src/ui/views/cron.test.ts ui/src/ui/controllers/cron.test.ts - Browser preview at http://localhost:5173/cron - Testbox check:changed passed guard/type lanes; lint:core hit unrelated existing origin/main sessionsShowArchived Boolean findings. --- CHANGELOG.md | 1 + ui/src/styles/components.css | 73 ++++++++++++++++++++++++++++++ ui/src/ui/app-render.ts | 14 +++++- ui/src/ui/app-view-state.ts | 1 + ui/src/ui/app.ts | 1 + ui/src/ui/controllers/cron.test.ts | 1 + ui/src/ui/controllers/cron.ts | 1 + ui/src/ui/views/cron.test.ts | 39 ++++++++++++++++ ui/src/ui/views/cron.ts | 55 ++++++++++++++++++---- 9 files changed, 175 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a4389b6e67..d080cc92692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Changes - Control UI/header: show the active agent name in dashboard breadcrumbs without adding the current session key, keeping non-chat views oriented without crowding the topbar. +- Control UI/cron: make the New Job sidebar collapsible so the jobs list can reclaim space while keeping the form one click away. Thanks @BunsDev. - Gateway/startup: keep model-catalog test helpers, run-session lookup code, QR pairing helpers, and TypeBox memory-tool schema construction out of hot startup import paths, reducing default gateway benchmark plugin-load and memory pressure. - Control UI/performance: record browser long animation frame or long task entries in the debug event log when supported, making slow dashboard renders easier to attribute from the UI. - Channels/streaming: add unified `streaming.mode: "progress"` drafts with auto single-word status labels and shared progress configuration across Discord, Telegram, Matrix, Slack, and Microsoft Teams. diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 56cff23b021..b7f6e4ea1b1 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1008,6 +1008,10 @@ align-items: start; } +.cron-workspace--form-collapsed { + grid-template-columns: minmax(0, 1fr) 64px; +} + .cron-workspace-main { display: grid; gap: 16px; @@ -1021,12 +1025,62 @@ overflow-y: auto; } +.cron-workspace-form--collapsed { + min-height: 180px; + overflow: hidden; + padding: 10px; +} + +.cron-form-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.cron-form-header__copy { + min-width: 0; +} + +.cron-form-collapse-toggle { + flex: 0 0 auto; + width: 34px; + height: 34px; + padding: 0; + justify-content: center; +} + +.cron-workspace-form--collapsed .cron-form-header { + min-height: 160px; + flex-direction: column; + align-items: center; + justify-content: space-between; +} + +.cron-workspace-form--collapsed .cron-form-header__copy { + display: flex; + justify-content: center; + writing-mode: vertical-rl; + transform: rotate(180deg); + white-space: nowrap; +} + +.cron-workspace-form--collapsed .card-title { + font-size: 13px; +} + .cron-form { margin-top: 16px; display: grid; gap: 14px; } +.cron-form[hidden], +.cron-form-status[hidden], +.cron-form-actions[hidden] { + display: none; +} + .cron-form-section { border: 1px solid var(--border); border-radius: var(--radius-md); @@ -1399,6 +1453,25 @@ order: -1; } + .cron-workspace--form-collapsed { + grid-template-columns: 1fr; + } + + .cron-workspace-form--collapsed { + min-height: 0; + } + + .cron-workspace-form--collapsed .cron-form-header { + min-height: 0; + flex-direction: row; + align-items: center; + } + + .cron-workspace-form--collapsed .cron-form-header__copy { + writing-mode: horizontal-tb; + transform: none; + } + .cron-form-grid { grid-template-columns: 1fr; gap: 12px; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 1e37f2b6ecd..30d3e071e04 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1814,6 +1814,7 @@ export function renderApp(state: AppViewState) { error: state.cronError, busy: state.cronBusy, form: state.cronForm, + cronFormCollapsed: state.cronFormCollapsed, channels: state.channelsSnapshot?.channelMeta?.length ? state.channelsSnapshot.channelMeta.map((entry) => entry.id) : (state.channelsSnapshot?.channelOrder ?? []), @@ -1844,9 +1845,18 @@ export function renderApp(state: AppViewState) { }, onRefresh: () => state.loadCron(), onAdd: () => addCronJob(state), - onEdit: (job) => startCronEdit(state, job), - onClone: (job) => startCronClone(state, job), + onEdit: (job) => { + state.cronFormCollapsed = false; + startCronEdit(state, job); + }, + onClone: (job) => { + state.cronFormCollapsed = false; + startCronClone(state, job); + }, onCancelEdit: () => cancelCronEdit(state), + onToggleFormCollapsed: (collapsed) => { + state.cronFormCollapsed = collapsed; + }, onToggle: (job, enabled) => toggleCronJob(state, job, enabled), onRun: (job, mode) => runCronJob(state, job, mode ?? "force"), onRemove: (job) => removeCronJob(state, job), diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index f728a62b21f..0203ea03e5d 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -330,6 +330,7 @@ export type AppViewState = { | "cronStatus" | "cronError" | "cronForm" + | "cronFormCollapsed" | "cronFieldErrors" | "cronEditingJobId" | "cronRunsJobId" diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 5d263701aa9..fb668b6da80 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -471,6 +471,7 @@ export class OpenClawApp extends LitElement { @state() cronStatus: CronStatus | null = null; @state() cronError: string | null = null; @state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM }; + @state() cronFormCollapsed = false; @state() cronFieldErrors: import("./controllers/cron.js").CronFieldErrors = {}; @state() cronEditingJobId: string | null = null; @state() cronRunsJobId: string | null = null; diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index 56f47e39fc9..3c8814caaf2 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -38,6 +38,7 @@ function createState(overrides: Partial = {}): CronState { cronStatus: null, cronError: null, cronForm: { ...DEFAULT_CRON_FORM }, + cronFormCollapsed: false, cronFieldErrors: {}, cronEditingJobId: null, cronRunsJobId: null, diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 141b22d6801..e1aafd866f6 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -67,6 +67,7 @@ export type CronState = { cronStatus: CronStatus | null; cronError: string | null; cronForm: CronFormState; + cronFormCollapsed: boolean; cronFieldErrors: CronFieldErrors; cronEditingJobId: string | null; cronRunsJobId: string | null; diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 51aff0ee793..e1fdf776e16 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -277,6 +277,45 @@ describe("cron view", () => { expect(container.querySelector('input[placeholder="https://example.com/cron"]')).toBeNull(); }); + it("collapses the new job sidebar without rendering the full form", () => { + const container = document.createElement("div"); + const onToggleFormCollapsed = vi.fn(); + const expandedProps = createProps() as CronProps & { + cronFormCollapsed: boolean; + onToggleFormCollapsed: (collapsed: boolean) => void; + }; + expandedProps.cronFormCollapsed = false; + expandedProps.onToggleFormCollapsed = onToggleFormCollapsed; + + render(renderCron(expandedProps), container); + + const collapseButton = container.querySelector('[data-test-id="cron-form-collapse-toggle"]'); + expect(collapseButton).not.toBeNull(); + expect(collapseButton?.getAttribute("aria-expanded")).toBe("true"); + collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onToggleFormCollapsed).toHaveBeenCalledWith(true); + expect(container.querySelector(".cron-form")).not.toBeNull(); + + const collapsedProps = createProps() as CronProps & { + cronFormCollapsed: boolean; + onToggleFormCollapsed: (collapsed: boolean) => void; + }; + collapsedProps.cronFormCollapsed = true; + collapsedProps.onToggleFormCollapsed = onToggleFormCollapsed; + + render(renderCron(collapsedProps), container); + + const collapsedButton = container.querySelector('[data-test-id="cron-form-collapse-toggle"]'); + expect(container.querySelector(".cron-workspace--form-collapsed")).not.toBeNull(); + expect(container.querySelector(".cron-workspace-form--collapsed")).not.toBeNull(); + expect(collapsedButton?.getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelector(".cron-form")?.hasAttribute("hidden")).toBe(true); + expect(container.querySelector(".cron-form-actions")?.hasAttribute("hidden")).toBe(true); + + collapsedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onToggleFormCollapsed).toHaveBeenLastCalledWith(false); + }); + it("shows webhook delivery details for jobs", () => { const container = document.createElement("div"); const job = { diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 73e932c9e6c..d37f7055cd9 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -45,6 +45,7 @@ export type CronProps = { fieldErrors: CronFieldErrors; canSubmit: boolean; editingJobId: string | null; + cronFormCollapsed?: boolean; channels: string[]; channelLabels?: Record; channelMeta?: ChannelUiMetaEntry[]; @@ -71,6 +72,7 @@ export type CronProps = { onEdit: (job: CronJob) => void; onClone: (job: CronJob) => void; onCancelEdit: () => void; + onToggleFormCollapsed?: (collapsed: boolean) => void; onToggle: (job: CronJob, enabled: boolean) => void; onRun: (job: CronJob, mode?: "force" | "due") => void; onRemove: (job: CronJob) => void; @@ -383,6 +385,9 @@ export function renderCron(props: CronProps) { props.form.sessionTarget !== "main" && props.form.payloadKind === "agentTurn"; const selectedDeliveryMode = props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode; + const formCollapsed = props.cronFormCollapsed === true; + const formTitle = isEditing ? t("cron.form.editJob") : t("cron.form.newJob"); + const toggleFormCollapsed = props.onToggleFormCollapsed; const blockingFields = collectBlockingFields(props.fieldErrors, props.form, selectedDeliveryMode); const blockedByValidation = !props.busy && blockingFields.length > 0; const hasActiveJobsFilters = @@ -437,7 +442,7 @@ export function renderCron(props: CronProps) { -
+
-
-
${isEditing ? t("cron.form.editJob") : t("cron.form.newJob")}
-
- ${isEditing ? t("cron.form.updateSubtitle") : t("cron.form.createSubtitle")} +
+
+
+
${formTitle}
+ ${formCollapsed + ? nothing + : html` +
+ ${isEditing ? t("cron.form.updateSubtitle") : t("cron.form.createSubtitle")} +
+ `} +
+ ${toggleFormCollapsed + ? html` + + ` + : nothing}
-
+
${t( "cron.form.required", @@ -1317,7 +1347,12 @@ export function renderCron(props: CronProps) {
${blockedByValidation ? html` -
+
${t("cron.form.cantAddYet")}
${t("cron.form.fillRequired")}
    @@ -1338,7 +1373,7 @@ export function renderCron(props: CronProps) {
` : nothing} -
+