Skip to content

Commit 5ae385b

Browse files
authored
fix(ui): keep control ui refresh responsive
Summary: - Keep Control UI chat refresh usable while history and secondary metadata refreshes are slow, with an explicit history-await path for manual refresh. - Let config and channel tabs render cheap/stale snapshots before slow schema or probe work finishes, then request updates when background refreshes settle. - Bound large chat render pressure to the last 100 history messages and preserve slow-render/long-frame instrumentation for follow-up tuning. - Add regression coverage for non-blocking refreshes, manual refresh completion, background update callbacks, and the 100-message render cap. Verification: - pnpm test ui/src/ui/app-chat.test.ts ui/src/ui/app-render.helpers.node.test.ts ui/src/ui/app-settings.refresh-active-tab.node.test.ts ui/src/ui/control-ui-performance.test.ts ui/src/ui/controllers/chat.test.ts ui/src/ui/chat/build-chat-items.test.ts - pnpm exec oxfmt --check --threads=1 CHANGELOG.md ui/src/ui/app-chat.ts ui/src/ui/app-chat.test.ts ui/src/ui/app-render.helpers.ts ui/src/ui/app-render.helpers.node.test.ts ui/src/ui/app-render.ts ui/src/ui/app-settings.ts ui/src/ui/app-settings.refresh-active-tab.node.test.ts ui/src/ui/chat/build-chat-items.ts ui/src/ui/chat/build-chat-items.test.ts ui/src/ui/chat/history-limits.ts - git diff --check origin/main..HEAD && git diff --check - GitHub CI on exact head 53295ae: all required checks passed
1 parent cbc69d9 commit 5ae385b

11 files changed

