mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user