Skip to content

Commit 6b3cd90

Browse files
authored
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 <changed files> - git diff --check origin/main...HEAD - node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.core.json <changed TypeScript files> - 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.
1 parent 678c2c0 commit 6b3cd90

26 files changed

Lines changed: 654 additions & 94 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
5959

6060
### Fixes
6161

62+
- 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.
6263
- 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.
6364
- 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.
6465
- 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
396397
- 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.
397398
- 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.
398399
- Gateway/performance: avoid resolving plugin auto-enable metadata twice in one runtime config pass, reducing repeated dashboard turn metadata scans. Thanks @shakkernerd.
400+
- 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.
399401
- 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.
400402
- 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.
401403
- 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.

src/gateway/server-methods/channels.status.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ vi.mock("../../infra/channel-activity.js", () => ({
4444

4545
import { channelsHandlers } from "./channels.js";
4646

47+
function getSuccessPayload(respond: ReturnType<typeof vi.fn>): Record<string, unknown> {
48+
expect(respond).toHaveBeenCalledWith(true, expect.any(Object), undefined);
49+
return respond.mock.calls[0]?.[1] as Record<string, unknown>;
50+
}
51+
4752
function createOptions(
4853
params: Record<string, unknown>,
4954
overrides?: Partial<GatewayRequestHandlerOptions>,
@@ -172,6 +177,55 @@ describe("channelsHandlers channels.status", () => {
172177
expect(probeArgs.cfg).toBe(autoEnabledConfig);
173178
});
174179

180+
it("preserves channel account rows when a live probe throws", async () => {
181+
const autoEnabledConfig = { autoEnabled: true };
182+
const probeAccount = vi.fn(async () => {
183+
throw new Error("probe failed");
184+
});
185+
const respond = vi.fn();
186+
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
187+
mocks.buildChannelAccountSnapshot.mockImplementation(async ({ accountId, probe }) => ({
188+
accountId,
189+
configured: true,
190+
probe,
191+
}));
192+
mocks.listChannelPlugins.mockReturnValue([
193+
{
194+
id: "whatsapp",
195+
config: {
196+
listAccountIds: () => ["default"],
197+
resolveAccount: () => ({}),
198+
isEnabled: () => true,
199+
isConfigured: async () => true,
200+
},
201+
status: {
202+
probeAccount,
203+
},
204+
},
205+
]);
206+
207+
await channelsHandlers["channels.status"](
208+
createOptions({ probe: true, timeoutMs: 1000 }, { respond }),
209+
);
210+
211+
const payload = getSuccessPayload(respond);
212+
const channelAccounts = payload.channelAccounts as Record<
213+
string,
214+
Array<Record<string, unknown>>
215+
>;
216+
expect(channelAccounts.whatsapp).toEqual([
217+
expect.objectContaining({
218+
accountId: "default",
219+
lastError: expect.stringContaining("probe failed"),
220+
lastProbeAt: expect.any(Number),
221+
probe: expect.objectContaining({
222+
ok: false,
223+
error: expect.stringContaining("probe failed"),
224+
}),
225+
}),
226+
]);
227+
});
228+
175229
it("returns a partial snapshot when a channel probe exceeds the status budget", async () => {
176230
vi.useFakeTimers();
177231
try {
@@ -211,6 +265,47 @@ describe("channelsHandlers channels.status", () => {
211265
}
212266
});
213267

268+
it("falls back to account-derived channel summaries when summary building fails", async () => {
269+
const autoEnabledConfig = { autoEnabled: true };
270+
const respond = vi.fn();
271+
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
272+
mocks.buildChannelAccountSnapshot.mockResolvedValue({
273+
accountId: "default",
274+
configured: true,
275+
});
276+
mocks.listChannelPlugins.mockReturnValue([
277+
{
278+
id: "whatsapp",
279+
config: {
280+
listAccountIds: () => ["default"],
281+
resolveAccount: () => ({}),
282+
isEnabled: () => true,
283+
isConfigured: async () => true,
284+
},
285+
status: {
286+
buildChannelSummary: async () => {
287+
throw new Error("summary failed");
288+
},
289+
},
290+
},
291+
]);
292+
293+
await channelsHandlers["channels.status"](
294+
createOptions({ probe: false, timeoutMs: 1000 }, { respond }),
295+
);
296+
297+
const payload = getSuccessPayload(respond);
298+
expect(payload.channels).toEqual({
299+
whatsapp: expect.objectContaining({
300+
configured: true,
301+
lastError: expect.stringContaining("summary failed"),
302+
}),
303+
});
304+
expect(payload.channelAccounts).toEqual({
305+
whatsapp: [expect.objectContaining({ accountId: "default", configured: true })],
306+
});
307+
});
308+
214309
it("annotates unhealthy channel snapshots and includes event-loop health", async () => {
215310
const now = Date.now();
216311
mocks.applyPluginAutoEnable.mockReturnValue({ config: { autoEnabled: true }, changes: [] });

src/gateway/server-methods/channels.ts

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,16 @@ function channelStatusTimeoutPayload(step: string, timeoutMs: number): Record<st
6565
};
6666
}
6767

68-
async function runChannelStatusHook(params: {
69-
accountId: string;
70-
channelId: ChannelId;
71-
step: "audit" | "probe";
68+
type TimeoutRaceResult<T> =
69+
| { kind: "value"; value: T }
70+
| { kind: "error"; error: unknown }
71+
| { kind: "timeout" };
72+
73+
async function raceWithTimeout<T>(params: {
7274
timeoutMs: number;
73-
warnings: string[];
74-
run: () => Promise<unknown>;
75-
}): Promise<unknown> {
76-
const timeoutMs = Math.max(1, params.timeoutMs);
75+
run: () => Promise<T> | T;
76+
}): Promise<TimeoutRaceResult<T>> {
77+
const timeoutMs = params.timeoutMs;
7778
let timer: ReturnType<typeof setTimeout> | null = null;
7879
const timeout = new Promise<{ kind: "timeout" }>((resolve) => {
7980
timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
@@ -93,6 +94,22 @@ async function runChannelStatusHook(params: {
9394
if (timer) {
9495
clearTimeout(timer);
9596
}
97+
return result;
98+
}
99+
100+
async function runChannelStatusHook(params: {
101+
accountId: string;
102+
channelId: ChannelId;
103+
step: "audit" | "probe";
104+
timeoutMs: number;
105+
warnings: string[];
106+
run: () => Promise<unknown>;
107+
}): Promise<unknown> {
108+
const timeoutMs = Math.max(1, params.timeoutMs);
109+
const result = await raceWithTimeout({
110+
timeoutMs,
111+
run: params.run,
112+
});
96113
if (result.kind === "value") {
97114
return result.value;
98115
}
@@ -109,6 +126,46 @@ async function runChannelStatusHook(params: {
109126
};
110127
}
111128

129+
type ChannelStatusSummaryOutcome =
130+
| { ok: true; value: unknown }
131+
| { ok: false; error: string; timedOut?: boolean };
132+
133+
async function runChannelStatusSummary(params: {
134+
channelId: ChannelId;
135+
timeoutMs: number;
136+
warnings: string[];
137+
run: () => unknown;
138+
}): Promise<ChannelStatusSummaryOutcome> {
139+
const timeoutMs = Math.max(1, params.timeoutMs);
140+
const result = await raceWithTimeout({
141+
timeoutMs,
142+
run: params.run,
143+
});
144+
const warningPrefix = `${params.channelId} summary`;
145+
if (result.kind === "value") {
146+
return { ok: true, value: result.value };
147+
}
148+
if (result.kind === "timeout") {
149+
const error = `summary timed out after ${timeoutMs}ms`;
150+
params.warnings.push(`${warningPrefix} timed out after ${timeoutMs}ms`);
151+
return { ok: false, timedOut: true, error };
152+
}
153+
const message = formatForLog(result.error);
154+
params.warnings.push(`${warningPrefix} failed: ${message}`);
155+
return { ok: false, error: message };
156+
}
157+
158+
function channelStatusFailureMessage(value: unknown): string | null {
159+
if (!value || typeof value !== "object") {
160+
return null;
161+
}
162+
const record = value as Record<string, unknown>;
163+
if (record.ok !== false || typeof record.error !== "string" || record.error.length === 0) {
164+
return null;
165+
}
166+
return record.error;
167+
}
168+
112169
function resolveChannelsStatusTimeoutMs(params: { probe: boolean; timeoutMsRaw: unknown }): number {
113170
const fallback = params.probe ? CHANNEL_STATUS_MAX_TIMEOUT_MS : 10_000;
114171
if (typeof params.timeoutMsRaw !== "number" || !Number.isFinite(params.timeoutMsRaw)) {
@@ -338,6 +395,11 @@ export const channelsHandlers: GatewayRequestHandlers = {
338395
probe: probeResult,
339396
audit: auditResult,
340397
});
398+
const hookError =
399+
channelStatusFailureMessage(auditResult) ?? channelStatusFailureMessage(probeResult);
400+
if (hookError && !snapshot.lastError) {
401+
snapshot.lastError = hookError;
402+
}
341403
if (lastProbeAt) {
342404
snapshot.lastProbeAt = lastProbeAt;
343405
}
@@ -421,20 +483,30 @@ export const channelsHandlers: GatewayRequestHandlers = {
421483
await buildChannelAccounts(plugin.id);
422484
const fallbackAccount =
423485
resolvedAccounts[defaultAccountId] ?? plugin.config.resolveAccount(cfg, defaultAccountId);
424-
const summary = plugin.status?.buildChannelSummary
425-
? await plugin.status.buildChannelSummary({
426-
account: fallbackAccount,
427-
cfg,
428-
defaultAccountId,
429-
snapshot:
430-
defaultAccount ??
431-
({
432-
accountId: defaultAccountId,
433-
} as ChannelAccountSnapshot),
434-
})
435-
: {
436-
configured: defaultAccount?.configured ?? false,
437-
};
486+
const fallbackSummary = (lastError?: string) => ({
487+
configured: defaultAccount?.configured ?? false,
488+
...(lastError ? { lastError } : {}),
489+
});
490+
let summary: unknown = fallbackSummary();
491+
if (plugin.status?.buildChannelSummary) {
492+
const summaryResult = await runChannelStatusSummary({
493+
channelId: plugin.id,
494+
timeoutMs,
495+
warnings: statusWarnings,
496+
run: () =>
497+
plugin.status!.buildChannelSummary!({
498+
account: fallbackAccount,
499+
cfg,
500+
defaultAccountId,
501+
snapshot:
502+
defaultAccount ??
503+
({
504+
accountId: defaultAccountId,
505+
} as ChannelAccountSnapshot),
506+
}),
507+
});
508+
summary = summaryResult.ok ? summaryResult.value : fallbackSummary(summaryResult.error);
509+
}
438510
return { pluginId: plugin.id, summary, accounts, defaultAccountId };
439511
}),
440512
limit: probe ? CHANNEL_STATUS_PROBE_CONCURRENCY : plugins.length || 1,

ui/src/ui/app-chat.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,9 +576,11 @@ describe("refreshChat", () => {
576576
"sessions.list",
577577
"sessions list payload",
578578
);
579+
expect(sessionsListPayload.activeMinutes).toBe(120);
579580
expect(sessionsListPayload.agentId).toBe("main");
580581
expect(sessionsListPayload.includeGlobal).toBe(true);
581582
expect(sessionsListPayload.includeUnknown).toBe(true);
583+
expect(sessionsListPayload.limit).toBe(100);
582584
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
583585
const commandsListPayload = findRequestPayload(
584586
request as unknown as MockCallSource,

ui/src/ui/app-chat.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export type ChatAbortOptions = {
8484
};
8585

8686
export const CHAT_SESSIONS_ACTIVE_MINUTES = 120;
87+
export const CHAT_SESSIONS_REFRESH_LIMIT = 100;
8788
export {
8889
handleChatDraftChange,
8990
handleChatInputHistoryKey,
@@ -768,8 +769,8 @@ export async function refreshChat(
768769
});
769770
const secondaryRefresh = Promise.allSettled([
770771
loadSessions(host as unknown as SessionsState, {
771-
activeMinutes: 0,
772-
limit: 0,
772+
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
773+
limit: CHAT_SESSIONS_REFRESH_LIMIT,
773774
includeGlobal: true,
774775
includeUnknown: true,
775776
agentId: resolveAgentIdForSession(host) ?? undefined,

ui/src/ui/app-gateway-chat-load.node.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ vi.mock("./gateway.ts", async (importOriginal) => {
5858

5959
vi.mock("./app-chat.ts", () => ({
6060
CHAT_SESSIONS_ACTIVE_MINUTES: 60,
61+
CHAT_SESSIONS_REFRESH_LIMIT: 100,
6162
clearPendingQueueItemsForRun: vi.fn(),
6263
flushChatQueueForEvent: vi.fn(),
6364
refreshChatAvatar: refreshChatAvatarMock,
@@ -217,4 +218,14 @@ describe("connectGateway chat load startup work", () => {
217218
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
218219
expect(refreshChatAvatarMock).toHaveBeenCalledWith(host);
219220
});
221+
222+
it("lets the active tab refresh own node and device loading after hello", async () => {
223+
const { host, client } = connectHost("overview");
224+
225+
client.emitHello();
226+
227+
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
228+
expect(loadNodesMock).not.toHaveBeenCalled();
229+
expect(loadDevicesMock).not.toHaveBeenCalled();
230+
});
220231
});

ui/src/ui/app-gateway.node.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,23 @@ describe("connectGateway", () => {
450450
]);
451451
});
452452

453+
it("clears pending session reload timers when the active client closes", () => {
454+
vi.useFakeTimers();
455+
try {
456+
const { host, client } = connectHostGateway();
457+
const pendingReload = vi.fn();
458+
host.sessionsChangedReloadTimer = globalThis.setTimeout(pendingReload, 1_000);
459+
460+
client.emitClose({ code: 1005 });
461+
462+
expect(host.sessionsChangedReloadTimer).toBeNull();
463+
vi.advanceTimersByTime(1_000);
464+
expect(pendingReload).not.toHaveBeenCalled();
465+
} finally {
466+
vi.useRealTimers();
467+
}
468+
});
469+
453470
it("preserves pending approval requests across reconnect", () => {
454471
const host = createHost();
455472
host.execApprovalQueue = [

0 commit comments

Comments
 (0)