fix(ui): restore full chat history after cached layout changes (#125536)

Cached Control UI transcripts no longer show a large blank region or hide earlier loaded messages after layout/transcript-shape changes.

Closes #125533
This commit is contained in:
Peter Steinberger
2026-08-17 20:33:05 -07:00
committed by GitHub
parent de41c7de8c
commit a0c5937fd1
7 changed files with 265 additions and 162 deletions
@@ -0,0 +1,194 @@
import fs from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import {
CHAT_SNAPSHOT_DB_NAME,
CHAT_SNAPSHOT_STORE_NAME,
} from "../pages/chat/session-snapshot-invalidation.ts";
import {
createChatFlowE2eSuite,
installMockGateway,
requireRecord,
waitForChatScrollIdle,
waitForRequests,
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
const sessionId = "durable-geometry-session";
function historyMessage(seq: number, text: string) {
return {
__openclaw: { id: `durable-geometry-${seq}`, seq },
content: [{ type: seq % 2 === 0 ? "output_text" : "input_text", text }],
role: seq % 2 === 0 ? "assistant" : "user",
timestamp: 1_800_000_000_000 + seq,
};
}
suite.define(() => {
it("discards stale transcript geometry before restored history bootstrap", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
}
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 600, width: 520 },
...(artifactDir
? { recordVideo: { dir: artifactDir, size: { height: 600, width: 520 } } }
: {}),
});
const page = await context.newPage();
const proofVideo = page.video();
const recentMessages = Array.from({ length: 16 }, (_, index) =>
historyMessage(
index + 3,
`Restored message ${index + 3}: ${"prior narrow presentation text ".repeat(18)}`,
),
);
const compactRecentMessages = recentMessages.map((message, index) =>
historyMessage(index + 3, `Restored message ${index + 3}`),
);
const olderMessages = [historyMessage(1, "Older 1"), historyMessage(2, "Older 2")];
const totalMessages = compactRecentMessages.length + olderMessages.length;
const gateway = await installMockGateway(page, {
methodResponses: {
"chat.startup": {
hasMore: false,
messages: recentMessages,
sessionId,
totalMessages: recentMessages.length,
},
},
sessionKey: "agent:main:main",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
await page.getByText("Restored message 18:", { exact: false }).waitFor({ timeout: 10_000 });
const rowKeys = await page.locator(".chat-virtual-row").evaluateAll((rows) =>
rows.flatMap((row) => {
const key = (row as HTMLElement).dataset.virtualRowKey;
return key ? [key] : [];
}),
);
expect(rowKeys.length).toBeGreaterThan(0);
if (artifactDir) {
await page.screenshot({
path: path.join(artifactDir, "00-prior-narrow-transcript.png"),
fullPage: true,
});
}
await expect
.poll(
() =>
page.evaluate(
async ({ databaseName, keys, storeName }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName);
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () =>
reject(new Error(request.error?.message ?? "snapshot database open failed")),
);
});
const transaction = database.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
const record = await new Promise<unknown>((resolve, reject) => {
const request = store.get("agent:main:main");
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () =>
reject(new Error(request.error?.message ?? "snapshot record read failed")),
);
});
if (!record || typeof record !== "object") {
database.close();
return false;
}
(record as Record<string, unknown>).rowHeights = new Map(
keys.map((key) => [key, 1_000]),
);
store.put(record);
await new Promise<void>((resolve, reject) => {
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () =>
reject(new Error(transaction.error?.message ?? "snapshot write failed")),
);
transaction.addEventListener("abort", () =>
reject(new Error(transaction.error?.message ?? "snapshot write aborted")),
);
});
database.close();
return true;
},
{
databaseName: CHAT_SNAPSHOT_DB_NAME,
keys: rowKeys,
storeName: CHAT_SNAPSHOT_STORE_NAME,
},
),
{ timeout: 10_000 },
)
.toBe(true);
await gateway.setMethodResponse("chat.startup", {
hasMore: true,
messages: compactRecentMessages,
nextOffset: compactRecentMessages.length,
sessionId,
totalMessages,
});
await gateway.setMethodResponse("chat.history", {
cases: [
{
match: { offset: compactRecentMessages.length, sessionKey: "agent:main:main" },
response: { hasMore: false, messages: olderMessages, sessionId, totalMessages },
},
],
});
const historyRequestsBeforeReload = (await gateway.getRequests("chat.history")).length;
await page.setViewportSize({ height: 2_400, width: 1_400 });
await page.reload();
const requests = await waitForRequests(
gateway,
"chat.history",
historyRequestsBeforeReload + 1,
);
expect(requireRecord(requests.at(-1)?.params)).toMatchObject({
offset: compactRecentMessages.length,
sessionKey: "agent:main:main",
});
await expect
.poll(() =>
page.locator(".chat-pane-cache__pane--active").evaluate((element) => {
const pane = element as HTMLElement & { state?: { chatMessages?: unknown[] } };
return pane.state?.chatMessages?.length ?? 0;
}),
)
.toBe(totalMessages);
await waitForChatScrollIdle(page);
await page.getByText("Older 1", { exact: true }).waitFor();
await page.getByText("Restored message 3", { exact: true }).waitFor();
await expect
.poll(() =>
page.locator(".chat-pane-cache__pane--active .chat-thread").evaluate((element) => {
const thread = element as HTMLElement;
return thread.scrollHeight - thread.clientHeight;
}),
)
.toBeLessThanOrEqual(1);
if (artifactDir) {
await page.screenshot({
path: path.join(artifactDir, "01-restored-without-phantom-gap.png"),
fullPage: true,
});
}
} finally {
await suite.closeBrowserContext(context);
if (artifactDir && proofVideo) {
await proofVideo.saveAs(path.join(artifactDir, "stale-transcript-geometry.webm"));
}
}
}, 120_000);
});
+3 -26
View File
@@ -60,7 +60,7 @@ import type { ChatPaneHeaderAction } from "./components/chat-pane-header.ts";
import type { ChatSessionSharingState } from "./components/chat-session-sharing.ts";
import { ChatTranscriptController } from "./components/chat-transcript-controller.ts";
import type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts";
import { resolveChatSnapshotKey, type ChatMessageCache } from "./session-message-cache.ts";
import type { ChatMessageCache } from "./session-message-cache.ts";
import type { SessionSnapshotStore } from "./session-snapshot-store.ts";
import { closeSlot, isSidebarSlotVisible, openSlot, setSidebarOpen } from "./sidebar-layout.ts";
@@ -185,16 +185,8 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
protected readonly composerCapabilities = new ChatComposerCapabilityHost(() =>
this.requestUpdate(),
);
protected readonly transcript = new ChatTranscriptController(this, {
read: (sessionKey, rowKey) => this.readTranscriptRowHeight(sessionKey, rowKey),
write: (sessionKey, rowKey, height) =>
this.writeTranscriptRowHeight(sessionKey, rowKey, height),
});
protected readonly taskSidebarTranscript = new ChatTranscriptController(this, {
read: (sessionKey, rowKey) => this.readTranscriptRowHeight(sessionKey, rowKey),
write: (sessionKey, rowKey, height) =>
this.writeTranscriptRowHeight(sessionKey, rowKey, height),
});
protected readonly transcript = new ChatTranscriptController(this);
protected readonly taskSidebarTranscript = new ChatTranscriptController(this);
protected readonly progressCard = new SessionProgressCardController(this, {
gateway: () => this.context?.gateway,
sessionKey: () => this.state?.sessionKey,
@@ -219,21 +211,6 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
// SessionDataController's own epoch-scoped controller for the sidebar.
protected headerSessionMutationAbortController = new AbortController();
private transcriptSnapshotKey(sessionKey: string): string | null {
return this.state ? resolveChatSnapshotKey(this.state, { sessionKey }) : null;
}
private readTranscriptRowHeight(sessionKey: string, rowKey: string): number | undefined {
const snapshotKey = this.transcriptSnapshotKey(sessionKey);
return snapshotKey ? this.sessionSnapshotStore?.readRowHeight(snapshotKey, rowKey) : undefined;
}
private writeTranscriptRowHeight(sessionKey: string, rowKey: string, height: number): void {
const snapshotKey = this.transcriptSnapshotKey(sessionKey);
if (snapshotKey) {
this.sessionSnapshotStore?.recordRowHeight(snapshotKey, rowKey, height);
}
}
@litState() protected headerEditing = false;
@litState() protected headerRenameValue = "";
@litState() protected headerPlatform: string | null = null;
+7 -12
View File
@@ -2,18 +2,13 @@ import type { ReactiveControllerHost } from "lit";
import { vi } from "vitest";
import { ChatTranscriptController } from "./components/chat-transcript-controller.ts";
export function createTestTranscript(
rowHeightCache?: ConstructorParameters<typeof ChatTranscriptController>[1],
): ChatTranscriptController {
return new ChatTranscriptController(
{
addController: () => undefined,
removeController: () => undefined,
requestUpdate: () => undefined,
updateComplete: Promise.resolve(true),
} satisfies ReactiveControllerHost,
rowHeightCache,
);
export function createTestTranscript(): ChatTranscriptController {
return new ChatTranscriptController({
addController: () => undefined,
removeController: () => undefined,
requestUpdate: () => undefined,
updateComplete: Promise.resolve(true),
} satisfies ReactiveControllerHost);
}
export function createPasteEvent(
@@ -2,7 +2,7 @@
import { nothing, render } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestTranscript } from "../chat-view.test-helpers.ts";
import { createTestTranscript, stubAnimationFrames } from "../chat-view.test-helpers.ts";
import { renderChatThread } from "./chat-thread.ts";
import {
flushDeferredRowPrune,
@@ -72,29 +72,28 @@ describe("chat transcript controller", () => {
expect(transcriptRows(container)[1]?.style.transform).toBe("translateY(100px)");
});
it("uses persisted row measurements before remounted rows can be measured", async () => {
const measured = new Map<string, number>();
const first = createTestTranscript({
read: () => undefined,
write: (_sessionKey, rowKey, height) => measured.set(rowKey, height),
it("reconciles an implicit end anchor when committed content has no scroll range", () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const messages = Array.from({ length: 18 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
timestamp: index + 1,
}));
const props = threadProps("pane-underfill-anchor", "agent:main:underfill", messages);
render(renderChatThread(props, transcript), container);
const scrollElement = container.querySelector<HTMLElement>(".chat-thread");
expect(scrollElement).not.toBeNull();
Object.defineProperties(scrollElement, {
clientHeight: { configurable: true, value: 600 },
scrollHeight: { configurable: true, value: 600 },
});
const props = threadProps("pane-persisted-heights", "agent:main:persisted-heights");
const firstContainer = document.body.appendChild(document.createElement("div"));
render(renderChatThread(props, first), firstContainer);
first.hostConnected();
first.hostUpdated();
await flushDeferredRowPrune();
expect(measured.size).toBeGreaterThan(0);
transcriptDomState.detachedRowHeight = 0;
const remounted = createTestTranscript({
read: (_sessionKey, rowKey) => measured.get(rowKey),
write: () => undefined,
});
const remountContainer = document.body.appendChild(document.createElement("div"));
render(renderChatThread(props, remounted), remountContainer);
expect(transcriptRows(remountContainer)[1]?.style.transform).toBe("translateY(100px)");
transcript.hostConnected();
transcript.hostUpdated();
render(renderChatThread(props, transcript), container);
expect(transcriptRows(container)[0]?.dataset.index).toBe("0");
expect(container.textContent).toContain("message 0");
});
it("pauses an unmeasurable restore until loading commits an empty transcript", () => {
@@ -115,11 +114,7 @@ describe("chat transcript controller", () => {
});
it("settles a restored offset when loaded rows no longer overflow", () => {
const frames: FrameRequestCallback[] = [];
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
frames.push(callback);
return frames.length;
});
const flushFrames = stubAnimationFrames();
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
@@ -131,9 +126,7 @@ describe("chat transcript controller", () => {
for (let index = 0; index <= 60; index += 1) {
transcript.hostUpdated();
for (const frame of frames.splice(0)) {
frame(0);
}
flushFrames();
}
expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBeNull();
@@ -49,11 +49,6 @@ export type ChatTranscriptSession = {
handleFocusOut(event: FocusEvent): void;
};
type TranscriptRowHeightCache = {
read: (sessionKey: string, rowKey: string) => number | undefined;
write: (sessionKey: string, rowKey: string, height: number) => void;
};
const CHAT_TRANSCRIPT_ESTIMATED_ROW_PX = 120;
const CHAT_TRANSCRIPT_OVERSCAN = 6;
// Initial virtual rows can correct their estimates for several frames. Hold a
@@ -93,6 +88,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri
private observedWidth: number | null = null;
private observedHeight: number | null = null;
private contentReady = false;
private implicitEndAnchorPending: boolean;
private pendingScrollOffset: {
offset: number;
stableFrames: number;
@@ -151,7 +147,6 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri
[]) {
const index = instance.indexFromElement(row);
const size = row[instance.options.horizontal ? "offsetWidth" : "offsetHeight"];
this.recordRowHeight(index, size);
instance.resizeItem(index, size);
}
}
@@ -217,20 +212,14 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri
constructor(
private readonly host: ReactiveControllerHost,
private readonly sessionKey: string,
private readonly rowHeightCache?: TranscriptRowHeightCache,
initialOffset: number | null = null,
onInitialOffsetSettled?: (position: ChatSessionScrollPosition) => void,
) {
this.implicitEndAnchorPending = initialOffset === null;
this.virtualizerController = new VirtualizerController(this, {
count: 0,
getScrollElement: () => this.scrollElement,
estimateSize: (index) => {
const rowKey = this.rowKeys[index];
return rowKey
? (this.rowHeightCache?.read(this.sessionKey, rowKey) ?? CHAT_TRANSCRIPT_ESTIMATED_ROW_PX)
: CHAT_TRANSCRIPT_ESTIMATED_ROW_PX;
},
estimateSize: () => CHAT_TRANSCRIPT_ESTIMATED_ROW_PX,
getItemKey: () => "",
initialRect: initialTranscriptRect(host),
initialOffset: initialOffset ?? Number.MAX_SAFE_INTEGER,
@@ -273,11 +262,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri
this.queueConnectedRowMeasure();
}
}),
measureElement: (element, entry, instance) => {
const size = measureVirtualElement(element, entry, instance);
this.recordRowHeight(instance.indexFromElement(element), size);
return size;
},
measureElement: measureVirtualElement,
rangeExtractor: (range) => {
const indexes = defaultRangeExtractor(range);
const focused =
@@ -342,6 +327,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri
for (const controller of this.controllers) {
controller.hostUpdated?.();
}
this.reconcileImplicitEndAnchor();
this.applyPendingScrollOffset();
}
@@ -506,6 +492,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri
offset: number,
onSettled?: (position: ChatSessionScrollPosition) => void,
): void {
this.implicitEndAnchorPending = false;
this.pendingScrollOffset = { offset, stableFrames: 0, zeroMaxFrames: 0, onSettled };
if (this.connected) {
this.host.requestUpdate();
@@ -535,13 +522,6 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri
return row.dataset.virtualRowKey || null;
}
private recordRowHeight(index: number, height: number): void {
const rowKey = this.rowKeys[index];
if (rowKey && Number.isFinite(height) && height > 0) {
this.rowHeightCache?.write(this.sessionKey, rowKey, height);
}
}
private syncAnnouncement(announcement: TranscriptAnnouncement | null, announce: boolean): void {
if (!this.announcementInitialized || !announce) {
this.announcementInitialized = true;
@@ -592,6 +572,31 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri
});
}
private reconcileImplicitEndAnchor(): void {
if (!this.implicitEndAnchorPending || !this.connected || !this.contentReady) {
return;
}
const maxOffset = this.getMaxScrollOffset();
const virtualizer = this.virtualizerController.getVirtualizer();
const scrollOffset = virtualizer.scrollOffset;
if (maxOffset === null || scrollOffset === null) {
return;
}
if (scrollOffset >= 0 && scrollOffset <= maxOffset) {
this.implicitEndAnchorPending = false;
return;
}
if (maxOffset !== 0) {
return;
}
this.implicitEndAnchorPending = false;
// The DOM clamps an underfilled end anchor to zero without a scroll event,
// so TanStack cannot reconcile its maximum-integer initial offset itself.
virtualizer.scrollOffset = 0;
virtualizer.scrollToOffset(0);
this.host.requestUpdate();
}
private applyPendingScrollOffset(): void {
const pending = this.pendingScrollOffset;
if (!pending || !this.connected) {
@@ -668,10 +673,7 @@ export class ChatTranscriptController implements ReactiveController {
private sessionVirtualizer: ChatSessionVirtualizerHost | null = null;
private connected = false;
constructor(
private readonly host: ReactiveControllerHost,
private readonly rowHeightCache?: TranscriptRowHeightCache,
) {
constructor(private readonly host: ReactiveControllerHost) {
host.addController(this);
}
@@ -695,8 +697,6 @@ export class ChatTranscriptController implements ReactiveController {
this.activeSessionKey = sessionKey;
this.sessionVirtualizer = new ChatSessionVirtualizerHost(
this.host,
sessionKey,
this.rowHeightCache,
initialOffset,
initialOffset === null
? undefined
@@ -80,10 +80,9 @@ describe("persistent chat session snapshots", () => {
vi.unstubAllGlobals();
});
it("shares sanitized snapshots and measured row heights across store owners", async () => {
it("shares sanitized snapshots across store owners", async () => {
const writer = new SessionSnapshotStore();
writer.write("agent:main:shared", snapshot({ text: "cached", callback: () => true }));
writer.recordRowHeight("agent:main:shared", "message:1", 184);
const savedAt = writer.readSavedAt("agent:main:shared");
expect(savedAt).not.toBeNull();
await writer.flush();
@@ -93,7 +92,6 @@ describe("persistent chat session snapshots", () => {
await reader.loadSavedAtIndex();
expect(await reader.read("agent:main:shared")).toEqual(snapshot({ text: "cached" }));
expect(reader.readSavedAt("agent:main:shared")).toBe(savedAt);
expect(reader.readRowHeight("agent:main:shared", "message:1")).toBe(184);
});
it("seeds the savedAt index once for every synchronous lookup", async () => {
@@ -120,8 +118,6 @@ describe("persistent chat session snapshots", () => {
await writer.flush();
writer.write(sessionKey, snapshot(1n));
writer.recordRowHeight(sessionKey, "message:1", 184);
expect(writer.readRowHeight(sessionKey, "message:1")).toBe(184);
await writer.flush();
expect(await new SessionSnapshotStore().read(sessionKey)).toBeNull();
@@ -188,7 +184,6 @@ describe("persistent chat session snapshots", () => {
sessionId: "session-1",
savedAt: Date.now(),
snapshot: { messages: "not-an-array" },
rowHeights: new Map(),
});
const reader = new SessionSnapshotStore();
+5 -56
View File
@@ -20,7 +20,6 @@ import {
CHAT_SNAPSHOT_STORE_NAME,
deleteStoredChatSnapshot,
} from "./session-snapshot-invalidation.ts";
const MAX_STORED_ROW_HEIGHTS = 500;
const CHAT_SNAPSHOT_WRITE_DELAY_MS = 500;
const paginationSchema = z.discriminatedUnion("hasMore", [
@@ -49,13 +48,8 @@ const snapshotSchema = z
})
.strict();
const rowHeightsSchema = z
.map(z.string(), z.number().finite().positive())
.refine((rowHeights) => rowHeights.size <= MAX_STORED_ROW_HEIGHTS);
const recordSchema = z
.object({
rowHeights: rowHeightsSchema,
savedAt: z.number().finite().nonnegative(),
sessionId: z.string().nullable(),
sessionKey: z.string().min(1),
@@ -65,11 +59,10 @@ const recordSchema = z
.refine((record) => record.sessionId === record.snapshot.sessionId);
type SessionSnapshotRecord = z.infer<typeof recordSchema>;
type StoredSessionState = {
rowHeights: Map<string, number>;
type PendingSessionState = {
savedAt: number;
snapshot: ChatSessionSnapshot;
};
type PendingSessionState = StoredSessionState & { savedAt: number };
const activeStores = new Set<SessionSnapshotStore>();
let snapshotStoreGeneration = 0;
@@ -128,7 +121,6 @@ function createSnapshotRecord(
return null;
}
const parsed = recordSchema.safeParse({
rowHeights: pending.rowHeights,
savedAt: pending.savedAt,
sessionId: pending.snapshot.sessionId,
sessionKey,
@@ -202,7 +194,6 @@ function measureStoredRecordWeight(record: SessionSnapshotRecord): number {
return (
snapshotWeight +
JSON.stringify({
rowHeights: [...record.rowHeights],
savedAt: record.savedAt,
sessionId: record.sessionId,
sessionKey: record.sessionKey,
@@ -269,7 +260,6 @@ async function writeSnapshotRecords(
export class SessionSnapshotStore implements ChatCacheObserver {
private connected = false;
private readonly sessions = new Map<string, StoredSessionState>();
private readonly pending = new Map<string, PendingSessionState>();
private readonly hydratedSnapshots = new Map<string, ChatSessionSnapshot>();
private readonly revisions = new Map<string, number>();
@@ -307,14 +297,6 @@ export class SessionSnapshotStore implements ChatCacheObserver {
) {
return null;
}
// A network write can win while IndexedDB is pending. Keep its snapshot and
// measurements authoritative even though the caller will discard this read.
if (!this.sessions.has(sessionKey)) {
setSessionCacheValue(this.sessions, sessionKey, {
rowHeights: new Map(record.rowHeights),
snapshot: record.snapshot,
});
}
setSessionCacheValue(this.hydratedSnapshots, sessionKey, record.snapshot);
return record.snapshot;
}
@@ -334,15 +316,9 @@ export class SessionSnapshotStore implements ChatCacheObserver {
return;
}
this.hydratedSnapshots.delete(sessionKey);
const existing = getSessionCacheValue(this.sessions, sessionKey);
// Cache reconciliation replaces snapshots immutably, so retaining this raw
// reference until the debounced flush cannot observe in-place mutation.
const state = {
rowHeights: existing?.rowHeights ?? new Map<string, number>(),
snapshot,
};
setSessionCacheValue(this.sessions, sessionKey, state);
this.schedule(sessionKey, state);
this.schedule(sessionKey, snapshot);
}
async delete(sessionKey: string): Promise<void> {
@@ -355,34 +331,9 @@ export class SessionSnapshotStore implements ChatCacheObserver {
this.revisions.set(sessionKey, (this.revisions.get(sessionKey) ?? 0) + 1);
this.pending.delete(sessionKey);
this.hydratedSnapshots.delete(sessionKey);
this.sessions.delete(sessionKey);
this.savedAtBySession.delete(sessionKey);
}
readRowHeight(sessionKey: string, rowKey: string): number | undefined {
return getSessionCacheValue(this.sessions, sessionKey)?.rowHeights.get(rowKey);
}
recordRowHeight(sessionKey: string, rowKey: string, height: number): void {
if (!Number.isFinite(height) || height <= 0) {
return;
}
const state = getSessionCacheValue(this.sessions, sessionKey);
if (!state || state.rowHeights.get(rowKey) === height) {
return;
}
state.rowHeights.delete(rowKey);
state.rowHeights.set(rowKey, height);
while (state.rowHeights.size > MAX_STORED_ROW_HEIGHTS) {
const oldest = state.rowHeights.keys().next().value;
if (typeof oldest !== "string") {
break;
}
state.rowHeights.delete(oldest);
}
this.schedule(sessionKey, state);
}
async flush(): Promise<void> {
if (this.writeTimer !== null) {
globalThis.clearTimeout(this.writeTimer);
@@ -422,7 +373,6 @@ export class SessionSnapshotStore implements ChatCacheObserver {
}
this.pending.clear();
this.hydratedSnapshots.clear();
this.sessions.clear();
this.revisions.clear();
this.savedAtBySession.clear();
this.memoryCache?.clear();
@@ -432,11 +382,10 @@ export class SessionSnapshotStore implements ChatCacheObserver {
await this.writeChain;
}
private schedule(sessionKey: string, state: StoredSessionState): void {
private schedule(sessionKey: string, snapshot: ChatSessionSnapshot): void {
const pending = {
rowHeights: new Map(state.rowHeights),
savedAt: Date.now(),
snapshot: state.snapshot,
snapshot,
};
this.pending.set(sessionKey, pending);
this.savedAtBySession.set(sessionKey, pending.savedAt);