From a0c5937fd19debeaad89f7a9869e69b524dbdd5a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 17 Aug 2026 20:33:05 -0700 Subject: [PATCH] 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 --- .../chat-history-stale-geometry.e2e.test.ts | 194 ++++++++++++++++++ ui/src/pages/chat/chat-pane-base.ts | 29 +-- ui/src/pages/chat/chat-view.test-helpers.ts | 19 +- .../chat-transcript-controller.test.ts | 53 +++-- .../components/chat-transcript-controller.ts | 64 +++--- .../pages/chat/session-snapshot-store.test.ts | 7 +- ui/src/pages/chat/session-snapshot-store.ts | 61 +----- 7 files changed, 265 insertions(+), 162 deletions(-) create mode 100644 ui/src/e2e/chat-history-stale-geometry.e2e.test.ts diff --git a/ui/src/e2e/chat-history-stale-geometry.e2e.test.ts b/ui/src/e2e/chat-history-stale-geometry.e2e.test.ts new file mode 100644 index 000000000000..72f19728d97e --- /dev/null +++ b/ui/src/e2e/chat-history-stale-geometry.e2e.test.ts @@ -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((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((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).rowHeights = new Map( + keys.map((key) => [key, 1_000]), + ); + store.put(record); + await new Promise((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); +}); diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index a40fe1a2d757..acf825b48b0d 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -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; diff --git a/ui/src/pages/chat/chat-view.test-helpers.ts b/ui/src/pages/chat/chat-view.test-helpers.ts index 75576f3c7950..24d9f776c997 100644 --- a/ui/src/pages/chat/chat-view.test-helpers.ts +++ b/ui/src/pages/chat/chat-view.test-helpers.ts @@ -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[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( diff --git a/ui/src/pages/chat/components/chat-transcript-controller.test.ts b/ui/src/pages/chat/components/chat-transcript-controller.test.ts index 4cf2b20fadb5..454aa108bb65 100644 --- a/ui/src/pages/chat/components/chat-transcript-controller.test.ts +++ b/ui/src/pages/chat/components/chat-transcript-controller.test.ts @@ -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(); - 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(".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(); diff --git a/ui/src/pages/chat/components/chat-transcript-controller.ts b/ui/src/pages/chat/components/chat-transcript-controller.ts index fb9440c6df53..efad15fc038d 100644 --- a/ui/src/pages/chat/components/chat-transcript-controller.ts +++ b/ui/src/pages/chat/components/chat-transcript-controller.ts @@ -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 diff --git a/ui/src/pages/chat/session-snapshot-store.test.ts b/ui/src/pages/chat/session-snapshot-store.test.ts index cb59632ad0eb..e3aa08b9c1b4 100644 --- a/ui/src/pages/chat/session-snapshot-store.test.ts +++ b/ui/src/pages/chat/session-snapshot-store.test.ts @@ -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(); diff --git a/ui/src/pages/chat/session-snapshot-store.ts b/ui/src/pages/chat/session-snapshot-store.ts index 534016f08a79..72eeabdb7287 100644 --- a/ui/src/pages/chat/session-snapshot-store.ts +++ b/ui/src/pages/chat/session-snapshot-store.ts @@ -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; -type StoredSessionState = { - rowHeights: Map; +type PendingSessionState = { + savedAt: number; snapshot: ChatSessionSnapshot; }; -type PendingSessionState = StoredSessionState & { savedAt: number }; const activeStores = new Set(); 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(); private readonly pending = new Map(); private readonly hydratedSnapshots = new Map(); private readonly revisions = new Map(); @@ -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(), - snapshot, - }; - setSessionCacheValue(this.sessions, sessionKey, state); - this.schedule(sessionKey, state); + this.schedule(sessionKey, snapshot); } async delete(sessionKey: string): Promise { @@ -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 { 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);