Stabilize chat prepend scrolling

This commit is contained in:
Federico Jaramillo Martinez
2026-05-12 09:46:14 +02:00
parent 70b079d3c3
commit 14657d26a9
7 changed files with 181 additions and 42 deletions
+7 -2
View File
@@ -40,8 +40,13 @@ describe("chat history loading decisions", () => {
expect(doesNotFillViewport({ scrollHeight: 502, clientHeight: 500 })).toBe(false);
});
it("uses the larger of the default threshold and viewport height", () => {
expect(isNearTop({ scrollTop: 700, clientHeight: 800 })).toBe(true);
expect(isNearTop({ scrollTop: 800, clientHeight: 800 })).toBe(false);
});
it("allows a custom top threshold", () => {
expect(isNearTop({ scrollTop: 80, topThreshold: 100 })).toBe(true);
expect(isNearTop({ scrollTop: 100, topThreshold: 100 })).toBe(false);
expect(isNearTop({ scrollTop: 80, clientHeight: 500, topThreshold: 100 })).toBe(true);
expect(isNearTop({ scrollTop: 100, clientHeight: 500, topThreshold: 100 })).toBe(false);
});
});
+3 -3
View File
@@ -8,7 +8,7 @@ export interface ChatHistoryLoadState {
topThreshold?: number;
}
const DEFAULT_TOP_THRESHOLD = 64;
const DEFAULT_TOP_THRESHOLD = 600;
const VIEWPORT_FILL_TOLERANCE = 1;
export function shouldRequestEarlierMessages(state: ChatHistoryLoadState): boolean {
@@ -16,8 +16,8 @@ export function shouldRequestEarlierMessages(state: ChatHistoryLoadState): boole
return isNearTop(state) || doesNotFillViewport(state);
}
export function isNearTop(state: Pick<ChatHistoryLoadState, "scrollTop" | "topThreshold">): boolean {
return state.scrollTop < (state.topThreshold ?? DEFAULT_TOP_THRESHOLD);
export function isNearTop(state: Pick<ChatHistoryLoadState, "scrollTop" | "clientHeight" | "topThreshold">): boolean {
return state.scrollTop < (state.topThreshold ?? Math.max(DEFAULT_TOP_THRESHOLD, state.clientHeight));
}
export function doesNotFillViewport(state: Pick<ChatHistoryLoadState, "scrollHeight" | "clientHeight">): boolean {
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { scrollDeltaForMarker, scrollTopForBottomDistance, selectPrependMarker } from "./chatScrollAnchoring";
describe("chat scroll anchoring", () => {
it("selects the nearest marker at or above the viewport top", () => {
expect(selectPrependMarker([
{ id: "below", offset: 20 },
{ id: "above-far", offset: -50 },
{ id: "above-near", offset: -2 },
])).toEqual({ id: "above-near", offset: -2 });
});
it("falls back to the nearest marker below the viewport top", () => {
expect(selectPrependMarker([
{ id: "below-far", offset: 80 },
{ id: "below-near", offset: 12 },
])).toEqual({ id: "below-near", offset: 12 });
});
it("returns undefined when there are no markers", () => {
expect(selectPrependMarker([])).toBeUndefined();
});
it("computes the scroll delta needed to keep a marker at the same offset", () => {
expect(scrollDeltaForMarker(150, 40)).toBe(110);
});
it("computes fallback scrollTop from bottom distance", () => {
expect(scrollTopForBottomDistance(1000, 250)).toBe(750);
expect(scrollTopForBottomDistance(100, 250)).toBe(0);
});
});
+60
View File
@@ -0,0 +1,60 @@
export interface PrependScrollAnchor {
distanceFromBottom: number;
markerId?: string;
markerOffset?: number;
}
export interface MarkerMeasurement {
id: string;
offset: number;
}
export const PREPEND_RESTORE_SETTLE_FRAMES = 30;
export function capturePrependScrollAnchor(scroller: HTMLElement, markers: HTMLElement[]): PrependScrollAnchor {
const marker = selectPrependMarker(measureMarkers(scroller, markers));
const base = { distanceFromBottom: scroller.scrollHeight - scroller.scrollTop };
return marker === undefined ? base : { ...base, markerId: marker.id, markerOffset: marker.offset };
}
export function restorePrependScrollAnchor(scroller: HTMLElement, anchor: PrependScrollAnchor, marker: HTMLElement | undefined): void {
if (marker !== undefined && anchor.markerOffset !== undefined) {
const markerOffset = marker.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
scroller.scrollTop += scrollDeltaForMarker(markerOffset, anchor.markerOffset);
return;
}
scroller.scrollTop = scrollTopForBottomDistance(scroller.scrollHeight, anchor.distanceFromBottom);
}
export function selectPrependMarker(markers: MarkerMeasurement[]): MarkerMeasurement | undefined {
let nearestAbove: MarkerMeasurement | undefined;
let nearestAboveOffset = Number.NEGATIVE_INFINITY;
let nearestBelow: MarkerMeasurement | undefined;
let nearestBelowOffset = Number.POSITIVE_INFINITY;
for (const marker of markers) {
if (marker.offset <= 0 && marker.offset >= nearestAboveOffset) {
nearestAbove = marker;
nearestAboveOffset = marker.offset;
} else if (marker.offset > 0 && marker.offset < nearestBelowOffset) {
nearestBelow = marker;
nearestBelowOffset = marker.offset;
}
}
return nearestAbove ?? nearestBelow;
}
export function scrollDeltaForMarker(currentOffset: number, previousOffset: number): number {
return currentOffset - previousOffset;
}
export function scrollTopForBottomDistance(scrollHeight: number, distanceFromBottom: number): number {
return Math.max(0, scrollHeight - distanceFromBottom);
}
function measureMarkers(scroller: HTMLElement, markers: HTMLElement[]): MarkerMeasurement[] {
const scrollerTop = scroller.getBoundingClientRect().top;
return markers.flatMap((marker) => {
const id = marker.dataset["markerId"];
return id === undefined ? [] : [{ id, offset: marker.getBoundingClientRect().top - scrollerTop }];
});
}
+71 -27
View File
@@ -2,19 +2,13 @@ import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { groupChatMessages, summarizeChatGroup } from "../chatGroups";
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import type { SessionActivity, SessionStatus } from "../api";
import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared";
import "./FormattedText";
interface PrependScrollAnchor {
scrollTop: number;
scrollHeight: number;
key?: string;
offset?: number;
}
function isScrollPosition(value: unknown): value is { index?: number; key?: string; offset: number } {
return typeof value === "object"
&& value !== null
@@ -43,6 +37,7 @@ export class ChatView extends LitElement {
@state() private expandedMetaKey: string | undefined;
@state() private copiedMessageKey: string | undefined;
private suppressScrollSave = false;
private suppressLoadMoreRequests = false;
private saveScrollTimer?: number;
private lastScrollTop = 0;
private lastClientHeight = 0;
@@ -75,6 +70,12 @@ export class ChatView extends LitElement {
if (changed.has("messages")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
}
protected override update(changed: Map<string, unknown>): void {
const prependAnchor = this.isPrependingMessages(changed) ? this.capturePrependScrollAnchor() : undefined;
super.update(changed);
if (prependAnchor !== undefined) this.restorePrependScrollAnchor(prependAnchor);
}
protected override updated(changed: Map<string, unknown>): void {
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
@@ -206,6 +207,7 @@ export class ChatView extends LitElement {
private renderMessage(message: ChatLine, index: number) {
return html`
${this.renderScrollMarker(this.messageScrollMarkerId(index))}
<article class="msg ${message.role}" data-index=${index} data-anchor-key=${this.messageAnchorKey(index)}>
${this.renderMessageHeader(message, String(index))}
${message.parts.map((part) => this.renderPart(part, message))}
@@ -216,6 +218,7 @@ export class ChatView extends LitElement {
private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number) {
const key = this.groupKey(endIndex);
return html`
${this.renderScrollMarker(this.groupScrollMarkerId(endIndex))}
<details class="msg event-group" data-index=${startIndex} data-anchor-key=${this.groupAnchorKey(endIndex)} ?open=${this.openGroupKeys.has(key)} @toggle=${(event: Event) => { this.onGroupToggle(key, event); }}>
<summary>
<b class="label">events</b>
@@ -233,6 +236,10 @@ export class ChatView extends LitElement {
`;
}
private renderScrollMarker(markerId: string) {
return html`<span class="scroll-marker" data-marker-id=${markerId} aria-hidden="true"></span>`;
}
private renderMessageHeader(message: ChatLine, key: string) {
const meta = this.messageMetaLabel(message);
const expanded = this.expandedMetaKey === key;
@@ -402,8 +409,14 @@ export class ChatView extends LitElement {
return chat !== undefined && this.lastClientHeight !== 0 && chat.clientHeight !== this.lastClientHeight;
}
private isPrependingMessages(changed: Map<string, unknown>): boolean {
const oldMessageStart = changed.get("messageStart");
return typeof oldMessageStart === "number" && this.messageStart < oldMessageStart;
}
private requestLoadMoreIfNeeded(): void {
requestAnimationFrame(() => {
if (this.suppressLoadMoreRequests) return;
const chat = this.chat;
if (!chat) return;
if (shouldRequestEarlierMessages({
@@ -491,31 +504,33 @@ export class ChatView extends LitElement {
capturePrependScrollAnchor(): PrependScrollAnchor | undefined {
const chat = this.chat;
if (!chat) return undefined;
const firstVisible = this.firstVisibleArticle();
if (!firstVisible) return { scrollTop: chat.scrollTop, scrollHeight: chat.scrollHeight };
const chatTop = chat.getBoundingClientRect().top;
const key = firstVisible.dataset["anchorKey"];
const anchor = { scrollTop: chat.scrollTop, scrollHeight: chat.scrollHeight };
return key === undefined
? anchor
: { ...anchor, key, offset: firstVisible.getBoundingClientRect().top - chatTop };
return capturePrependScrollAnchor(chat, this.scrollMarkers());
}
restorePrependScrollAnchor(anchor: PrependScrollAnchor | undefined): void {
if (!this.chat || !anchor) return;
this.suppressLoadMoreRequests = true;
this.suppressScrollSave = true;
let frames = 0;
const settle = () => {
const chat = this.chat;
if (!chat || !anchor) return;
this.withSuppressedScrollSave(() => {
const article = anchor.key === undefined ? undefined : this.articleAt({ key: anchor.key });
if (article !== undefined && anchor.offset !== undefined) {
const chatTop = chat.getBoundingClientRect().top;
const currentOffset = article.getBoundingClientRect().top - chatTop;
chat.scrollTop += currentOffset - anchor.offset;
} else {
chat.scrollTop = anchor.scrollTop + (chat.scrollHeight - anchor.scrollHeight);
}
if (!chat) return;
restorePrependScrollAnchor(chat, anchor, anchor.markerId === undefined ? undefined : this.scrollMarkerAt(anchor.markerId));
this.lastScrollTop = chat.scrollTop;
frames += 1;
// Formatted markdown/code layout can settle after Lit's first render. Re-apply
// the marker anchor briefly so late height changes above the viewport do not
// move the user's reading position.
if (frames < PREPEND_RESTORE_SETTLE_FRAMES) {
requestAnimationFrame(settle);
return;
}
requestAnimationFrame(() => {
this.suppressScrollSave = false;
this.suppressLoadMoreRequests = false;
});
this.requestLoadMoreIfNeeded();
};
settle();
}
saveScrollPosition(sessionId = this.sessionId) {
@@ -561,14 +576,25 @@ export class ChatView extends LitElement {
}
}
private scrollMarkers(): HTMLElement[] {
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>(".scroll-marker"));
}
private scrollMarkerAt(markerId: string): HTMLElement | undefined {
return this.scrollMarkers().find((marker) => marker.dataset["markerId"] === markerId);
}
private firstVisibleArticle(): HTMLElement | undefined {
const chat = this.chat;
if (!chat) return undefined;
const firstVisible = (selector: string) => {
const chatRect = chat.getBoundingClientRect();
return this.articles().find((article) => {
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>(selector)).find((article) => {
const rect = article.getBoundingClientRect();
return rect.bottom >= chatRect.top && rect.top <= chatRect.bottom;
});
};
return firstVisible("article.msg") ?? firstVisible("article.msg, details.msg");
}
private articleAt(position: { index?: number; key?: string }): HTMLElement | undefined {
@@ -592,6 +618,16 @@ export class ChatView extends LitElement {
});
}
private withSuppressedLoadMoreRequests(callback: () => void) {
this.suppressLoadMoreRequests = true;
callback();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.suppressLoadMoreRequests = false;
});
});
}
private storageKey(sessionId = this.sessionId): string {
return `pi-web:chat-scroll:${sessionId}`;
}
@@ -612,6 +648,14 @@ export class ChatView extends LitElement {
return `g:${String(endIndex)}`;
}
private messageScrollMarkerId(index: number): string {
return `m:${String(index)}`;
}
private groupScrollMarkerId(endIndex: number): string {
return `g:${String(endIndex)}`;
}
private readOpenGroupKeys(): Set<string> {
if (this.sessionId === "") return new Set();
try {
-3
View File
@@ -141,12 +141,9 @@ export class PiWebApp extends LitElement {
}
private async withChatPrependTransition(action: () => Promise<void>) {
const anchor = this.chatView?.capturePrependScrollAnchor();
await action();
await this.updateComplete;
await this.chatView?.updateComplete;
await nextFrame();
this.chatView?.restorePrependScrollAnchor(anchor);
}
private updateUrl(options?: { replace?: boolean | undefined }) {
+2 -1
View File
@@ -145,7 +145,8 @@ export const listStyles = css`
export const chatStyles = css`
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: #e6edf3; font: 14px system-ui, sans-serif; }
.chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.chat { height: 100%; min-height: 0; overflow: auto; padding: 16px 16px 64px; box-sizing: border-box; }
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 16px 16px 64px; box-sizing: border-box; }
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
.history-indicator { position: absolute; top: 10px; right: 18px; z-index: 2; display: grid; gap: 2px; max-width: min(320px, calc(100% - 36px)); border: 1px solid #30363d; border-radius: 8px; background: #0d1117dd; color: #8b949e; padding: 6px 8px; font-size: 12px; text-align: right; pointer-events: none; box-shadow: 0 8px 24px #0006; }
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 3; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid #30363d; border-radius: 999px; background: #0d1117e6; color: #8b949e; padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px #0008; backdrop-filter: blur(6px); }
.activity-dock.active { border-color: #238636; color: #3fb950; background: #0f1b12ee; }