Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/__tests__/pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ await activateTestConversation(threadId);
expect(getTestThreadOldestLoadedSequence(threadId)).toBe(1);
expect(getTestThreadHasMoreMessages(threadId)).toBe(false);
expect(getTestThreadIsLoadingMore(threadId)).toBe(false);
expect(mockTransport.getMessages).toHaveBeenCalledWith(threadId, 50, 51);
expect(mockTransport.getMessages).toHaveBeenCalledWith(threadId, 25, 51);
});

it("loadOlderMessages is a no-op when hasMore is false", async () => {
Expand Down Expand Up @@ -541,7 +541,7 @@ await activateTestConversation(threadId);

await useThreadStore.getState().loadOlderMessages(threadId);

expect(mockTransport.loadConversationPage).toHaveBeenCalledWith(threadId, 50, 5);
expect(mockTransport.loadConversationPage).toHaveBeenCalledWith(threadId, 25, 5);
expect(getTestActiveMessages()).toEqual([
older,
residentAtSharedSequence,
Expand Down
32 changes: 17 additions & 15 deletions apps/web/src/__tests__/threadStore-message-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ await activateTestConversation("t1");
] satisfies NarrativeEntry[]);

await activateTestConversation("t1");
expect(useThreadStore.getState().isNarrativeLoaded("t1", assistant.id)).toBe(false);
// Activation prefetches tail detail, so the window is already resident.
expect(useThreadStore.getState().isNarrativeLoaded("t1", assistant.id)).toBe(true);

await useThreadStore.getState().loadNarrativeForMessage(assistant.id, "t1");

Expand Down Expand Up @@ -166,8 +167,8 @@ await activateTestConversation("t1");
] satisfies NarrativeEntry[]);

await activateTestConversation("t1");
await useThreadStore.getState().loadNarrativeForMessage(assistant.id, "t1");

// The activation prefetch consumes the first window and leaves the cursor.
expect(mockTransport.loadTurn).toHaveBeenCalledTimes(1);
expect(useThreadStore.getState().isNarrativeLoaded("t1", assistant.id)).toBe(false);

Expand Down Expand Up @@ -300,7 +301,7 @@ await activateTestConversation("t1");
.mockResolvedValueOnce(tooLargeWindow);

await activateTestConversation("t1");
await useThreadStore.getState().loadNarrativeForMessage(assistant.id, "t1");
// The activation prefetch consumed the first window; this requests the next.
await useThreadStore.getState().loadNarrativeForMessage(assistant.id, "t1");

const record = getThreadRecord(useThreadStore.getState().records, "t1");
Expand Down Expand Up @@ -368,8 +369,8 @@ await activateTestConversation("t1");
expect(useThreadStore.getState().isNarrativeLoaded("t1", assistant.id)).toBe(false);
});

