mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user