From 6b3cd9043ee68b97f11d7a2889a4e23bdc7aa023 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 11 May 2026 10:37:35 -0500 Subject: [PATCH] fix(control-ui): keep channel statuses responsive Summary: - Keep Channels responsive by opening on cached/runtime snapshots, bounding live probes, and preventing stale slow probe results from replacing newer snapshots. - Reduce Control UI churn by scoping Nodes polling to the active Nodes tab, debouncing sessions.changed reconciliation, and bounding secondary chat/session refreshes. - Scope config schema analysis before section-limited renders so excluded root sections are not fully analyzed. Verification: - pnpm test ui/src/ui/app-channels.test.ts ui/src/ui/controllers/channels.test.ts ui/src/ui/app-settings.refresh-active-tab.node.test.ts ui/src/ui/app-gateway.sessions.node.test.ts ui/src/ui/app-lifecycle-connect.node.test.ts ui/src/ui/controllers/sessions.test.ts ui/src/ui/views/config.browser.test.ts src/gateway/server-methods/channels.status.test.ts src/gateway/control-ui.http.test.ts ui/src/ui/app-polling.node.test.ts ui/src/ui/app-gateway-chat-load.node.test.ts ui/src/ui/app-gateway.node.test.ts ui/src/ui/app-chat.test.ts ui/src/ui/app-render.helpers.node.test.ts ui/src/ui/app-lifecycle.node.test.ts - pnpm exec oxfmt --check --threads=1 - git diff --check origin/main...HEAD - node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.core.json - pnpm changed:lanes --json Note: local pnpm check:changed reached core lint and failed on src/gateway/server-methods/nodes.invoke-wake.test.ts, which is unchanged in this PR and already present on current origin/main; changed-file lint passed under the same repo wrapper. --- CHANGELOG.md | 2 + .../server-methods/channels.status.test.ts | 95 ++++++++++++++ src/gateway/server-methods/channels.ts | 116 ++++++++++++++---- ui/src/ui/app-chat.test.ts | 2 + ui/src/ui/app-chat.ts | 5 +- ui/src/ui/app-gateway-chat-load.node.test.ts | 11 ++ ui/src/ui/app-gateway.node.test.ts | 17 +++ ui/src/ui/app-gateway.sessions.node.test.ts | 81 +++++++++++- ui/src/ui/app-gateway.ts | 35 +++++- ui/src/ui/app-lifecycle-connect.node.test.ts | 17 +++ ui/src/ui/app-lifecycle.node.test.ts | 24 +++- ui/src/ui/app-lifecycle.ts | 15 ++- ui/src/ui/app-polling.node.test.ts | 62 ++++++++++ ui/src/ui/app-polling.ts | 12 +- ui/src/ui/app-render.helpers.node.test.ts | 10 +- ui/src/ui/app-render.helpers.ts | 15 ++- ui/src/ui/app-render.ts | 11 +- ...p-settings.refresh-active-tab.node.test.ts | 66 ++++++++-- ui/src/ui/app-settings.ts | 16 ++- ui/src/ui/app.ts | 1 + ui/src/ui/chat/session-controls.ts | 5 +- ui/src/ui/controllers/channels.test.ts | 39 ++++++ ui/src/ui/controllers/channels.ts | 16 ++- ui/src/ui/controllers/channels.types.ts | 2 + ui/src/ui/views/config.browser.test.ts | 37 ++++++ ui/src/ui/views/config.ts | 36 ++---- 26 files changed, 654 insertions(+), 94 deletions(-) create mode 100644 ui/src/ui/app-polling.node.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6842f3ed996..210ae2cb51c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI/performance: scope Nodes polling to the active Nodes tab, debounce stale session-list reconciliation, and bound chat-side session refreshes so long-running dashboards avoid background reload churn. Thanks @BunsDev. - Bonjour/Gateway: treat active ciao probing and fresh name-conflict renames as in-progress so the mDNS watchdog waits for probe settlement before retrying, preventing rapid re-advertise loops on Windows, WSL, and other multicast-hostile hosts. (#74778) Refs #74242. Thanks @fuller-stack-dev. - Providers/MiniMax: send a minimal Anthropic-compatible user fallback when message conversion filters a turn to an empty payload, so MiniMax M2.7 no longer returns `chat content is empty` after tool-heavy sessions. Fixes #74589. Thanks @neeravmakwana and @DerekEXS. - Tools/media: preserve implicit allow-all semantics from `tools.alsoAllow`-only policies when preconstructing built-in media generation and PDF tools, so configured media tools become live without forcing `tools.allow: ["*", ...]`. Fixes #77841. Thanks @trialanderrorstudios. @@ -396,6 +397,7 @@ Docs: https://docs.openclaw.ai - Gateway/performance: reuse the compatible plugin metadata snapshot across dashboard and channel agent turns so auto-enabled runtime config does not repeatedly rescan plugin metadata before provider calls. Thanks @shakkernerd. - Gateway/performance: reuse current plugin metadata for provider activation, auth/env candidate lookup, and bundle settings during dashboard and channel agent turns while keeping the configless secret-target cache unscoped and refusing stale unscoped reuse when plugin discovery roots differ. Thanks @shakkernerd. - Gateway/performance: avoid resolving plugin auto-enable metadata twice in one runtime config pass, reducing repeated dashboard turn metadata scans. Thanks @shakkernerd. +- Control UI/performance: pre-scope config tab schemas before rendering, load Channels with cached/runtime status before manual probes, preserve channel rows through failed status summaries, and keep stale slow probes from replacing newer snapshots. Thanks @BunsDev. - Auth/providers: pass `config` and `workspaceDir` lookup context through to provider-id resolution so workspace-scoped auth aliases resolve correctly when no explicit alias map is supplied. Thanks @shakkernerd. - Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and opt-in sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics. - QA/Mantis: add an opt-in Discord thread attachment before/after scenario that creates a real thread, calls `message.thread-reply` with `filePath`, and captures baseline/candidate screenshot evidence. diff --git a/src/gateway/server-methods/channels.status.test.ts b/src/gateway/server-methods/channels.status.test.ts index 099d3abd3fb..fcd808edfef 100644 --- a/src/gateway/server-methods/channels.status.test.ts +++ b/src/gateway/server-methods/channels.status.test.ts @@ -44,6 +44,11 @@ vi.mock("../../infra/channel-activity.js", () => ({ import { channelsHandlers } from "./channels.js"; +function getSuccessPayload(respond: ReturnType): Record { + expect(respond).toHaveBeenCalledWith(true, expect.any(Object), undefined); + return respond.mock.calls[0]?.[1] as Record; +} + function createOptions( params: Record, overrides?: Partial, @@ -172,6 +177,55 @@ describe("channelsHandlers channels.status", () => { expect(probeArgs.cfg).toBe(autoEnabledConfig); }); + it("preserves channel account rows when a live probe throws", async () => { + const autoEnabledConfig = { autoEnabled: true }; + const probeAccount = vi.fn(async () => { + throw new Error("probe failed"); + }); + const respond = vi.fn(); + mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] }); + mocks.buildChannelAccountSnapshot.mockImplementation(async ({ accountId, probe }) => ({ + accountId, + configured: true, + probe, + })); + mocks.listChannelPlugins.mockReturnValue([ + { + id: "whatsapp", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + isEnabled: () => true, + isConfigured: async () => true, + }, + status: { + probeAccount, + }, + }, + ]); + + await channelsHandlers["channels.status"]( + createOptions({ probe: true, timeoutMs: 1000 }, { respond }), + ); + + const payload = getSuccessPayload(respond); + const channelAccounts = payload.channelAccounts as Record< + string, + Array> + >; + expect(channelAccounts.whatsapp).toEqual([ + expect.objectContaining({ + accountId: "default", + lastError: expect.stringContaining("probe failed"), + lastProbeAt: expect.any(Number), + probe: expect.objectContaining({ + ok: false, + error: expect.stringContaining("probe failed"), + }), + }), + ]); + }); + it("returns a partial snapshot when a channel probe exceeds the status budget", async () => { vi.useFakeTimers(); try { @@ -211,6 +265,47 @@ describe("channelsHandlers channels.status", () => { } }); + it("falls back to account-derived channel summaries when summary building fails", async () => { + const autoEnabledConfig = { autoEnabled: true }; + const respond = vi.fn(); + mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] }); + mocks.buildChannelAccountSnapshot.mockResolvedValue({ + accountId: "default", + configured: true, + }); + mocks.listChannelPlugins.mockReturnValue([ + { + id: "whatsapp", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + isEnabled: () => true, + isConfigured: async () => true, + }, + status: { + buildChannelSummary: async () => { + throw new Error("summary failed"); + }, + }, + }, + ]); + + await channelsHandlers["channels.status"]( + createOptions({ probe: false, timeoutMs: 1000 }, { respond }), + ); + + const payload = getSuccessPayload(respond); + expect(payload.channels).toEqual({ + whatsapp: expect.objectContaining({ + configured: true, + lastError: expect.stringContaining("summary failed"), + }), + }); + expect(payload.channelAccounts).toEqual({ + whatsapp: [expect.objectContaining({ accountId: "default", configured: true })], + }); + }); + it("annotates unhealthy channel snapshots and includes event-loop health", async () => { const now = Date.now(); mocks.applyPluginAutoEnable.mockReturnValue({ config: { autoEnabled: true }, changes: [] }); diff --git a/src/gateway/server-methods/channels.ts b/src/gateway/server-methods/channels.ts index f21efb97e36..bdd490b73c5 100644 --- a/src/gateway/server-methods/channels.ts +++ b/src/gateway/server-methods/channels.ts @@ -65,15 +65,16 @@ function channelStatusTimeoutPayload(step: string, timeoutMs: number): Record = + | { kind: "value"; value: T } + | { kind: "error"; error: unknown } + | { kind: "timeout" }; + +async function raceWithTimeout(params: { timeoutMs: number; - warnings: string[]; - run: () => Promise; -}): Promise { - const timeoutMs = Math.max(1, params.timeoutMs); + run: () => Promise | T; +}): Promise> { + const timeoutMs = params.timeoutMs; let timer: ReturnType | null = null; const timeout = new Promise<{ kind: "timeout" }>((resolve) => { timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs); @@ -93,6 +94,22 @@ async function runChannelStatusHook(params: { if (timer) { clearTimeout(timer); } + return result; +} + +async function runChannelStatusHook(params: { + accountId: string; + channelId: ChannelId; + step: "audit" | "probe"; + timeoutMs: number; + warnings: string[]; + run: () => Promise; +}): Promise { + const timeoutMs = Math.max(1, params.timeoutMs); + const result = await raceWithTimeout({ + timeoutMs, + run: params.run, + }); if (result.kind === "value") { return result.value; } @@ -109,6 +126,46 @@ async function runChannelStatusHook(params: { }; } +type ChannelStatusSummaryOutcome = + | { ok: true; value: unknown } + | { ok: false; error: string; timedOut?: boolean }; + +async function runChannelStatusSummary(params: { + channelId: ChannelId; + timeoutMs: number; + warnings: string[]; + run: () => unknown; +}): Promise { + const timeoutMs = Math.max(1, params.timeoutMs); + const result = await raceWithTimeout({ + timeoutMs, + run: params.run, + }); + const warningPrefix = `${params.channelId} summary`; + if (result.kind === "value") { + return { ok: true, value: result.value }; + } + if (result.kind === "timeout") { + const error = `summary timed out after ${timeoutMs}ms`; + params.warnings.push(`${warningPrefix} timed out after ${timeoutMs}ms`); + return { ok: false, timedOut: true, error }; + } + const message = formatForLog(result.error); + params.warnings.push(`${warningPrefix} failed: ${message}`); + return { ok: false, error: message }; +} + +function channelStatusFailureMessage(value: unknown): string | null { + if (!value || typeof value !== "object") { + return null; + } + const record = value as Record; + if (record.ok !== false || typeof record.error !== "string" || record.error.length === 0) { + return null; + } + return record.error; +} + function resolveChannelsStatusTimeoutMs(params: { probe: boolean; timeoutMsRaw: unknown }): number { const fallback = params.probe ? CHANNEL_STATUS_MAX_TIMEOUT_MS : 10_000; if (typeof params.timeoutMsRaw !== "number" || !Number.isFinite(params.timeoutMsRaw)) { @@ -338,6 +395,11 @@ export const channelsHandlers: GatewayRequestHandlers = { probe: probeResult, audit: auditResult, }); + const hookError = + channelStatusFailureMessage(auditResult) ?? channelStatusFailureMessage(probeResult); + if (hookError && !snapshot.lastError) { + snapshot.lastError = hookError; + } if (lastProbeAt) { snapshot.lastProbeAt = lastProbeAt; } @@ -421,20 +483,30 @@ export const channelsHandlers: GatewayRequestHandlers = { await buildChannelAccounts(plugin.id); const fallbackAccount = resolvedAccounts[defaultAccountId] ?? plugin.config.resolveAccount(cfg, defaultAccountId); - const summary = plugin.status?.buildChannelSummary - ? await plugin.status.buildChannelSummary({ - account: fallbackAccount, - cfg, - defaultAccountId, - snapshot: - defaultAccount ?? - ({ - accountId: defaultAccountId, - } as ChannelAccountSnapshot), - }) - : { - configured: defaultAccount?.configured ?? false, - }; + const fallbackSummary = (lastError?: string) => ({ + configured: defaultAccount?.configured ?? false, + ...(lastError ? { lastError } : {}), + }); + let summary: unknown = fallbackSummary(); + if (plugin.status?.buildChannelSummary) { + const summaryResult = await runChannelStatusSummary({ + channelId: plugin.id, + timeoutMs, + warnings: statusWarnings, + run: () => + plugin.status!.buildChannelSummary!({ + account: fallbackAccount, + cfg, + defaultAccountId, + snapshot: + defaultAccount ?? + ({ + accountId: defaultAccountId, + } as ChannelAccountSnapshot), + }), + }); + summary = summaryResult.ok ? summaryResult.value : fallbackSummary(summaryResult.error); + } return { pluginId: plugin.id, summary, accounts, defaultAccountId }; }), limit: probe ? CHANNEL_STATUS_PROBE_CONCURRENCY : plugins.length || 1, diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 3096bbf2669..27127ff9d49 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -576,9 +576,11 @@ describe("refreshChat", () => { "sessions.list", "sessions list payload", ); + expect(sessionsListPayload.activeMinutes).toBe(120); expect(sessionsListPayload.agentId).toBe("main"); expect(sessionsListPayload.includeGlobal).toBe(true); expect(sessionsListPayload.includeUnknown).toBe(true); + expect(sessionsListPayload.limit).toBe(100); expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }); const commandsListPayload = findRequestPayload( request as unknown as MockCallSource, diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 219f1f99d76..22d40df4cf2 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -84,6 +84,7 @@ export type ChatAbortOptions = { }; export const CHAT_SESSIONS_ACTIVE_MINUTES = 120; +export const CHAT_SESSIONS_REFRESH_LIMIT = 100; export { handleChatDraftChange, handleChatInputHistoryKey, @@ -768,8 +769,8 @@ export async function refreshChat( }); const secondaryRefresh = Promise.allSettled([ loadSessions(host as unknown as SessionsState, { - activeMinutes: 0, - limit: 0, + activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, + limit: CHAT_SESSIONS_REFRESH_LIMIT, includeGlobal: true, includeUnknown: true, agentId: resolveAgentIdForSession(host) ?? undefined, diff --git a/ui/src/ui/app-gateway-chat-load.node.test.ts b/ui/src/ui/app-gateway-chat-load.node.test.ts index 1040baba707..ebb4fd55c6e 100644 --- a/ui/src/ui/app-gateway-chat-load.node.test.ts +++ b/ui/src/ui/app-gateway-chat-load.node.test.ts @@ -58,6 +58,7 @@ vi.mock("./gateway.ts", async (importOriginal) => { vi.mock("./app-chat.ts", () => ({ CHAT_SESSIONS_ACTIVE_MINUTES: 60, + CHAT_SESSIONS_REFRESH_LIMIT: 100, clearPendingQueueItemsForRun: vi.fn(), flushChatQueueForEvent: vi.fn(), refreshChatAvatar: refreshChatAvatarMock, @@ -217,4 +218,14 @@ describe("connectGateway chat load startup work", () => { await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host)); expect(refreshChatAvatarMock).toHaveBeenCalledWith(host); }); + + it("lets the active tab refresh own node and device loading after hello", async () => { + const { host, client } = connectHost("overview"); + + client.emitHello(); + + await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host)); + expect(loadNodesMock).not.toHaveBeenCalled(); + expect(loadDevicesMock).not.toHaveBeenCalled(); + }); }); diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 723f8c4ea9e..ff649cc5679 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -450,6 +450,23 @@ describe("connectGateway", () => { ]); }); + it("clears pending session reload timers when the active client closes", () => { + vi.useFakeTimers(); + try { + const { host, client } = connectHostGateway(); + const pendingReload = vi.fn(); + host.sessionsChangedReloadTimer = globalThis.setTimeout(pendingReload, 1_000); + + client.emitClose({ code: 1005 }); + + expect(host.sessionsChangedReloadTimer).toBeNull(); + vi.advanceTimersByTime(1_000); + expect(pendingReload).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("preserves pending approval requests across reconnect", () => { const host = createHost(); host.execApprovalQueue = [ diff --git a/ui/src/ui/app-gateway.sessions.node.test.ts b/ui/src/ui/app-gateway.sessions.node.test.ts index ce64ed44aa7..9246359c794 100644 --- a/ui/src/ui/app-gateway.sessions.node.test.ts +++ b/ui/src/ui/app-gateway.sessions.node.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { afterAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; const loadSessionsMock = vi.fn(); const loadChatHistoryMock = vi.fn(); @@ -8,6 +8,7 @@ const handleChatEventMock = vi.fn(() => "idle"); vi.mock("./app-chat.ts", () => ({ CHAT_SESSIONS_ACTIVE_MINUTES: 10, + CHAT_SESSIONS_REFRESH_LIMIT: 25, clearPendingQueueItemsForRun: vi.fn(), flushChatQueueForEvent: vi.fn(), refreshChatAvatar: vi.fn(), @@ -96,7 +97,7 @@ function createHost() { }, password: "", clientInstanceId: "instance-test", - client: null, + client: {}, connected: true, hello: null, lastError: null, @@ -132,6 +133,10 @@ function createHost() { } describe("handleGatewayEvent sessions.changed", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("scopes post-chat final session refreshes to the run's agent", () => { loadSessionsMock.mockReset(); handleChatEventMock.mockReset().mockReturnValue("final"); @@ -149,6 +154,7 @@ describe("handleGatewayEvent sessions.changed", () => { expect(loadSessionsMock).toHaveBeenCalledWith(host, { activeMinutes: 10, agentId: "ops", + limit: 25, }); }); @@ -175,7 +181,8 @@ describe("handleGatewayEvent sessions.changed", () => { expect(loadSessionsMock).not.toHaveBeenCalled(); }); - it("reloads sessions when a change event cannot be applied locally", () => { + it("debounces session reloads when a change event cannot be applied locally", () => { + vi.useFakeTimers(); loadSessionsMock.mockReset(); applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false }); const host = createHost(); @@ -187,9 +194,77 @@ describe("handleGatewayEvent sessions.changed", () => { seq: 1, }); + expect(loadSessionsMock).not.toHaveBeenCalled(); + vi.advanceTimersByTime(4_999); + expect(loadSessionsMock).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); expect(loadSessionsMock).toHaveBeenCalledWith(host); }); + it("coalesces unapplied session change reloads into one reconciliation", () => { + vi.useFakeTimers(); + loadSessionsMock.mockReset(); + applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false }); + const host = createHost(); + + handleGatewayEvent(host, { + type: "event", + event: "sessions.changed", + payload: { sessionKey: "agent:main:a", reason: "cleanup" }, + seq: 1, + }); + vi.advanceTimersByTime(2_500); + handleGatewayEvent(host, { + type: "event", + event: "sessions.changed", + payload: { sessionKey: "agent:main:b", reason: "cleanup" }, + seq: 2, + }); + + vi.advanceTimersByTime(4_999); + expect(loadSessionsMock).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(loadSessionsMock).toHaveBeenCalledTimes(1); + expect(loadSessionsMock).toHaveBeenCalledWith(host); + }); + + it("skips a delayed session reload after the user returns to chat", () => { + vi.useFakeTimers(); + loadSessionsMock.mockReset(); + applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false }); + const host = createHost(); + + handleGatewayEvent(host, { + type: "event", + event: "sessions.changed", + payload: { sessionKey: "agent:main:main", reason: "cleanup" }, + seq: 1, + }); + host.tab = "chat"; + vi.advanceTimersByTime(5_000); + + expect(loadSessionsMock).not.toHaveBeenCalled(); + }); + + it("skips a delayed session reload after disconnect", () => { + vi.useFakeTimers(); + loadSessionsMock.mockReset(); + applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false }); + const host = createHost(); + + handleGatewayEvent(host, { + type: "event", + event: "sessions.changed", + payload: { sessionKey: "agent:main:main", reason: "cleanup" }, + seq: 1, + }); + host.connected = false; + host.client = null; + vi.advanceTimersByTime(5_000); + + expect(loadSessionsMock).not.toHaveBeenCalled(); + }); + it("does not reload sessions for applied message-phase session patches to existing rows", () => { loadSessionsMock.mockReset(); applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: true, change: "updated" }); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 71822cbf637..62d6bf9b60d 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -5,6 +5,7 @@ import { import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js"; import { CHAT_SESSIONS_ACTIVE_MINUTES, + CHAT_SESSIONS_REFRESH_LIMIT, clearPendingQueueItemsForRun, flushChatQueueForEvent, refreshChatAvatar, @@ -45,7 +46,6 @@ import { removeExecApproval, } from "./controllers/exec-approval.ts"; import { loadHealthState, type HealthState } from "./controllers/health.ts"; -import { loadNodes, type NodesState } from "./controllers/nodes.ts"; import { applySessionsChangedEvent, loadSessions, @@ -110,6 +110,7 @@ type GatewayHost = { execApprovalError: string | null; updateAvailable: UpdateAvailable | null; reconcileWebPushState?: () => Promise | void; + sessionsChangedReloadTimer?: number | ReturnType | null; }; type GatewayHostWithDeferredSessionMessageReload = GatewayHost & { @@ -133,6 +134,8 @@ type GatewayHostWithSideResults = GatewayHost & { chatSideResultTerminalRuns?: Set; }; +const SESSIONS_CHANGED_RELOAD_DEBOUNCE_MS = 5_000; + function enqueueApprovalRequest(host: GatewayHost, entry: ExecApprovalRequest | null) { if (!entry) { return; @@ -173,6 +176,29 @@ function isChatTurnSessionChangedPayload(payload: unknown): boolean { ); } +function clearSessionsChangedReloadTimer(host: GatewayHost) { + if (host.sessionsChangedReloadTimer == null) { + return; + } + globalThis.clearTimeout(host.sessionsChangedReloadTimer); + host.sessionsChangedReloadTimer = null; +} + +function shouldRunDeferredSessionsReload(host: GatewayHost): boolean { + return host.connected && Boolean(host.client) && host.tab !== "chat"; +} + +function scheduleSessionsChangedReload(host: GatewayHost) { + clearSessionsChangedReloadTimer(host); + host.sessionsChangedReloadTimer = globalThis.setTimeout(() => { + host.sessionsChangedReloadTimer = null; + if (!shouldRunDeferredSessionsReload(host)) { + return; + } + void loadSessions(host as unknown as SessionsState); + }, SESSIONS_CHANGED_RELOAD_DEBOUNCE_MS); +} + type ConnectGatewayOptions = { reason?: "initial" | "seq-gap"; }; @@ -462,6 +488,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption const reconnectReason = options?.reason ?? "initial"; shutdownHost.pendingShutdownMessage = null; shutdownHost.resumeChatQueueAfterReconnect = false; + clearSessionsChangedReloadTimer(host); host.lastError = null; host.lastErrorCode = null; host.hello = null; @@ -543,8 +570,6 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption void refreshChatAvatar(host as unknown as Parameters[0]); } 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 loadAgentsThenRefreshActiveTab(host); // Re-run push reconciliation now that the gateway client is available. void host.reconcileWebPushState?.(); @@ -555,6 +580,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption return; } host.connected = false; + clearSessionsChangedReloadTimer(host); // Code 1012 = Service Restart (expected during config saves, don't show as error) host.lastErrorCode = resolveGatewayErrorDetailCode(error) ?? @@ -642,6 +668,7 @@ function handleTerminalChatEvent( void loadSessions(host as unknown as SessionsState, { activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, agentId: resolveChatEventSessionListAgentId(host, payload), + limit: CHAT_SESSIONS_REFRESH_LIMIT, }); } } @@ -832,7 +859,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { if (result.applied || isChatTurnSessionChangedPayload(evt.payload)) { return; } - void loadSessions(host as unknown as SessionsState); + scheduleSessionsChangedReload(host); return; } diff --git a/ui/src/ui/app-lifecycle-connect.node.test.ts b/ui/src/ui/app-lifecycle-connect.node.test.ts index e77f56f5d47..bfce1934167 100644 --- a/ui/src/ui/app-lifecycle-connect.node.test.ts +++ b/ui/src/ui/app-lifecycle-connect.node.test.ts @@ -40,6 +40,9 @@ vi.mock("./app-scroll.ts", () => ({ })); import { handleConnected } from "./app-lifecycle.ts"; +import { startNodesPolling } from "./app-polling.ts"; + +const startNodesPollingMock = vi.mocked(startNodesPolling); function createDeferred() { let resolve: (() => void) | undefined; @@ -82,6 +85,7 @@ describe("handleConnected", () => { applySettingsFromUrlMock.mockReset(); connectGatewayMock.mockReset(); loadBootstrapMock.mockReset(); + startNodesPollingMock.mockReset(); vi.stubGlobal("window", { addEventListener: vi.fn(), }); @@ -129,4 +133,17 @@ describe("handleConnected", () => { loadBootstrapMock.mock.invocationCallOrder[0], ); }); + + it("starts Nodes polling only when the Nodes tab is active on connect", () => { + loadBootstrapMock.mockResolvedValue(undefined); + const chatHost = createHost(); + + handleConnected(chatHost as never); + expect(startNodesPollingMock).not.toHaveBeenCalled(); + + const nodesHost = createHost(); + nodesHost.tab = "nodes"; + handleConnected(nodesHost as never); + expect(startNodesPollingMock).toHaveBeenCalledWith(nodesHost); + }); }); diff --git a/ui/src/ui/app-lifecycle.node.test.ts b/ui/src/ui/app-lifecycle.node.test.ts index 23d3129d887..18b01d132e3 100644 --- a/ui/src/ui/app-lifecycle.node.test.ts +++ b/ui/src/ui/app-lifecycle.node.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { handleDisconnected } from "./app-lifecycle.ts"; function createHost() { @@ -22,12 +22,17 @@ function createHost() { logsAutoFollow: false, logsAtBottom: true, logsEntries: [], + sessionsChangedReloadTimer: null as number | ReturnType | null, popStateHandler: vi.fn(), topbarObserver: { disconnect: vi.fn() } as unknown as ResizeObserver, }; } describe("handleDisconnected", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("stops and clears gateway client on teardown", () => { vi.stubGlobal("window", { removeEventListener: vi.fn(), @@ -49,4 +54,21 @@ describe("handleDisconnected", () => { removeSpy.mockRestore(); vi.unstubAllGlobals(); }); + + it("clears pending session reload timers on teardown", () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { + removeEventListener: vi.fn(), + }); + const host = createHost(); + const pendingReload = vi.fn(); + host.sessionsChangedReloadTimer = globalThis.setTimeout(pendingReload, 1_000); + + handleDisconnected(host as unknown as Parameters[0]); + + expect(host.sessionsChangedReloadTimer).toBeNull(); + vi.advanceTimersByTime(1_000); + expect(pendingReload).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); }); diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 6166c512ac7..b36179acb2a 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -52,6 +52,7 @@ type LifecycleHost = { chatScrollFrame?: number | null; chatScrollTimeout?: number | null; logsScrollFrame?: number | null; + sessionsChangedReloadTimer?: number | ReturnType | null; controlUiTabPaintSeq?: number; controlUiResponsivenessObserver?: { disconnect: () => void } | null; popStateHandler: () => void; @@ -72,7 +73,9 @@ export function handleConnected(host: LifecycleHost) { } connectGateway(host as unknown as Parameters[0]); }); - startNodesPolling(host as unknown as Parameters[0]); + if (host.tab === "nodes") { + startNodesPolling(host as unknown as Parameters[0]); + } if (host.tab === "logs") { startLogsPolling(host as unknown as Parameters[0]); } @@ -100,6 +103,14 @@ function clearHostTimeout(timeout: number | null | undefined) { } } +function clearHostGlobalTimeout( + timeout: number | ReturnType | null | undefined, +) { + if (timeout != null) { + globalThis.clearTimeout(timeout); + } +} + export function handleDisconnected(host: LifecycleHost) { host.connectGeneration += 1; host.controlUiTabPaintSeq = (host.controlUiTabPaintSeq ?? 0) + 1; @@ -113,6 +124,8 @@ export function handleDisconnected(host: LifecycleHost) { host.logsScrollFrame = null; clearHostTimeout(host.chatScrollTimeout); host.chatScrollTimeout = null; + clearHostGlobalTimeout(host.sessionsChangedReloadTimer); + host.sessionsChangedReloadTimer = null; host.realtimeTalkSession?.stop(); host.realtimeTalkSession = null; host.realtimeTalkActive = false; diff --git a/ui/src/ui/app-polling.node.test.ts b/ui/src/ui/app-polling.node.test.ts new file mode 100644 index 00000000000..06f269e6c87 --- /dev/null +++ b/ui/src/ui/app-polling.node.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; + +const loadNodesMock = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock("./controllers/debug.ts", () => ({ + loadDebug: vi.fn(async () => undefined), +})); +vi.mock("./controllers/logs.ts", () => ({ + loadLogs: vi.fn(async () => undefined), +})); +vi.mock("./controllers/nodes.ts", () => ({ + loadNodes: loadNodesMock, +})); + +const { NODES_ACTIVE_POLL_INTERVAL_MS, startNodesPolling, stopNodesPolling } = + await import("./app-polling.ts"); + +function createHost() { + return { + client: {}, + connected: true, + nodesPollInterval: null, + logsPollInterval: null, + debugPollInterval: null, + tab: "overview", + }; +} + +describe("startNodesPolling", () => { + let testHost: ReturnType | null = null; + + afterEach(() => { + if (testHost) { + stopNodesPolling(testHost as never); + testHost = null; + } + vi.useRealTimers(); + vi.unstubAllGlobals(); + loadNodesMock.mockReset(); + }); + + it("does not poll nodes while another tab is active", () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { + clearInterval: globalThis.clearInterval, + setInterval: globalThis.setInterval, + }); + const host = createHost(); + testHost = host; + + startNodesPolling(host as never); + vi.advanceTimersByTime(NODES_ACTIVE_POLL_INTERVAL_MS); + expect(loadNodesMock).not.toHaveBeenCalled(); + + host.tab = "nodes"; + vi.advanceTimersByTime(NODES_ACTIVE_POLL_INTERVAL_MS); + expect(loadNodesMock).toHaveBeenCalledWith(host, { quiet: true }); + + stopNodesPolling(host as never); + }); +}); diff --git a/ui/src/ui/app-polling.ts b/ui/src/ui/app-polling.ts index 2607a231d7f..4435a64f113 100644 --- a/ui/src/ui/app-polling.ts +++ b/ui/src/ui/app-polling.ts @@ -12,14 +12,18 @@ type PollingHost = { tab: string; }; +export const NODES_ACTIVE_POLL_INTERVAL_MS = 30_000; + export function startNodesPolling(host: PollingHost) { if (host.nodesPollInterval != null) { return; } - host.nodesPollInterval = window.setInterval( - () => void loadNodes(host as unknown as NodesState, { quiet: true }), - 5000, - ); + host.nodesPollInterval = window.setInterval(() => { + if (host.tab !== "nodes") { + return; + } + void loadNodes(host as unknown as NodesState, { quiet: true }); + }, NODES_ACTIVE_POLL_INTERVAL_MS); } export function stopNodesPolling(host: PollingHost) { diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index e67512ba311..b10d3f9316a 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -17,6 +17,8 @@ const { })); vi.mock("./app-chat.ts", () => ({ + CHAT_SESSIONS_ACTIVE_MINUTES: 120, + CHAT_SESSIONS_REFRESH_LIMIT: 100, refreshChat: refreshChatMock, refreshChatAvatar: refreshChatAvatarMock, })); @@ -734,8 +736,8 @@ describe("createChatSession", () => { emitCommandHooks: true, }, { - activeMinutes: 0, - limit: 0, + activeMinutes: 120, + limit: 100, includeGlobal: true, includeUnknown: true, showArchived: false, @@ -936,8 +938,8 @@ describe("switchChatSession", () => { }); expect(loadChatHistoryMock).toHaveBeenCalledWith(state); expect(loadSessionsMock).toHaveBeenCalledWith(state, { - activeMinutes: 0, - limit: 0, + activeMinutes: 120, + limit: 100, includeGlobal: true, includeUnknown: true, showArchived: false, diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 31021a79f85..26f34812b59 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -1,6 +1,11 @@ import { html, nothing } from "lit"; import { t } from "../i18n/index.ts"; -import { refreshChat, refreshChatAvatar } from "./app-chat.ts"; +import { + CHAT_SESSIONS_ACTIVE_MINUTES, + CHAT_SESSIONS_REFRESH_LIMIT, + refreshChat, + refreshChatAvatar, +} from "./app-chat.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; import { @@ -642,8 +647,8 @@ export async function createChatSession(state: AppViewState) { emitCommandHooks: parentSessionKey !== undefined ? true : undefined, }, { - activeMinutes: 0, - limit: 0, + activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, + limit: CHAT_SESSIONS_REFRESH_LIMIT, includeGlobal: true, includeUnknown: true, showArchived: state.sessionsShowArchived, @@ -674,8 +679,8 @@ export async function createChatSession(state: AppViewState) { async function refreshSessionOptions(state: AppViewState) { await loadSessions(state as unknown as Parameters[0], { - activeMinutes: 0, - limit: 0, + activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, + limit: CHAT_SESSIONS_REFRESH_LIMIT, includeGlobal: true, includeUnknown: true, showArchived: state.sessionsShowArchived, diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 2f966b85621..afec74ce5f5 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -2,7 +2,12 @@ import { html, nothing } from "lit"; import { styleMap } from "lit/directives/style-map.js"; import { t } from "../i18n/index.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; -import { hasAbortableSessionRun, refreshChat } from "./app-chat.ts"; +import { + CHAT_SESSIONS_ACTIVE_MINUTES, + CHAT_SESSIONS_REFRESH_LIMIT, + hasAbortableSessionRun, + refreshChat, +} from "./app-chat.ts"; import { DEFAULT_CRON_FORM } from "./app-defaults.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; import { @@ -2516,8 +2521,8 @@ export function renderApp(state: AppViewState) { state.sessionsExpandedCheckpointKey = state.sessionKey; state.setTab("sessions" as import("./navigation.ts").Tab); void loadSessions(state, { - activeMinutes: 0, - limit: 0, + activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, + limit: CHAT_SESSIONS_REFRESH_LIMIT, includeGlobal: true, includeUnknown: true, }); diff --git a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts index 1ec35ab6777..03f352d528e 100644 --- a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts +++ b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; type CronRunsLoadStatus = "ok" | "error" | "skipped"; @@ -55,11 +55,25 @@ const mocks = vi.hoisted(() => ({ loadSessionsMock: vi.fn(async () => {}), loadSkillsMock: vi.fn(async () => {}), loadUsageMock: vi.fn(async () => {}), + startDebugPollingMock: vi.fn(), + startLogsPollingMock: vi.fn(), + startNodesPollingMock: vi.fn(), + stopDebugPollingMock: vi.fn(), + stopLogsPollingMock: vi.fn(), + stopNodesPollingMock: vi.fn(), })); vi.mock("./app-chat.ts", () => ({ refreshChat: mocks.refreshChatMock, })); +vi.mock("./app-polling.ts", () => ({ + startDebugPolling: mocks.startDebugPollingMock, + startLogsPolling: mocks.startLogsPollingMock, + startNodesPolling: mocks.startNodesPollingMock, + stopDebugPolling: mocks.stopDebugPollingMock, + stopLogsPolling: mocks.stopLogsPollingMock, + stopNodesPolling: mocks.stopNodesPollingMock, +})); vi.mock("./app-scroll.ts", () => ({ scheduleChatScroll: mocks.scheduleChatScrollMock, scheduleLogsScroll: mocks.scheduleLogsScrollMock, @@ -120,7 +134,7 @@ vi.mock("./controllers/usage.ts", () => ({ loadUsage: mocks.loadUsageMock, })); -import { refreshActiveTab, setTab } from "./app-settings.ts"; +import { loadChannelsTab, refreshActiveTab, setTab } from "./app-settings.ts"; function createHost() { return { @@ -141,6 +155,7 @@ function createHost() { updateComplete: Promise.resolve(), cronRunsScope: "all", cronRunsJobId: null as string | null, + sessionsChangedReloadTimer: null as number | ReturnType | null, sessionKey: "main", settings: {}, basePath: "", @@ -184,6 +199,10 @@ describe("refreshActiveTab", () => { } }); + afterEach(() => { + vi.useRealTimers(); + }); + const expectCommonAgentsTabRefresh = (host: ReturnType) => { expect(mocks.loadAgentsMock).toHaveBeenCalledOnce(); expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); @@ -239,6 +258,16 @@ describe("refreshActiveTab", () => { expect(mocks.loadAgentSkillsMock).not.toHaveBeenCalled(); }); + it("loads the Channels tab without automatic live probes", async () => { + const host = createHost(); + + await loadChannelsTab(host as never); + + expect(mocks.loadChannelsMock).toHaveBeenCalledWith(host, false); + expect(mocks.loadConfigSchemaMock).toHaveBeenCalledWith(host); + expect(mocks.loadConfigMock).toHaveBeenCalledWith(host); + }); + it("refreshes logs tab by resetting bottom-follow and scheduling scroll", async () => { const host = createHost(); host.tab = "logs"; @@ -269,6 +298,26 @@ describe("refreshActiveTab", () => { sessions.resolve(); }); + it("starts node polling on Nodes tab entry and clears pending session reloads on tab changes", () => { + vi.useFakeTimers(); + const host = createHost(); + host.tab = "overview"; + const pendingReload = vi.fn(); + host.sessionsChangedReloadTimer = globalThis.setTimeout(pendingReload, 1_000); + + setTab(host as never, "nodes"); + + expect(host.sessionsChangedReloadTimer).toBeNull(); + expect(mocks.startNodesPollingMock).toHaveBeenCalledWith(host); + expect(mocks.stopLogsPollingMock).toHaveBeenCalledWith(host); + expect(mocks.stopDebugPollingMock).toHaveBeenCalledWith(host); + vi.advanceTimersByTime(1_000); + expect(pendingReload).not.toHaveBeenCalled(); + + setTab(host as never, "sessions"); + expect(mocks.stopNodesPollingMock).toHaveBeenCalledWith(host); + }); + it("does not wait for secondary overview refreshes before resolving", async () => { const host = createHost(); host.tab = "overview"; @@ -304,31 +353,24 @@ describe("refreshActiveTab", () => { }); }); - it("renders channels from the cheap snapshot before starting slow probes", async () => { + it("renders channels from the cheap snapshot without waiting for config schema", async () => { const host = createHost(); host.tab = "channels"; const schema = createDeferred(); - const channelProbe = createDeferred(); mocks.loadConfigSchemaMock.mockReturnValueOnce(schema.promise); - mocks.loadChannelsMock.mockImplementation(async (_host, probe) => { - if (probe) { - await channelProbe.promise; - } - }); const refresh = refreshActiveTab(host as never); const outcome = await raceWithNextMacrotask(refresh); expect(outcome).toBe("resolved"); - expect(mocks.loadChannelsMock.mock.calls.map(([, probe]) => probe)).toEqual([false, true]); + expect(mocks.loadChannelsMock.mock.calls.map(([, probe]) => probe)).toEqual([false]); expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); expect(host.requestUpdate).not.toHaveBeenCalled(); schema.resolve(); - channelProbe.resolve(); await vi.waitFor(() => { - expect(host.requestUpdate).toHaveBeenCalledTimes(2); + expect(host.requestUpdate).toHaveBeenCalledOnce(); }); }); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index e6b4ef1f5c1..894e7878e59 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -3,7 +3,9 @@ import { t } from "../i18n/index.ts"; import { refreshChat } from "./app-chat.ts"; import { startLogsPolling, + startNodesPolling, stopLogsPolling, + stopNodesPolling, startDebugPolling, stopDebugPolling, } from "./app-polling.ts"; @@ -106,6 +108,7 @@ type SettingsHost = { controlUiTabPaintSeq?: number; controlUiOverviewRefreshSeq?: number; controlUiCronRefreshSeq?: number; + sessionsChangedReloadTimer?: number | ReturnType | null; dreamingStatusLoading: boolean; dreamingStatusError: string | null; dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null; @@ -549,6 +552,14 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) { applyTabSelection(host, next, { refreshPolicy: "connected" }); } +function clearPendingSessionsChangedReload(host: SettingsHost) { + if (host.sessionsChangedReloadTimer == null) { + return; + } + globalThis.clearTimeout(host.sessionsChangedReloadTimer); + host.sessionsChangedReloadTimer = null; +} + function updateBrowserHistory(url: URL, replace: boolean) { const history = typeof window === "undefined" ? undefined : window.history; if (!history) { @@ -569,6 +580,7 @@ function applyTabSelection( host.tab = next; if (prev !== next) { scheduleControlUiTabVisibleTiming(host, prev, next); + clearPendingSessionsChangedReload(host); } // Cleanup chat module state when navigating away from chat @@ -582,6 +594,9 @@ function applyTabSelection( (next === "logs" ? startLogsPolling : stopLogsPolling)( host as unknown as Parameters[0], ); + (next === "nodes" ? startNodesPolling : stopNodesPolling)( + host as unknown as Parameters[0], + ); (next === "debug" ? startDebugPolling : stopDebugPolling)( host as unknown as Parameters[0], ); @@ -839,7 +854,6 @@ export async function loadChannelsTab(host: SettingsHost) { const app = host as unknown as SettingsAppHost; void loadConfigSchema(app).finally(() => host.requestUpdate?.()); await Promise.all([loadChannels(app, false), loadConfig(app)]); - void loadChannels(app, true).finally(() => host.requestUpdate?.()); } export async function loadCron(host: SettingsHost) { diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 34131d6afdf..0c9d33d453c 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -598,6 +598,7 @@ export class OpenClawApp extends LitElement { nodesPollInterval: number | null = null; logsPollInterval: number | null = null; debugPollInterval: number | null = null; + sessionsChangedReloadTimer: number | ReturnType | null = null; logsScrollFrame: number | null = null; controlUiResponsivenessObserver: { disconnect: () => void } | null = null; toolStreamById = new Map(); diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index b58a0eefa72..b890097ed15 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -1,5 +1,6 @@ import { html } from "lit"; import { repeat } from "lit/directives/repeat.js"; +import { CHAT_SESSIONS_ACTIVE_MINUTES, CHAT_SESSIONS_REFRESH_LIMIT } from "../app-chat.ts"; import type { AppViewState } from "../app-view-state.ts"; import { createChatModelOverride } from "../chat-model-ref.ts"; import { @@ -138,8 +139,8 @@ function renderChatAgentSelect( async function refreshSessionOptions(state: AppViewState) { await loadSessions(state as unknown as Parameters[0], { - activeMinutes: 0, - limit: 0, + activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, + limit: CHAT_SESSIONS_REFRESH_LIMIT, includeGlobal: true, includeUnknown: true, showArchived: state.sessionsShowArchived, diff --git a/ui/src/ui/controllers/channels.test.ts b/ui/src/ui/controllers/channels.test.ts index 25012353eda..506538120cf 100644 --- a/ui/src/ui/controllers/channels.test.ts +++ b/ui/src/ui/controllers/channels.test.ts @@ -15,6 +15,17 @@ function createDeferred() { return { promise, resolve, reject }; } +function createChannelsSnapshot(label: string): ChannelsStatusSnapshot { + return { + ts: Date.now(), + channelOrder: ["test"], + channelLabels: { test: label }, + channels: {}, + channelAccounts: {}, + channelDefaultAccountId: {}, + }; +} + function createState(): ChannelsState { return { client: { @@ -70,6 +81,34 @@ describe("channels controller WhatsApp wait", () => { }); describe("loadChannels", () => { + it("keeps a stale slow probe from replacing a newer non-probe snapshot", async () => { + const state = createState(); + const request = vi.mocked(state.client!.request); + const slowProbe = createDeferred(); + const fastRuntime = createDeferred(); + request.mockImplementation(async (_method: string, params?: unknown) => { + if ((params as { probe?: boolean } | undefined)?.probe) { + return slowProbe.promise; + } + return fastRuntime.promise; + }); + + const probeLoad = loadChannels(state, true, { softTimeoutMs: 1 }); + await probeLoad; + const runtimeLoad = loadChannels(state, false); + expect(request).toHaveBeenCalledTimes(2); + + fastRuntime.resolve(createChannelsSnapshot("fresh")); + await runtimeLoad; + expect(state.channelsSnapshot?.channelLabels.test).toBe("fresh"); + + slowProbe.resolve(createChannelsSnapshot("stale")); + await Promise.resolve(); + + expect(state.channelsSnapshot?.channelLabels.test).toBe("fresh"); + expect(state.channelsLoading).toBe(false); + }); + it("returns after a soft timeout while preserving the stale snapshot", async () => { vi.useFakeTimers(); try { diff --git a/ui/src/ui/controllers/channels.ts b/ui/src/ui/controllers/channels.ts index c80267bd925..a36a7a89a8e 100644 --- a/ui/src/ui/controllers/channels.ts +++ b/ui/src/ui/controllers/channels.ts @@ -23,10 +23,13 @@ export async function loadChannels( if (!state.client || !state.connected) { return; } - if (state.channelsLoading) { + if (state.channelsLoading && (!state.channelsLoadingProbe || probe)) { return; } + const refreshSeq = (state.channelsRefreshSeq ?? 0) + 1; + state.channelsRefreshSeq = refreshSeq; state.channelsLoading = true; + state.channelsLoadingProbe = probe; state.channelsError = null; const refresh = (async () => { try { @@ -34,9 +37,15 @@ export async function loadChannels( probe, timeoutMs: 8000, }); + if (state.channelsRefreshSeq !== refreshSeq) { + return; + } state.channelsSnapshot = res; state.channelsLastSuccess = Date.now(); } catch (err) { + if (state.channelsRefreshSeq !== refreshSeq) { + return; + } if (isMissingOperatorReadScopeError(err)) { state.channelsSnapshot = null; state.channelsError = formatMissingOperatorReadScopeMessage("channel status"); @@ -44,7 +53,10 @@ export async function loadChannels( state.channelsError = String(err); } } finally { - state.channelsLoading = false; + if (state.channelsRefreshSeq === refreshSeq) { + state.channelsLoading = false; + state.channelsLoadingProbe = null; + } } })(); diff --git a/ui/src/ui/controllers/channels.types.ts b/ui/src/ui/controllers/channels.types.ts index 4fb8e6bc510..a0306b893dc 100644 --- a/ui/src/ui/controllers/channels.types.ts +++ b/ui/src/ui/controllers/channels.types.ts @@ -5,6 +5,8 @@ export type ChannelsState = { client: GatewayBrowserClient | null; connected: boolean; channelsLoading: boolean; + channelsLoadingProbe?: boolean | null; + channelsRefreshSeq?: number; channelsSnapshot: ChannelsStatusSnapshot | null; channelsError: string | null; channelsLastSuccess: number | null; diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index e061af0f532..caec39a5984 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -401,6 +401,43 @@ describe("config view", () => { expect(content.scrollLeft).toBe(0); }); + it("does not normalize off-scope schema sections for scoped config tabs", () => { + const offScopeSchema = { type: "object" } as Record; + Object.defineProperty(offScopeSchema, "properties", { + get() { + throw new Error("off-scope schema was normalized"); + }, + }); + + const { container } = renderConfigView({ + activeSection: "channels", + navRootLabel: "Communication", + includeSections: ["channels"], + schema: { + type: "object", + properties: { + channels: { + type: "object", + properties: { + telegram: { type: "string", title: "Telegram" }, + }, + }, + models: offScopeSchema, + }, + }, + formValue: { + channels: { telegram: "enabled" }, + models: {}, + }, + originalValue: { + channels: { telegram: "enabled" }, + models: {}, + }, + }); + + expect(normalizedText(container)).toContain("Telegram"); + }); + it("renders and wires the search field controls", () => { const container = document.createElement("div"); const onSearchChange = vi.fn(); diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 14a19577c24..bc5f3c6ca2f 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -478,40 +478,23 @@ function scopeSchemaSections( const include = params.include; const exclude = params.exclude; const nextProps: Record = {}; - for (const [key, value] of Object.entries(schema.properties)) { + for (const key of Object.keys(schema.properties)) { if (include && include.size > 0 && !include.has(key)) { continue; } if (exclude && exclude.size > 0 && exclude.has(key)) { continue; } - nextProps[key] = value; + nextProps[key] = schema.properties[key]; } return { ...schema, properties: nextProps }; } -function scopeUnsupportedPaths( - unsupportedPaths: string[], - params: { include?: ReadonlySet | null; exclude?: ReadonlySet | null }, -): string[] { - const include = params.include; - const exclude = params.exclude; - if ((!include || include.size === 0) && (!exclude || exclude.size === 0)) { - return unsupportedPaths; +function asConfigSchema(value: unknown): JsonSchema | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; } - return unsupportedPaths.filter((entry) => { - if (entry === "") { - return true; - } - const [top] = entry.split("."); - if (include && include.size > 0) { - return include.has(top); - } - if (exclude && exclude.size > 0) { - return !exclude.has(top); - } - return true; - }); + return value as JsonSchema; } function resolveSectionMeta( @@ -1177,11 +1160,8 @@ export function renderConfig(props: ConfigProps) { const includeVirtualSections = props.includeVirtualSections ?? true; const include = props.includeSections?.length ? new Set(props.includeSections) : null; const exclude = props.excludeSections?.length ? new Set(props.excludeSections) : null; - const rawAnalysis = analyzeConfigSchema(props.schema); - const analysis = { - schema: scopeSchemaSections(rawAnalysis.schema, { include, exclude }), - unsupportedPaths: scopeUnsupportedPaths(rawAnalysis.unsupportedPaths, { include, exclude }), - }; + const scopedSchema = scopeSchemaSections(asConfigSchema(props.schema), { include, exclude }); + const analysis = analyzeConfigSchema(scopedSchema); const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false; const rawAvailable = props.rawAvailable ?? true; const formMode = showModeToggle && rawAvailable ? props.formMode : "form";