Skip to content

Commit 60171e8

Browse files
committed
Keep Control UI responsive under slow status and history loads
1 parent 3f6b481 commit 60171e8

18 files changed

Lines changed: 562 additions & 157 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ Docs: https://docs.openclaw.ai
101101

102102
### Fixes
103103

104+
- Control UI/performance: keep chat and channel tabs responsive while history payloads and channel probes are slow, label partial channel status, and record slow chat/config render timings in the event log. Thanks @BunsDev.
104105
- Control UI/sessions: fire the documented `/new` command and lifecycle hooks only for explicit Control UI session creation, restoring session-memory and custom hook capture without changing SDK parent-session creates. Fixes #76957. Thanks @BunsDev.
105106
- Slack: preserve Socket Mode SDK error context and structured Slack API fields in reconnect logs, so startup failures no longer collapse to a bare `unknown error`.
106107
- iOS pairing: allow setup-code and manual `ws://` connects for private LAN and `.local` gateways while keeping Tailscale/public routes on `wss://`, and prefer explicit gateway passwords over stale bootstrap tokens in mixed-auth reconnects. Fixes #47887; carries forward #65185. Thanks @draix and @BunsDev.

docs/web/control-ui.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,14 @@ Imported themes are stored only in the current browser profile. They are not wri
9696
<AccordionGroup>
9797
<Accordion title="Chat and Talk">
9898
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`).
99+
- Chat history refreshes request a bounded recent window with per-message text caps so large sessions do not force the browser to render a full transcript payload before the chat becomes usable.
99100
- Talk through browser realtime sessions. OpenAI uses direct WebRTC, Google Live uses a constrained one-use browser token over WebSocket, and backend-only realtime voice plugins use the Gateway relay transport. Client-owned provider sessions start with `talk.client.create`; Gateway relay sessions start with `talk.session.create`. The relay keeps provider credentials on the Gateway while the browser streams microphone PCM through `talk.session.appendAudio` and forwards `openclaw_agent_consult` provider tool calls through `talk.client.toolCall` for Gateway policy and the larger configured OpenClaw model.
100101
- Stream tool calls + live tool output cards in Chat (agent events).
101102

102103
</Accordion>
103104
<Accordion title="Channels, instances, sessions, dreams">
104105
- Channels: built-in plus bundled/external plugin channels status, QR login, and per-channel config (`channels.status`, `web.login.*`, `config.patch`).
106+
- Channel probe refreshes keep the previous snapshot visible while slow provider checks finish, and partial snapshots are labeled when a probe or audit exceeds its UI budget.
105107
- Instances: presence list + refresh (`system-presence`).
106108
- Sessions: list + per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`).
107109
- Dreams: dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`).
@@ -127,7 +129,7 @@ Imported themes are stored only in the current browser profile. They are not wri
127129
</Accordion>
128130
<Accordion title="Debug, logs, update">
129131
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`).
130-
- The event log includes Control UI refresh/RPC timings plus browser responsiveness entries for long animation frames or long tasks when the browser exposes those PerformanceObserver entry types.
132+
- The event log includes Control UI refresh/RPC timings, slow chat/config render timings, and browser responsiveness entries for long animation frames or long tasks when the browser exposes those PerformanceObserver entry types.
131133
- Logs: live tail of gateway file logs with filter/export (`logs.tail`).
132134
- Update: run a package/git update + restart (`update.run`) with a restart report, then poll `update.status` after reconnect to verify the running gateway version.
133135

src/gateway/protocol/channels.schema.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ describe("ChannelsStatusResultSchema", () => {
5050
],
5151
},
5252
channelDefaultAccountId: { discord: "default" },
53+
partial: true,
54+
warnings: ["discord:default probe timed out after 1000ms"],
5355
eventLoop: {
5456
degraded: true,
5557
reasons: ["event_loop_delay", "cpu"],

src/gateway/protocol/schema/channels.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,8 @@ export const ChannelsStatusResultSchema = Type.Object(
644644
channelAccounts: Type.Record(NonEmptyString, Type.Array(ChannelAccountSnapshotSchema)),
645645
channelDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString),
646646
eventLoop: Type.Optional(ChannelEventLoopHealthSchema),
647+
partial: Type.Optional(Type.Boolean()),
648+
warnings: Type.Optional(Type.Array(Type.String())),
647649
},
648650
{ additionalProperties: false },
649651
);

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,54 @@ describe("channelsHandlers channels.status", () => {
164164
);
165165
});
166166

