fix(ui): stop hidden chat stream repainting (#125088)

* fix(ui): defer hidden chat stream paints

* fix(webui): defer hidden chat invalidations

* fix(ui): defer all hidden chat renders

* fix(ui): finish hidden update lifecycle

---------

Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
This commit is contained in:
Alix-007
2026-08-18 04:50:18 +08:00
committed by GitHub
parent 248966d300
commit bb03085015
4 changed files with 170 additions and 26 deletions
+27
View File
@@ -54,6 +54,7 @@ import {
} from "./chat-session-companion.ts";
import { ChatStateController } from "./chat-state-controller.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
import { requestChatPageUpdate } from "./chat-state-render.ts";
import { resolveChatAgentId } from "./chat-state-route.ts";
import type { ChatPaneHeaderAction } from "./components/chat-pane-header.ts";
import type { ChatSessionSharingState } from "./components/chat-session-sharing.ts";
@@ -64,6 +65,32 @@ import type { SessionSnapshotStore } from "./session-snapshot-store.ts";
import { closeSlot, isSidebarSlotVisible, openSlot, setSidebarOpen } from "./sidebar-layout.ts";
export abstract class ChatPaneBase extends OpenClawLightDomElement {
// Transfer a queued stream frame to Lit before parking; visibility resumes it.
// Disconnect releases the waiter so reconnect can schedule in its new lifecycle.
private hiddenUpdateResume: (() => void) | undefined;
private readonly handleVisibilityChange = () =>
document.visibilityState === "hidden" && this.state?.chatStreamRenderFrame != null
? requestChatPageUpdate(this.state)
: this.hiddenUpdateResume?.();
override connectedCallback() {
document.addEventListener("visibilitychange", this.handleVisibilityChange);
super.connectedCallback();
}
protected override async scheduleUpdate() {
while (this.isConnected && document.visibilityState === "hidden") {
await new Promise<void>((resolve) => {
this.hiddenUpdateResume = resolve;
});
}
await super.scheduleUpdate();
}
override disconnectedCallback() {
this.hiddenUpdateResume?.();
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
super.disconnectedCallback();
}
// Relative labels still need a minute tick; external PR state is server-pushed.
readonly minutePoll = new PollController(this, 60_000, () => {
this.requestUpdate();
@@ -15,6 +15,7 @@ import { createChatAttachmentHandoff } from "../../app/chat-attachment-handoff.t
import type { ApplicationContext } from "../../app/context.ts";
import { createInitialUserMessageHandoff } from "../../app/initial-user-message-handoff.ts";
import type { SessionCapability } from "../../lib/sessions/index.ts";
import { ChatPaneBase } from "./chat-pane-base.ts";
import { createTestChatPane, type TestChatPane } from "./chat-pane.test-support.ts";
import { applySelectedChatAgent } from "./chat-session.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
@@ -771,6 +772,68 @@ describe("chat pane presentation teardown", () => {
});
describe("chat pane connection lifecycle", () => {
it("reconciles hidden invalidations as one visible Lit update", async () => {
let visibilityState: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibilityState);
const { pane, requestUpdate, state } = createTestChatPane({
client: { request: vi.fn() } as unknown as GatewayBrowserClient,
sessions: {} as SessionCapability,
});
const lifecycle = pane as TestChatPane & {
performUpdate: () => void;
render: () => unknown;
requestUpdate: () => void;
};
lifecycle.render = () => null;
ChatPaneBase.prototype.connectedCallback.call(lifecycle);
await lifecycle.updateComplete;
const performUpdate = vi.spyOn(lifecycle, "performUpdate");
const cancelAnimationFrame = vi.spyOn(globalThis, "cancelAnimationFrame");
visibilityState = "hidden";
state.chatStreamRenderFrame = 7;
document.dispatchEvent(new Event("visibilitychange"));
expect(cancelAnimationFrame).toHaveBeenCalledWith(7);
expect(state.chatStreamRenderFrame).toBeNull();
expect(requestUpdate).toHaveBeenCalledOnce();
lifecycle.requestUpdate();
lifecycle.requestUpdate();
await Promise.resolve();
expect(performUpdate).not.toHaveBeenCalled();
visibilityState = "visible";
document.dispatchEvent(new Event("visibilitychange"));
await lifecycle.updateComplete;
expect(performUpdate).toHaveBeenCalledOnce();
const addVisibilityListener = vi.spyOn(document, "addEventListener");
const removeVisibilityListener = vi.spyOn(document, "removeEventListener");
visibilityState = "hidden";
lifecycle.requestUpdate();
await Promise.resolve();
Object.defineProperty(lifecycle, "isConnected", { configurable: true, value: false });
ChatPaneBase.prototype.disconnectedCallback.call(lifecycle);
expect(removeVisibilityListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
Object.defineProperty(lifecycle, "isConnected", { configurable: true, value: true });
ChatPaneBase.prototype.connectedCallback.call(lifecycle);
await Promise.resolve();
visibilityState = "visible";
document.dispatchEvent(new Event("visibilitychange"));
await lifecycle.updateComplete;
expect(performUpdate).toHaveBeenCalledTimes(2);
Object.defineProperty(lifecycle, "isConnected", { configurable: true, value: false });
ChatPaneBase.prototype.disconnectedCallback.call(lifecycle);
addVisibilityListener.mockClear();
lifecycle.requestUpdate();
await lifecycle.updateComplete;
expect(addVisibilityListener).not.toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
});
it("fully tears down realtime Talk when the gateway disconnects", () => {
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
+2 -3
View File
@@ -17,7 +17,8 @@ export function requestChatPageUpdate(
state: ChatPageHost,
mode: ChatPageUpdateMode = "immediate",
): void {
if (mode === "immediate" || typeof globalThis.requestAnimationFrame !== "function") {
const hidden = globalThis.document?.visibilityState === "hidden";
if (hidden || mode === "immediate" || typeof globalThis.requestAnimationFrame !== "function") {
cancelChatStreamRenderFrame(state);
state.requestUpdate?.();
return;
@@ -25,8 +26,6 @@ export function requestChatPageUpdate(
if (state.chatStreamRenderFrame != null) {
return;
}
// Deltas still mutate the canonical stream immediately. One frame owns the
// paint; terminal/non-stream events cancel it so stale partial UI cannot win.
let frame = 0;
frame = globalThis.requestAnimationFrame(() => {
if (state.chatStreamRenderFrame !== frame) {
+78 -23
View File
@@ -45,6 +45,7 @@ describe("canonical session message recovery", () => {
sessionId: "selected-session",
thinkingLevel: null,
});
const requestUpdate = overrides.requestUpdate ?? vi.fn();
const state = {
...makeChatHost(),
client: { request } as unknown as GatewayBrowserClient,
@@ -59,7 +60,8 @@ describe("canonical session message recovery", () => {
reconcileChanged: vi.fn().mockReturnValue({ applied: false }),
refresh: vi.fn().mockResolvedValue(undefined),
},
requestUpdate: vi.fn(),
renderLifecycle: { invalidate: requestUpdate },
requestUpdate,
...overrides,
} as unknown as ChatPageHost;
return { request, state };
@@ -855,13 +857,15 @@ describe("canonical session message recovery", () => {
describe("ChatStateController render lifecycle", () => {
function createObserverState(overrides: Partial<Record<keyof ChatPageHost, unknown>> = {}) {
const requestUpdate = (overrides.requestUpdate ?? vi.fn()) as ReturnType<typeof vi.fn>;
return {
sessionKey: "agent:main:current",
assistantAgentId: "main",
agentsList: { defaultId: "main" },
chatRunId: null,
observerDigest: null,
requestUpdate: vi.fn(),
renderLifecycle: { invalidate: requestUpdate },
requestUpdate,
...overrides,
} as unknown as ChatPageHost;
}
@@ -916,6 +920,7 @@ describe("ChatStateController render lifecycle", () => {
}
function createStreamEventState(overrides: Partial<ChatPageHost> = {}) {
const requestUpdate = overrides.requestUpdate ?? vi.fn();
return {
chatMessages: [],
chatMessagesBySession: new Map(),
@@ -925,12 +930,34 @@ describe("ChatStateController render lifecycle", () => {
chatStreamStartedAt: 1,
lastError: null,
pendingSessionMessageReloadSessionKey: null,
requestUpdate: vi.fn(),
renderLifecycle: { invalidate: requestUpdate },
requestUpdate,
sessionKey: "main",
...overrides,
} as unknown as ChatPageHost;
}
function createPageContext() {
return {
agents: {
state: { agentsList: null },
adoptList: vi.fn(),
},
agentSelection: { state: { selectedId: "main" } },
basePath: "",
config: {
current: {
allowExternalEmbedUrls: false,
assistantIdentity: { name: "Assistant" },
embedSandboxMode: "scripts",
localMediaPreviewRoots: [],
},
},
initialUserMessage: createInitialUserMessageHandoff(),
sessions: {},
} as unknown as ApplicationContext;
}
it("keeps the active observer digest when another run streams in the same session", () => {
const projectedDigest = {
sessionKey: "agent:main:current",
@@ -1224,6 +1251,7 @@ describe("ChatStateController render lifecycle", () => {
});
it("tracks waiting approval only for the selected session until resolution", () => {
const requestUpdate = vi.fn();
const state = {
sessionKey: "agent:main:current",
assistantAgentId: "main",
@@ -1239,7 +1267,8 @@ describe("ChatStateController render lifecycle", () => {
waitingApprovalStatuses: new Map(),
sessions: { setModelOverride: vi.fn() },
chatStreamRenderFrame: null,
requestUpdate: vi.fn(),
renderLifecycle: { invalidate: requestUpdate },
requestUpdate,
} as unknown as ChatPageHost;
const lifecycleEvent = (
phase: "waiting-approval" | "approval-resolved",
@@ -1362,6 +1391,48 @@ describe("ChatStateController render lifecycle", () => {
expect(state.chatStreamRenderFrame).toBeNull();
});
it("projects hidden Gateway state without scheduling animation frames", () => {
vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
const requestAnimationFrame = vi
.spyOn(globalThis, "requestAnimationFrame")
.mockImplementation(() => 1);
const requestUpdate = vi.fn();
const state = createStreamEventState({ requestUpdate });
for (const deltaText of ["A", "B", "C"]) {
handlePageGatewayEvent(state, {
type: "event",
event: "chat",
payload: { state: "delta", runId: "run-1", sessionKey: "main", deltaText },
});
}
expect(state.chatStream).toBe("ABC");
expect(requestAnimationFrame).not.toHaveBeenCalled();
expect(requestUpdate).toHaveBeenCalledTimes(3);
handlePageGatewayEvent(state, {
type: "event",
event: "session.observer",
payload: {
sessionKey: "main",
runId: "run-1",
revision: 1,
updatedAt: 1_000,
headline: "Waiting for a tool",
health: "grinding",
},
});
handlePageGatewayEvent(state, {
type: "event",
event: "session.operation",
payload: {},
});
expect(state.observerDigest?.headline).toBe("Waiting for a tool");
expect(requestUpdate).toHaveBeenCalledTimes(5);
});
it("keeps every chat delta while batching their render", () => {
let scheduledFrame: FrameRequestCallback | undefined;
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((callback) => {
@@ -1487,25 +1558,9 @@ describe("ChatStateController render lifecycle", () => {
const controller = new ChatStateController<ChatPageHost>(host);
controller.hostConnected();
const renderLifecycle = controller.createRenderLifecycle();
const context = {
agents: {
state: { agentsList: null },
adoptList: vi.fn(),
},
agentSelection: { state: { selectedId: "main" } },
basePath: "",
config: {
current: {
allowExternalEmbedUrls: false,
assistantIdentity: { name: "Assistant" },
embedSandboxMode: "scripts",
localMediaPreviewRoots: [],
},
},
initialUserMessage: createInitialUserMessageHandoff(),
sessions: {},
} as unknown as ApplicationContext;
const state = createPageState(context, renderLifecycle, { querySelector: () => null });
const state = createPageState(createPageContext(), renderLifecycle, {
querySelector: () => null,
});
const stop = vi.fn(() => {
expect(state.realtimeTalkSession).toBeNull();
});