perf(ui): debounce chat draft persistence

Debounce draft-only Control UI chat composer persistence while snapshotting pending drafts so session changes and teardown still flush the correct state. Verified with focused UI lifecycle/composer tests, format, oxlint, tsgo core/UI test, clean autoreview, and PR checks.
This commit is contained in:
Vincent Koc
2026-06-01 08:04:23 +01:00
committed by GitHub
parent 61ffd6bc66
commit 8071b06634
3 changed files with 204 additions and 1 deletions

View File

@@ -35,6 +35,14 @@ function createHost() {
};
}
type ComposerPersistHost = ReturnType<typeof createHost> & {
chatComposerPersistTimer?: ReturnType<typeof globalThis.setTimeout> | number | null;
};
function createComposerPersistHost(): ComposerPersistHost {
return createHost() as ComposerPersistHost;
}
describe("handleDisconnected", () => {
afterEach(() => {
vi.useRealTimers();
@@ -82,9 +90,143 @@ describe("handleDisconnected", () => {
describe("handleUpdated", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("debounces draft-only composer persistence", () => {
vi.useFakeTimers();
vi.stubGlobal("sessionStorage", createStorageMock());
const host = createHost();
host.chatMessage = "typing without blocking input";
handleUpdated(
host as unknown as Parameters<typeof handleUpdated>[0],
new Map<PropertyKey, unknown>([["chatMessage", ""]]),
);
expect(loadChatComposerSnapshot(host, "main")).toBeNull();
vi.advanceTimersByTime(199);
expect(loadChatComposerSnapshot(host, "main")).toBeNull();
vi.advanceTimersByTime(1);
expect(loadChatComposerSnapshot(host, "main")).toEqual({
draft: "typing without blocking input",
queue: [],
});
});
it("flushes delayed draft persistence on teardown", () => {
vi.useFakeTimers();
vi.stubGlobal("sessionStorage", createStorageMock());
vi.stubGlobal("window", {
removeEventListener: vi.fn(),
});
const host = createHost();
host.chatMessage = "save before close";
handleUpdated(
host as unknown as Parameters<typeof handleUpdated>[0],
new Map<PropertyKey, unknown>([["chatMessage", ""]]),
);
handleDisconnected(host as unknown as Parameters<typeof handleDisconnected>[0]);
expect(loadChatComposerSnapshot(host, "main")).toEqual({
draft: "save before close",
queue: [],
});
vi.advanceTimersByTime(200);
expect(loadChatComposerSnapshot(host, "main")).toEqual({
draft: "save before close",
queue: [],
});
});
it("persists queue changes immediately and clears delayed draft persistence", () => {
vi.useFakeTimers();
vi.stubGlobal("sessionStorage", createStorageMock());
const host = createComposerPersistHost();
host.chatMessage = "draft with queued work";
handleUpdated(
host as unknown as Parameters<typeof handleUpdated>[0],
new Map<PropertyKey, unknown>([["chatMessage", ""]]),
);
host.chatQueue = [{ id: "queued-1", text: "next prompt", createdAt: 1 }];
handleUpdated(
host as unknown as Parameters<typeof handleUpdated>[0],
new Map<PropertyKey, unknown>([["chatQueue", []]]),
);
expect(host.chatComposerPersistTimer).toBeNull();
expect(loadChatComposerSnapshot(host, "main")).toEqual({
draft: "draft with queued work",
queue: [{ id: "queued-1", text: "next prompt", createdAt: 1 }],
});
});
it("persists drafts immediately when the active session changes", () => {
vi.useFakeTimers();
vi.stubGlobal("sessionStorage", createStorageMock());
const host = createComposerPersistHost();
host.chatMessage = "draft before new chat";
handleUpdated(
host as unknown as Parameters<typeof handleUpdated>[0],
new Map<PropertyKey, unknown>([["chatMessage", ""]]),
);
host.sessionKey = "agent:main:new-session";
host.chatMessage = "draft restored into new chat";
handleUpdated(
host as unknown as Parameters<typeof handleUpdated>[0],
new Map<PropertyKey, unknown>([
["sessionKey", "main"],
["chatMessage", ""],
]),
);
expect(host.chatComposerPersistTimer).toBeNull();
expect(loadChatComposerSnapshot(host, "main")).toEqual({
draft: "draft before new chat",
queue: [],
});
expect(loadChatComposerSnapshot(host, "agent:main:new-session")).toEqual({
draft: "draft restored into new chat",
queue: [],
});
});
it("flushes delayed draft persistence when only the session changes", () => {
vi.useFakeTimers();
vi.stubGlobal("sessionStorage", createStorageMock());
const host = createComposerPersistHost();
host.chatMessage = "draft from old session";
handleUpdated(
host as unknown as Parameters<typeof handleUpdated>[0],
new Map<PropertyKey, unknown>([["chatMessage", ""]]),
);
host.sessionKey = "agent:main:other";
handleUpdated(
host as unknown as Parameters<typeof handleUpdated>[0],
new Map<PropertyKey, unknown>([["sessionKey", "main"]]),
);
expect(host.chatComposerPersistTimer).toBeNull();
expect(loadChatComposerSnapshot(host, "main")).toEqual({
draft: "draft from old session",
queue: [],
});
expect(loadChatComposerSnapshot(host, "agent:main:other")).toBeNull();
vi.advanceTimersByTime(200);
expect(loadChatComposerSnapshot(host, "agent:main:other")).toBeNull();
expect(loadChatComposerSnapshot(host, "main")).toEqual({
draft: "draft from old session",
queue: [],
});
});
it("persists chat draft and queue changes before chat refresh short-circuits", () => {
vi.stubGlobal("sessionStorage", createStorageMock());
const host = createHost();

View File

@@ -26,6 +26,14 @@ import { loadControlUiBootstrapConfig } from "./controllers/control-ui-bootstrap
import type { Tab } from "./navigation.ts";
import type { ChatQueueItem } from "./ui-types.ts";
const CHAT_COMPOSER_DRAFT_PERSIST_DELAY_MS = 200;
type PendingChatComposerPersistSnapshot = {
sessionKey: string;
chatMessage: string;
chatQueue: ChatQueueItem[];
};
type LifecycleHost = {
basePath: string;
client?: { stop: () => void } | null;
@@ -48,6 +56,8 @@ type LifecycleHost = {
sessionKey: string;
chatMessage: string;
chatQueue: ChatQueueItem[];
chatComposerPersistTimer?: ReturnType<typeof globalThis.setTimeout> | number | null;
chatComposerPersistSnapshot?: PendingChatComposerPersistSnapshot | null;
pendingGatewayUrl?: string | null;
realtimeTalkSession?: { stop: () => void } | null;
realtimeTalkActive?: boolean;
@@ -134,9 +144,46 @@ function clearHostGlobalTimeout(
}
}
function clearPendingChatComposerPersistence(host: LifecycleHost) {
clearHostGlobalTimeout(host.chatComposerPersistTimer);
host.chatComposerPersistTimer = null;
host.chatComposerPersistSnapshot = null;
}
function flushPendingChatComposerPersistence(host: LifecycleHost) {
const snapshot = host.chatComposerPersistSnapshot;
if (host.chatComposerPersistTimer == null || !snapshot) {
clearPendingChatComposerPersistence(host);
return;
}
clearPendingChatComposerPersistence(host);
persistChatComposerState(
{
...host,
sessionKey: snapshot.sessionKey,
chatMessage: snapshot.chatMessage,
chatQueue: snapshot.chatQueue,
},
snapshot.sessionKey,
);
}
function scheduleChatComposerDraftPersistence(host: LifecycleHost) {
clearPendingChatComposerPersistence(host);
host.chatComposerPersistSnapshot = {
sessionKey: host.sessionKey,
chatMessage: host.chatMessage,
chatQueue: [...host.chatQueue],
};
host.chatComposerPersistTimer = globalThis.setTimeout(() => {
flushPendingChatComposerPersistence(host);
}, CHAT_COMPOSER_DRAFT_PERSIST_DELAY_MS);
}
export function handleDisconnected(host: LifecycleHost) {
host.connectGeneration += 1;
host.controlUiTabPaintSeq = (host.controlUiTabPaintSeq ?? 0) + 1;
flushPendingChatComposerPersistence(host);
window.removeEventListener("popstate", host.popStateHandler);
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
@@ -169,8 +216,16 @@ export function handleDisconnected(host: LifecycleHost) {
}
export function handleUpdated(host: LifecycleHost, changed: Map<PropertyKey, unknown>) {
if (changed.has("chatMessage") || changed.has("chatQueue")) {
if (changed.has("chatQueue")) {
clearPendingChatComposerPersistence(host);
persistChatComposerState(host);
} else if (changed.has("sessionKey")) {
flushPendingChatComposerPersistence(host);
if (changed.has("chatMessage")) {
persistChatComposerState(host);
}
} else if (changed.has("chatMessage")) {
scheduleChatComposerDraftPersistence(host);
}
if (host.tab === "chat" && host.chatManualRefreshInFlight) {
return;

View File

@@ -287,6 +287,12 @@ export class OpenClawApp extends LitElement {
private sessionSwitchNoticeSeq = 0;
private sessionSwitchNoticeTimer: number | null = null;
private sessionSwitchFlashTimer: number | null = null;
chatComposerPersistTimer: ReturnType<typeof globalThis.setTimeout> | number | null = null;
chatComposerPersistSnapshot: {
sessionKey: string;
chatMessage: string;
chatQueue: ChatQueueItem[];
} | null = null;
@state() chatQueue: ChatQueueItem[] = [];
@state() chatQueueBySession: Record<string, ChatQueueItem[]> = {};
@state() chatAttachments: ChatAttachment[] = [];