167+
it("returns a partial snapshot when a channel probe exceeds the status budget", async () => {
168+
vi.useFakeTimers();
169+
try {
170+
const autoEnabledConfig = { autoEnabled: true };
171+
const probeAccount = vi.fn(() => new Promise(() => undefined));
172+
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
173+
mocks.listChannelPlugins.mockReturnValue([
174+
{
175+
id: "whatsapp",
176+
config: {
177+
listAccountIds: () => ["default"],
178+
resolveAccount: () => ({}),
179+
isEnabled: () => true,
180+
isConfigured: async () => true,
181+
},
182+
status: {
183+
probeAccount,
184+
},
185+
},
186+
]);
187+
const respond = vi.fn();
188+
const run = channelsHandlers["channels.status"](
189+
createOptions({ probe: true, timeoutMs: 1000 }, { respond }),
190+
);
191+
192+
await vi.advanceTimersByTimeAsync(1000);
193+
await run;
194+
195+
expect(mocks.buildChannelAccountSnapshot).toHaveBeenCalledWith(
196+
expect.objectContaining({
197+
probe: expect.objectContaining({
198+
timedOut: true,
199+
}),
200+
}),
201+
);
202+
expect(respond).toHaveBeenCalledWith(
203+
true,
204+
expect.objectContaining({
205+
partial: true,
206+
warnings: [expect.stringContaining("whatsapp:default probe timed out after 1000ms")],
207+
}),
208+
undefined,
209+
);
210+
} finally {
211+
vi.useRealTimers();
212+
}
213+
});
214+
167215
it("annotates unhealthy channel snapshots and includes event-loop health", async () => {
168216
const now = Date.now();
169217
mocks.applyPluginAutoEnable.mockReturnValue({ config: { autoEnabled: true }, changes: [] });

src/gateway/server-methods/channels.ts

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,58 @@ type ChannelStopPayload = {
5757
const CHANNEL_STATUS_MAX_TIMEOUT_MS = 30_000;
5858
const CHANNEL_STATUS_PROBE_CONCURRENCY = 5;
5959

60+
function channelStatusTimeoutPayload(step: string, timeoutMs: number): Record<string, unknown> {
61+
return {
62+
ok: false,
63+
timedOut: true,
64+
error: `${step} timed out after ${timeoutMs}ms`,
65+
};
66+
}
67+
68+
async function runChannelStatusHook(params: {
69+
accountId: string;
70+
channelId: ChannelId;
71+
step: "audit" | "probe";
72+
timeoutMs: number;
73+
warnings: string[];
74+
run: () => Promise<unknown>;
75+
}): Promise<unknown> {
76+
const timeoutMs = Math.max(1, params.timeoutMs);
77+
let timer: ReturnType<typeof setTimeout> | null = null;
78+
const timeout = new Promise<{ kind: "timeout" }>((resolve) => {
79+
timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
80+
if (typeof timer === "object" && "unref" in timer) {
81+
timer.unref();
82+
}
83+
});
84+
const result = await Promise.race([
85+
Promise.resolve()
86+
.then(params.run)
87+
.then(
88+
(value) => ({ kind: "value" as const, value }),
89+
(error) => ({ kind: "error" as const, error }),
90+
),
91+
timeout,
92+
]);
93+
if (timer) {
94+
clearTimeout(timer);
95+
}
96+
if (result.kind === "value") {
97+
return result.value;
98+
}
99+
const warningPrefix = `${params.channelId}:${params.accountId} ${params.step}`;
100+
if (result.kind === "timeout") {
101+
params.warnings.push(`${warningPrefix} timed out after ${timeoutMs}ms`);
102+
return channelStatusTimeoutPayload(params.step, timeoutMs);
103+
}
104+
const message = formatForLog(result.error);
105+
params.warnings.push(`${warningPrefix} failed: ${message}`);
106+
return {
107+
ok: false,
108+
error: message,
109+
};
110+
}
111+
60112
function resolveChannelsStatusTimeoutMs(params: { probe: boolean; timeoutMsRaw: unknown }): number {
61113
const fallback = params.probe ? CHANNEL_STATUS_MAX_TIMEOUT_MS : 10_000;
62114
if (typeof params.timeoutMsRaw !== "number" || !Number.isFinite(params.timeoutMsRaw)) {
@@ -198,6 +250,7 @@ export const channelsHandlers: GatewayRequestHandlers = {
198250
const pluginMap = new Map<ChannelId, ChannelPlugin>(
199251
plugins.map((plugin) => [plugin.id, plugin]),
200252
);
253+
const statusWarnings: string[] = [];
201254

202255
const resolveRuntimeSnapshot = (
203256
channelId: ChannelId,
@@ -237,10 +290,18 @@ export const channelsHandlers: GatewayRequestHandlers = {
237290
configured = await plugin.config.isConfigured(account, cfg);
238291
}
239292
if (configured) {
240-
probeResult = await plugin.status.probeAccount({
241-
account,
293+
probeResult = await runChannelStatusHook({
294+
channelId,
295+
accountId,
296+
step: "probe",
242297
timeoutMs,
243-
cfg,
298+
warnings: statusWarnings,
299+
run: () =>
300+
plugin.status!.probeAccount!({
301+
account,
302+
timeoutMs,
303+
cfg,
304+
}),
244305
});
245306
lastProbeAt = Date.now();
246307
}
@@ -252,11 +313,19 @@ export const channelsHandlers: GatewayRequestHandlers = {
252313
configured = await plugin.config.isConfigured(account, cfg);
253314
}
254315
if (configured) {
255-
auditResult = await plugin.status.auditAccount({
256-
account,
316+
auditResult = await runChannelStatusHook({
317+
channelId,
318+
accountId,
319+
step: "audit",
257320
timeoutMs,
258-
cfg,
259-
probe: probeResult,
321+
warnings: statusWarnings,
322+
run: () =>
323+
plugin.status!.auditAccount!({
324+
account,
325+
timeoutMs,
326+
cfg,
327+
probe: probeResult,
328+
}),
260329
});
261330
}
262331
}
@@ -377,6 +446,10 @@ export const channelsHandlers: GatewayRequestHandlers = {
377446
defaultAccountIdMap[result.pluginId] = result.defaultAccountId;
378447
}
379448
}
449+
if (statusWarnings.length > 0) {
450+
payload.partial = true;
451+
payload.warnings = statusWarnings.slice(0, 50);
452+
}
380453

381454
respond(true, payload, undefined);
382455
},

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ let handleSendChat: typeof import("./app-chat.ts").handleSendChat;
4141
let steerQueuedChatMessage: typeof import("./app-chat.ts").steerQueuedChatMessage;
4242
let navigateChatInputHistory: typeof import("./app-chat.ts").navigateChatInputHistory;
4343
let handleAbortChat: typeof import("./app-chat.ts").handleAbortChat;
44+
let refreshChat: typeof import("./app-chat.ts").refreshChat;
4445
let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar;
4546
let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun;
4647
let removeQueuedMessage: typeof import("./app-chat.ts").removeQueuedMessage;
@@ -51,6 +52,7 @@ async function loadChatHelpers(): Promise<void> {
5152
steerQueuedChatMessage,
5253
navigateChatInputHistory,
5354
handleAbortChat,
55+
refreshChat,
5456
refreshChatAvatar,
5557
clearPendingQueueItemsForRun,
5658
removeQueuedMessage,
@@ -433,6 +435,55 @@ describe("refreshChatAvatar", () => {
433435
});
434436
});
435437

438+
describe("refreshChat", () => {
439+
beforeAll(async () => {
440+
await loadChatHelpers();
441+
});
442+
443+
it("does not wait for secondary chat metadata refreshes before showing history", async () => {
444+
const previousFetch = globalThis.fetch;
445+
globalThis.fetch = vi.fn(() => new Promise<Response>(() => undefined)) as never;
446+
try {
447+
const request = vi.fn((method: string) => {
448+
if (method === "chat.history") {
449+
return Promise.resolve({
450+
messages: [{ role: "assistant", content: [{ type: "text", text: "ready" }] }],
451+
});
452+
}
453+
return new Promise(() => undefined);
454+
});
455+
const host = makeHost({
456+
client: { request } as unknown as ChatHost["client"],
457+
sessionKey: "main",
458+
});
459+
460+
const outcome = await Promise.race([
461+
refreshChat(host).then(() => "resolved" as const),
462+
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 20)),
463+
]);
464+
465+
expect(outcome).toBe("resolved");
466+
expect(host.chatMessages).toEqual([
467+
{ role: "assistant", content: [{ type: "text", text: "ready" }] },
468+
]);
469+
expect(request).toHaveBeenCalledWith(
470+
"sessions.list",
471+
expect.objectContaining({
472+
includeGlobal: true,
473+
includeUnknown: true,
474+
}),
475+
);
476+
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
477+
expect(request).toHaveBeenCalledWith(
478+
"commands.list",
479+
expect.objectContaining({ includeArgs: true, scope: "text" }),
480+
);
481+
} finally {
482+
globalThis.fetch = previousFetch;
483+
}
484+
});
485+
});
486+
436487
describe("handleSendChat", () => {
437488
beforeAll(async () => {
438489
await loadChatHelpers();

ui/src/ui/app-chat.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -666,8 +666,7 @@ function injectCommandResult(host: ChatHost, content: string) {
666666
}
667667

668668
export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) {
669-
await Promise.all([
670-
loadChatHistory(host as unknown as ChatState),
669+
void Promise.allSettled([
671670
loadSessions(host as unknown as SessionsState, {
672671
activeMinutes: 0,
673672
limit: 0,
@@ -678,6 +677,7 @@ export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: bool
678677
refreshChatModels(host),
679678
refreshChatCommands(host),
680679
]);
680+
await loadChatHistory(host as unknown as ChatState);
681681
if (opts?.scheduleScroll !== false) {
682682
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
683683
}

0 commit comments

Comments
 (0)