Lines changed: 279 additions & 35 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Docs: https://docs.openclaw.ai
133133
- Plugins/runtime state: add `registerIfAbsent` for atomic keyed-store dedupe claims that return whether a plugin successfully claimed a key without overwriting an existing live value. Thanks @amknight.
134134
- Exec approvals: add a tree-sitter-backed shell command explainer for future approval and command-review surfaces. (#75004) Thanks @jesse-merhi.
135135
- 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.
136+
- Control UI/performance: keep chat, config, and channel refreshes responsive by decoupling slow history/schema/status work, reducing the client history window, and logging over-budget chat/config renders. Refs #77060, #45698, #47979, #44107. Thanks @BunsDev.
136137
- 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.
137138
- QA/Codex harness: add targeted live Docker/Testbox diagnostics, auth preflight checks, cache mount fixes, and app-server protocol checkout discovery so maintainer harness failures are easier to reproduce. Thanks @vincentkoc.
138139
- QA/Mantis: add `pnpm openclaw qa mantis slack-desktop-smoke` to run Slack live QA inside a Crabbox VNC desktop, open Slack Web, and capture desktop screenshots beside the Slack QA artifacts.

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,77 @@ function createDeferred<T>() {
142142
return { promise, resolve, reject };
143143
}
144144

145+
describe("refreshChat", () => {
146+
beforeAll(async () => {
147+
await loadChatHelpers();
148+
});
149+
150+
it("dispatches chat refresh work without waiting for slow history or secondary RPCs", async () => {
151+
const request = vi.fn(() => new Promise<unknown>(() => undefined));
152+
const requestUpdate = vi.fn();
153+
const host = makeHost({
154+
client: { request } as unknown as ChatHost["client"],
155+
sessionKey: "main",
156+
requestUpdate,
157+
});
158+
159+
const refresh = refreshChat(host);
160+
const outcome = await Promise.race([
161+
refresh.then(() => "resolved" as const),
162+
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)),
163+
]);
164+
165+
expect(outcome).toBe("resolved");
166+
expect(host.chatLoading).toBe(true);
167+
expect(request).toHaveBeenCalledWith("chat.history", {
168+
sessionKey: "main",
169+
limit: 100,
170+
maxChars: 4000,
171+
});
172+
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
173+
expect(request).toHaveBeenCalledWith("commands.list", {
174+
agentId: "main",
175+
includeArgs: true,
176+
scope: "text",
177+
});
178+
expect(requestUpdate).not.toHaveBeenCalled();
179+
});
180+
181+
it("can wait for history without waiting for secondary metadata refreshes", async () => {
182+
const history = createDeferred<unknown>();
183+
const requestUpdate = vi.fn();
184+
const request = vi.fn((method: string) => {
185+
if (method === "chat.history") {
186+
return history.promise;
187+
}
188+
return new Promise<unknown>(() => undefined);
189+
});
190+
const host = makeHost({
191+
client: { request } as unknown as ChatHost["client"],
192+
sessionKey: "main",
193+
requestUpdate,
194+
});
195+
196+
const refresh = refreshChat(host, { awaitHistory: true, scheduleScroll: false });
197+
const pendingOutcome = await Promise.race([
198+
refresh.then(() => "resolved" as const),
199+
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)),
200+
]);
201+
202+
expect(pendingOutcome).toBe("pending");
203+
history.resolve({
204+
messages: [{ role: "assistant", content: [{ type: "text", text: "ready" }] }],
205+
});
206+
207+
await expect(refresh).resolves.toBeUndefined();
208+
expect(host.chatMessages).toEqual([
209+
{ role: "assistant", content: [{ type: "text", text: "ready" }] },
210+
]);
211+
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
212+
expect(requestUpdate).toHaveBeenCalled();
213+
});
214+
});
215+
145216
describe("refreshChatAvatar", () => {
146217
beforeAll(async () => {
147218
await loadChatHelpers();

ui/src/ui/app-chat.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export type ChatHost = ChatInputHistoryState & {
6666
chatModelCatalog: ModelCatalogEntry[];
6767
sessionsResult?: SessionsListResult | null;
6868
updateComplete?: Promise<unknown>;
69+
requestUpdate?: () => void;
6970
refreshSessionsAfterChat: Set<string>;
7071
pendingAbort?: { runId?: string | null; sessionKey: string } | null;
7172
chatSubmitGuards?: Map<string, Promise<void>>;
@@ -745,8 +746,18 @@ function injectCommandResult(host: ChatHost, content: string) {
745746
];
746747
}
747748

748-
export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) {
749-
void Promise.allSettled([
749+
export async function refreshChat(
750+
host: ChatHost,
751+
opts?: { scheduleScroll?: boolean; awaitHistory?: boolean },
752+
) {
753+
const requestUpdate = () => host.requestUpdate?.();
754+
const historyRefresh = loadChatHistory(host as unknown as ChatState).finally(() => {
755+
if (opts?.scheduleScroll !== false) {
756+
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
757+
}
758+
requestUpdate();
759+
});
760+
const secondaryRefresh = Promise.allSettled([
750761
loadSessions(host as unknown as SessionsState, {
751762
activeMinutes: 0,
752763
limit: 0,
@@ -756,11 +767,14 @@ export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: bool
756767
refreshChatAvatar(host),
757768
refreshChatModels(host),
758769
refreshChatCommands(host),
759-
]);
760-
await loadChatHistory(host as unknown as ChatState);
761-
if (opts?.scheduleScroll !== false) {
762-
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
770+
]).finally(requestUpdate);
771+
void historyRefresh;
772+
void secondaryRefresh;
773+
if (opts?.awaitHistory === true) {
774+
await historyRefresh;
775+
return;
763776
}
777+
await Promise.resolve();
764778
}
765779

766780
async function refreshChatModels(host: ChatHost) {

ui/src/ui/app-render.helpers.node.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ vi.mock("./controllers/sessions.ts", () => ({
3737
import {
3838
createChatSession,
3939
dismissChatError,
40+
handleChatManualRefresh,
4041
isCronSessionKey,
4142
parseSessionKey,
4243
resolveAssistantAttachmentAuthToken,
@@ -647,6 +648,64 @@ describe("resolveSessionOptionGroups", () => {
647648
});
648649
});
649650

651+
describe("handleChatManualRefresh", () => {
652+
it("waits for chat history before scrolling and clearing refresh state", async () => {
653+
const animationFrame = { callback: undefined as FrameRequestCallback | undefined };
654+
const previousRequestAnimationFrame = globalThis.requestAnimationFrame;
655+
Object.defineProperty(globalThis, "requestAnimationFrame", {
656+
configurable: true,
657+
value: vi.fn((callback: FrameRequestCallback) => {
658+
animationFrame.callback = callback;
659+
return 1;
660+
}),
661+
});
662+
try {
663+
let resolveRefresh!: () => void;
664+
refreshChatMock.mockReturnValueOnce(
665+
new Promise<void>((resolve) => {
666+
resolveRefresh = resolve;
667+
}),
668+
);
669+
const state = {
670+
chatManualRefreshInFlight: false,
671+
chatNewMessagesBelow: true,
672+
updateComplete: Promise.resolve(),
673+
resetToolStream: vi.fn(),
674+
scrollToBottom: vi.fn(),
675+
} as unknown as Parameters<typeof handleChatManualRefresh>[0];
676+
677+
const run = handleChatManualRefresh(state);
678+
await Promise.resolve();
679+
680+
expect(state.scrollToBottom).not.toHaveBeenCalled();
681+
resolveRefresh();
682+
await run;
683+
684+
expect(refreshChatMock).toHaveBeenCalledWith(state, {
685+
awaitHistory: true,
686+
scheduleScroll: false,
687+
});
688+
expect(state.scrollToBottom).toHaveBeenCalledWith({ smooth: true });
689+
expect(state.chatManualRefreshInFlight).toBe(true);
690+
expect(animationFrame.callback).toBeTypeOf("function");
691+
692+
const callback = animationFrame.callback;
693+
if (!callback) {
694+
throw new Error("expected manual refresh to schedule a frame callback");
695+
}
696+
callback(0);
697+
698+
expect(state.chatManualRefreshInFlight).toBe(false);
699+
expect(state.chatNewMessagesBelow).toBe(false);
700+
} finally {
701+
Object.defineProperty(globalThis, "requestAnimationFrame", {
702+
configurable: true,
703+
value: previousRequestAnimationFrame,
704+
});
705+
}
706+
});
707+
});
708+
650709
describe("createChatSession", () => {
651710
it("creates a dashboard session, switches to it, and preserves the current composer", async () => {
652711
const state = createChatSessionState();

ui/src/ui/app-render.helpers.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,25 @@ type ChatRefreshHost = AppViewState & {
4949
updateComplete?: Promise<unknown>;
5050
};
5151

52+
export async function handleChatManualRefresh(state: ChatRefreshHost): Promise<void> {
53+
state.chatManualRefreshInFlight = true;
54+
state.chatNewMessagesBelow = false;
55+
await state.updateComplete;
56+
state.resetToolStream();
57+
try {
58+
await refreshChat(state as unknown as Parameters<typeof refreshChat>[0], {
59+
awaitHistory: true,
60+
scheduleScroll: false,
61+
});
62+
state.scrollToBottom({ smooth: true });
63+
} finally {
64+
requestAnimationFrame(() => {
65+
state.chatManualRefreshInFlight = false;
66+
state.chatNewMessagesBelow = false;
67+
});
68+
}
69+
}
70+
5271
export function resolveAssistantAttachmentAuthToken(
5372
state: Pick<AppViewState, "hello" | "settings" | "password">,
5473
) {
@@ -315,24 +334,7 @@ export function renderChatControls(state: AppViewState) {
315334
<button
316335
class="btn btn--sm btn--icon"
317336
?disabled=${refreshDisabled}
318-
@click=${async () => {
319-
const app = state as unknown as ChatRefreshHost;
320-
app.chatManualRefreshInFlight = true;
321-
app.chatNewMessagesBelow = false;
322-
await app.updateComplete;
323-
app.resetToolStream();
324-
try {
325-
await refreshChat(state as unknown as Parameters<typeof refreshChat>[0], {
326-
scheduleScroll: false,
327-
});
328-
app.scrollToBottom({ smooth: true });
329-
} finally {
330-
requestAnimationFrame(() => {
331-
app.chatManualRefreshInFlight = false;
332-
app.chatNewMessagesBelow = false;
333-
});
334-
}
335-
}}
337+
@click=${() => handleChatManualRefresh(state as unknown as ChatRefreshHost)}
336338
title=${refreshLabel}
337339
aria-label=${refreshLabel}
338340
data-tooltip=${refreshLabel}

ui/src/ui/app-render.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -981,7 +981,9 @@ export function renderApp(state: AppViewState) {
981981
"config",
982982
{
983983
tab: state.tab,
984+
formMode: overrides.formMode,
984985
activeSection: overrides.activeSection,
986+
activeSubsection: overrides.activeSubsection,
985987
schemaSectionCount: countTopLevelSchemaProperties(commonConfigProps.schema),
986988
hasSearch: Boolean(overrides.searchQuery?.trim()),
987989
},
@@ -2456,7 +2458,7 @@ export function renderApp(state: AppViewState) {
24562458
onRefresh: () => {
24572459
state.chatSideResult = null;
24582460
state.resetToolStream();
2459-
return refreshChat(state, { scheduleScroll: false });
2461+
return refreshChat(state, { awaitHistory: true, scheduleScroll: false });
24602462
},
24612463
onToggleFocusMode: () => {
24622464
if (state.onboarding) {

ui/src/ui/app-settings.refresh-active-tab.node.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({
1212
loadAgentIdentityMock: vi.fn(async () => {}),
1313
loadAgentSkillsMock: vi.fn(async () => {}),
1414
loadAgentsMock: vi.fn(async () => {}),
15-
loadChannelsMock: vi.fn(async () => {}),
15+
loadChannelsMock: vi.fn<(_host: unknown, _probe: boolean) => Promise<void>>(async () => {}),
1616
loadConfigMock: vi.fn(async () => {}),
1717
loadConfigSchemaMock: vi.fn(async () => {}),
1818
loadCronStatusMock: vi.fn(async () => {}),
@@ -241,6 +241,71 @@ describe("refreshActiveTab", () => {
241241
expect(mocks.loadUsageMock).toHaveBeenCalled();
242242
});
243243

244+
it("does not wait for config schema before resolving config tab refresh", async () => {
245+
const host = createHost();
246+
host.tab = "config";
247+
let resolveSchema!: () => void;
248+
mocks.loadConfigSchemaMock.mockReturnValueOnce(
249+
new Promise<void>((resolve) => {
250+
resolveSchema = resolve;
251+
}),
252+
);
253+
254+
const refresh = refreshActiveTab(host as never);
255+
const outcome = await Promise.race([
256+
refresh.then(() => "resolved" as const),
257+
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)),
258+
]);
259+
260+
expect(outcome).toBe("resolved");
261+
expect(mocks.loadConfigSchemaMock).toHaveBeenCalledOnce();
262+
expect(mocks.loadConfigMock).toHaveBeenCalledOnce();
263+
expect(host.requestUpdate).not.toHaveBeenCalled();
264+
265+
resolveSchema();
266+
267+
await vi.waitFor(() => {
268+
expect(host.requestUpdate).toHaveBeenCalledOnce();
269+
});
270+
});
271+
272+
it("renders channels from the cheap snapshot before starting slow probes", async () => {
273+
const host = createHost();
274+
host.tab = "channels";
275+
let resolveSchema!: () => void;
276+
let resolveProbe!: () => void;
277+
mocks.loadConfigSchemaMock.mockReturnValueOnce(
278+
new Promise<void>((resolve) => {
279+
resolveSchema = resolve;
280+
}),
281+
);
282+
mocks.loadChannelsMock.mockImplementation(async (_host, probe) => {
283+
if (probe) {
284+
await new Promise<void>((resolve) => {
285+
resolveProbe = resolve;
286+
});
287+
}
288+
});
289+
290+
const refresh = refreshActiveTab(host as never);
291+
const outcome = await Promise.race([
292+
refresh.then(() => "resolved" as const),
293+
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)),
294+
]);
295+
296+
expect(outcome).toBe("resolved");
297+
expect(mocks.loadChannelsMock.mock.calls.map(([, probe]) => probe)).toEqual([false, true]);
298+
expect(mocks.loadConfigMock).toHaveBeenCalledOnce();
299+
expect(host.requestUpdate).not.toHaveBeenCalled();
300+
301+
resolveSchema();
302+
resolveProbe();
303+
304+
await vi.waitFor(() => {
305+
expect(host.requestUpdate).toHaveBeenCalledTimes(2);
306+
});
307+
});
308+
244309
it("records overview secondary refresh duration and aggregate status", async () => {
245310
const host = createHost();
246311
host.tab = "overview";

ui/src/ui/app-settings.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ export async function refreshActiveTab(host: SettingsHost) {
357357
case "automation":
358358
case "infrastructure":
359359
case "aiAgents":
360-
await loadConfigSchema(app);
360+
void loadConfigSchema(app).finally(() => host.requestUpdate?.());
361361
await loadConfig(app);
362362
break;
363363
case "overview":
@@ -837,11 +837,9 @@ function buildAttentionItems(host: SettingsAppHost) {
837837

838838
export async function loadChannelsTab(host: SettingsHost) {
839839
const app = host as unknown as SettingsAppHost;
840-
await Promise.all([
841-
loadChannels(app, true, { softTimeoutMs: 750 }),
842-
loadConfigSchema(app),
843-
loadConfig(app),
844-
]);
840+
void loadConfigSchema(app).finally(() => host.requestUpdate?.());
841+
await Promise.all([loadChannels(app, false), loadConfig(app)]);
842+
void loadChannels(app, true).finally(() => host.requestUpdate?.());
845843
}
846844

847845
export async function loadCron(host: SettingsHost) {

0 commit comments

Comments
 (0)