mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
de41c7de8c
commit
a0c5937fd1
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user