From 14657d26a9ecbd048bb66f45746a8de44a90416c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 12 May 2026 09:46:14 +0200 Subject: [PATCH] Stabilize chat prepend scrolling --- src/client/src/chatHistoryLoading.test.ts | 9 +- src/client/src/chatHistoryLoading.ts | 6 +- src/client/src/chatScrollAnchoring.test.ts | 32 ++++++ src/client/src/chatScrollAnchoring.ts | 60 +++++++++++ src/client/src/components/ChatView.ts | 110 ++++++++++++++------- src/client/src/components/PiWebApp.ts | 3 - src/client/src/components/shared.ts | 3 +- 7 files changed, 181 insertions(+), 42 deletions(-) create mode 100644 src/client/src/chatScrollAnchoring.test.ts create mode 100644 src/client/src/chatScrollAnchoring.ts diff --git a/src/client/src/chatHistoryLoading.test.ts b/src/client/src/chatHistoryLoading.test.ts index a2e4cf7..4fb8102 100644 --- a/src/client/src/chatHistoryLoading.test.ts +++ b/src/client/src/chatHistoryLoading.test.ts @@ -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); }); }); diff --git a/src/client/src/chatHistoryLoading.ts b/src/client/src/chatHistoryLoading.ts index d1f582d..e03b02f 100644 --- a/src/client/src/chatHistoryLoading.ts +++ b/src/client/src/chatHistoryLoading.ts @@ -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): boolean { - return state.scrollTop < (state.topThreshold ?? DEFAULT_TOP_THRESHOLD); +export function isNearTop(state: Pick): boolean { + return state.scrollTop < (state.topThreshold ?? Math.max(DEFAULT_TOP_THRESHOLD, state.clientHeight)); } export function doesNotFillViewport(state: Pick): boolean { diff --git a/src/client/src/chatScrollAnchoring.test.ts b/src/client/src/chatScrollAnchoring.test.ts new file mode 100644 index 0000000..b74b414 --- /dev/null +++ b/src/client/src/chatScrollAnchoring.test.ts @@ -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); + }); +}); diff --git a/src/client/src/chatScrollAnchoring.ts b/src/client/src/chatScrollAnchoring.ts new file mode 100644 index 0000000..64c645f --- /dev/null +++ b/src/client/src/chatScrollAnchoring.ts @@ -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 }]; + }); +} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 8ba8be1..193c920 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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): void { + const prependAnchor = this.isPrependingMessages(changed) ? this.capturePrependScrollAnchor() : undefined; + super.update(changed); + if (prependAnchor !== undefined) this.restorePrependScrollAnchor(prependAnchor); + } + protected override updated(changed: Map): 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))}
${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))}
{ this.onGroupToggle(key, event); }}> events @@ -233,6 +236,10 @@ export class ChatView extends LitElement { `; } + private renderScrollMarker(markerId: string) { + return html``; + } + 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): 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 { - 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 (!this.chat || !anchor) return; + this.suppressLoadMoreRequests = true; + this.suppressScrollSave = true; + let frames = 0; + const settle = () => { + const chat = this.chat; + if (!chat) return; + restorePrependScrollAnchor(chat, anchor, anchor.markerId === undefined ? undefined : this.scrollMarkerAt(anchor.markerId)); this.lastScrollTop = chat.scrollTop; - }); - this.requestLoadMoreIfNeeded(); + 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; + }); + }; + settle(); } saveScrollPosition(sessionId = this.sessionId) { @@ -561,14 +576,25 @@ export class ChatView extends LitElement { } } + private scrollMarkers(): HTMLElement[] { + return Array.from(this.renderRoot.querySelectorAll(".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 chatRect = chat.getBoundingClientRect(); - return this.articles().find((article) => { - const rect = article.getBoundingClientRect(); - return rect.bottom >= chatRect.top && rect.top <= chatRect.bottom; - }); + const firstVisible = (selector: string) => { + const chatRect = chat.getBoundingClientRect(); + return Array.from(this.renderRoot.querySelectorAll(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 { if (this.sessionId === "") return new Set(); try { diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 68a3d97..096c4fb 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -141,12 +141,9 @@ export class PiWebApp extends LitElement { } private async withChatPrependTransition(action: () => Promise) { - 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 }) { diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 651df49..a193bf2 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; }