it("keeps detail through a virtual-row handoff and evicts after the final row releases", async () => {
const assistant = createMockMessage({ id: "assistant-lease", thread_id: "t1", role: "assistant" });
it("retains loaded detail across a thread switch round trip", async () => {
const assistant = createMockMessage({ id: "assistant-retain", thread_id: "t1", role: "assistant" });
(mockTransport.loadConversationPage as ReturnType<typeof vi.fn>).mockResolvedValue({
messages: [assistant], hasMore: false, narrativeByMessage: {},
});
Expand All @@ -379,18 +380,19 @@ await activateTestConversation("t1");

await activateTestConversation("t1");
await useThreadStore.getState().loadNarrativeForMessage(assistant.id, "t1");
useThreadStore.getState().retainNarrativeForMessage(assistant.id, "t1");
useThreadStore.getState().retainNarrativeForMessage(assistant.id, "t1");
useThreadStore.getState().releaseNarrativeForMessage(assistant.id, "t1");
await Promise.resolve();

expect(getThreadRecord(useThreadStore.getState().records, "t1").narrativeByMessage[assistant.id]).toBeDefined();
expect(useThreadStore.getState().isNarrativeLoaded("t1", assistant.id)).toBe(true);
const detailCalls = vi.mocked(mockTransport.loadTurn).mock.calls.length;

useThreadStore.getState().releaseNarrativeForMessage(assistant.id, "t1");
await Promise.resolve();
useThreadStore.setState((s) => ({
currentThreadId: "t2",
records: patchThreadRecord(s.records, "t2", { messages: [] }),
}));
await activateTestConversation("t1");

expect(getThreadRecord(useThreadStore.getState().records, "t1").narrativeByMessage[assistant.id]).toBeUndefined();
expect(useThreadStore.getState().isNarrativeLoaded("t1", assistant.id)).toBe(false);
const record = getThreadRecord(useThreadStore.getState().records, "t1");
expect(record.narrativeByMessage[assistant.id]).toBeDefined();
expect(useThreadStore.getState().isNarrativeLoaded("t1", assistant.id)).toBe(true);
expect(vi.mocked(mockTransport.loadTurn).mock.calls.length).toBe(detailCalls);
});

it("on cache hit, does not call conversation.page and renders from cache", async () => {
Expand Down
48 changes: 43 additions & 5 deletions apps/web/src/components/ui/virtual-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class VirtualViewport {
private expectedScrollTop = 0;
private disposed = false;
private animating = false;
private pinnedAnchor: { key: string; top: number } | undefined;

constructor(
container: HTMLElement,
Expand Down Expand Up @@ -84,7 +85,7 @@ export class VirtualViewport {
},
hooks: {
onCommit: () => this.publishVisible(),
onResize: () => this.applyPosition(),
onResize: () => this.applyPosition(true),
},
}]);
this.viewport = this.context.dom.viewport;
Expand Down Expand Up @@ -146,7 +147,7 @@ export class VirtualViewport {
if (!this.rowIndexes.has(key)) this.heights.delete(key);
}
this.list.setItems([...this.rows]);
this.applyPosition();
this.applyPosition(true);
if (restoreFocus && focused.isConnected && document.activeElement !== focused) {
focused.focus({ preventScroll: true });
}
Expand Down Expand Up @@ -179,7 +180,7 @@ export class VirtualViewport {
if (this.disposed) return;
this.context.rebuildSizeCache();
this.context.updateContentSize(this.context.sizeCache.getTotalSize());
this.applyPosition();
this.applyPosition(true);
});
}

Expand All @@ -192,20 +193,44 @@ export class VirtualViewport {
return true;
}

private applyPosition(): void {
private applyPosition(correctAnchor = false): void {
if (this.disposed || !this.viewport) return;
const anchor = correctAnchor ? this.readingAnchorHost() : undefined;
const target = Math.max(0, this.targetScrollTop());
// A short initial page needs its trailing space retained when history is prepended.
this.context.updateContentSize(Math.max(
this.context.sizeCache.getTotalSize(),
this.position.kind === "reading" ? target + this.viewport.clientHeight : 0,
));
if (!this.animating) this.context.scrollTo(target);
if (!this.animating) {
this.context.scrollTo(target);
this.correctAnchorDrift(anchor);
}
this.expectedScrollTop = this.viewport.scrollTop;
this.context.forceRender();
this.onPosition(this.position);
}

/** The reading row's live host plus the screen position it must keep. */
private readingAnchorHost(): { host: HTMLDivElement; top: number } | undefined {
if (this.position.kind !== "reading") return undefined;
const host = this.hosts.get(this.position.key);
// Content changes push the anchored row between measurement passes, so the
// pin must come from the user's last scroll, not the current drifted rect.
const pinned = this.pinnedAnchor;
const top = pinned?.key === this.position.key
? pinned.top
: host?.isConnected ? host.getBoundingClientRect().top : undefined;
return host && top !== undefined ? { host, top } : undefined;
}

/** Sizes resolve through provisional heights; the host's real rect is truth. */
private correctAnchorDrift(anchor: { host: HTMLDivElement; top: number } | undefined): void {
if (!anchor?.host.isConnected) return;
const drift = anchor.host.getBoundingClientRect().top - anchor.top;
if (drift !== 0) this.viewport.scrollTop += drift;
}

private targetScrollTop(): number {
const cache = this.context.sizeCache;
let top = this.viewport.scrollTop;
Expand Down Expand Up @@ -240,6 +265,7 @@ export class VirtualViewport {
this.context.cancelScroll();
this.animating = false;
this.position = this.readingPosition();
this.pinReadingAnchor();
this.onPosition(this.position);
};

Expand All @@ -264,10 +290,18 @@ export class VirtualViewport {
const atEnd = this.viewport.scrollHeight - this.viewport.clientHeight - top <= 2;
const anchor = this.readingPosition();
this.position = this.options.positionOnScroll?.(anchor, atEnd) ?? anchor;
this.pinReadingAnchor();
this.expectedScrollTop = top;
this.onPosition(this.position);
};

private pinReadingAnchor(): void {
const host = this.position.kind === "reading" ? this.hosts.get(this.position.key) : undefined;
this.pinnedAnchor = this.position.kind === "reading" && host?.isConnected
? { key: this.position.key, top: host.getBoundingClientRect().top }
: undefined;
}

/** Captures the first visible row independently of React's commit timing. */
getReadingAnchor(): { key: string; offset: number } | undefined {
const position = this.readingPosition();
Expand All @@ -283,22 +317,26 @@ export class VirtualViewport {
this.context.smoothScrollTo(() => Math.max(0, this.targetScrollTop()), 250, undefined, () => {
this.animating = false;
this.applyPosition();
this.pinReadingAnchor();
});
}
this.applyPosition();
this.pinReadingAnchor();
}

/** Restores an absolute offset and captures its row anchor. */
restoreOffset(top: number): void {
this.context.scrollTo(top);
this.position = this.readingPosition();
this.applyPosition();
this.pinReadingAnchor();
}

/** Compensates for a layout inset changing the viewport's screen position. */
shiftReadingPosition(delta: number): void {
if (this.position.kind !== "reading" || delta === 0) return;
this.position = { ...this.position, offset: this.position.offset + delta };
if (this.pinnedAnchor) this.pinnedAnchor = { ...this.pinnedAnchor, top: this.pinnedAnchor.top + delta };
this.applyPosition();
}

Expand Down
Loading
Loading