diff --git a/.changeset/steady-stream-scroll.md b/.changeset/steady-stream-scroll.md new file mode 100644 index 0000000..e42a542 --- /dev/null +++ b/.changeset/steady-stream-scroll.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Rework chat scroll restoration around explicit bottom and anchor positions so session navigation and streaming updates keep the user's reading position stable. diff --git a/src/client/src/chatScrollPosition.test.ts b/src/client/src/chatScrollPosition.test.ts index 784ec21..01c32d2 100644 --- a/src/client/src/chatScrollPosition.test.ts +++ b/src/client/src/chatScrollPosition.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ChatScrollController, captureScrollPosition, chatScrollStorageKey, findFirstVisibleArticle, type ChatScrollElement, type ChatScrollScheduler, type ChatScrollStorage, type ChatScrollViewport } from "./chatScrollPosition"; +import { ChatScrollController, captureScrollPosition, chatScrollStorageKey, findFirstVisibleArticle, findVisibleScrollAnchor, type ChatScrollElement, type ChatScrollScheduler, type ChatScrollStorage, type ChatScrollViewport } from "./chatScrollPosition"; class MemoryScrollStorage implements ChatScrollStorage { readonly values = new Map(); @@ -60,19 +60,15 @@ class FakeScroller implements ChatScrollViewport { } class FakeArticle implements ChatScrollElement { - readonly dataset: { readonly anchorKey?: string | undefined; readonly index?: string | undefined; readonly endIndex?: string | undefined }; + readonly dataset: { readonly scrollAnchorId?: string | undefined }; constructor( private readonly top: number, private readonly bottom: number, - index: number, - key?: string, - endIndex?: number, + anchorId?: string, ) { this.dataset = { - ...(key === undefined ? {} : { anchorKey: key }), - index: String(index), - ...(endIndex === undefined ? {} : { endIndex: String(endIndex) }), + ...(anchorId === undefined ? {} : { scrollAnchorId: anchorId }), }; } @@ -88,56 +84,55 @@ describe("ChatScrollController", () => { const key = chatScrollStorageKey("s1"); storage.setItem(key, "old"); - const result = controller.savePosition("s1", new FakeScroller(0, 0, 0, 0, 0), [new FakeArticle(0, 10, 0, "m:0")]); + const result = controller.savePosition("s1", new FakeScroller(0, 0, 0, 0, 0), [new FakeArticle(0, 10, "m:0")]); expect(result).toBe("skipped"); expect(storage.getItem(key)).toBe("old"); }); - it("saves and restores the first visible article", () => { + it("saves and restores the visible anchor nearest the viewport top", () => { const storage = new MemoryScrollStorage(); const controller = new ChatScrollController(storage, new ManualScheduler()); const scroller = new FakeScroller(200, 1000, 300, 100, 400); - const articles = [new FakeArticle(40, 90, 0, "m:0"), new FakeArticle(140, 180, 1, "m:1")]; + const anchors = [ + new FakeArticle(-600, 350, "g:1"), + new FakeArticle(80, 170, "e:6"), + new FakeArticle(220, 280, "m:7"), + ]; - expect(controller.savePosition("s1", scroller, articles)).toBe("saved"); + expect(controller.savePosition("s1", scroller, anchors)).toBe("saved"); + expect(JSON.parse(storage.getItem(chatScrollStorageKey("s1")) ?? "{}")).toEqual({ mode: "anchor", anchorId: "e:6", offset: -20 }); scroller.scrollTop = 500; - const rerenderedArticles = [new FakeArticle(120, 160, 0, "m:0"), new FakeArticle(220, 260, 1, "m:1")]; + const rerenderedAnchors = [ + new FakeArticle(-500, 360, "g:1"), + new FakeArticle(130, 210, "e:6"), + new FakeArticle(250, 310, "m:7"), + ]; - expect(controller.restorePosition("s1", scroller, rerenderedArticles)).toEqual({ status: "restored" }); - expect(scroller.scrollTop).toBe(580); + expect(controller.restorePosition("s1", scroller, rerenderedAnchors)).toEqual({ status: "restored" }); + expect(scroller.scrollTop).toBe(550); }); it("reports missing stored anchors instead of forcing bottom when requested", () => { const storage = new MemoryScrollStorage(); const controller = new ChatScrollController(storage, new ManualScheduler()); - const position = { key: "m:4", index: 4, offset: 20 }; + const position = { mode: "anchor", anchorId: "m:4", offset: 20 }; storage.setItem(chatScrollStorageKey("s1"), JSON.stringify(position)); const scroller = new FakeScroller(100, 900, 300, 0, 300); - expect(controller.restorePosition("s1", scroller, [new FakeArticle(10, 40, 9, "m:9")], { fallbackToBottom: false })).toEqual({ status: "missing", position }); + expect(controller.restorePosition("s1", scroller, [new FakeArticle(10, 40, "m:9")], { fallbackToBottom: false })).toEqual({ status: "missing", position }); expect(scroller.scrollTop).toBe(100); }); - it("can restore by end index when a group's primary key and start index changed", () => { - const storage = new MemoryScrollStorage(); - const controller = new ChatScrollController(storage, new ManualScheduler()); - storage.setItem(chatScrollStorageKey("s1"), JSON.stringify({ key: "g:10", index: 10, endIndex: 20, offset: 40 })); - const scroller = new FakeScroller(100, 900, 300, 0, 300); - - expect(controller.restorePosition("s1", scroller, [new FakeArticle(90, 180, 8, "g:8", 20)])).toEqual({ status: "restored" }); - expect(scroller.scrollTop).toBe(150); - }); - - it("removes stored scroll when the user is near the bottom", () => { + it("saves explicit bottom mode when the user is at the bottom", () => { const storage = new MemoryScrollStorage(); const controller = new ChatScrollController(storage, new ManualScheduler()); const key = chatScrollStorageKey("s1"); storage.setItem(key, "old"); - expect(controller.savePosition("s1", new FakeScroller(660, 1000, 300, 0, 300), [new FakeArticle(0, 30, 0, "m:0")])).toBe("removed"); - expect(storage.getItem(key)).toBeNull(); + expect(controller.savePosition("s1", new FakeScroller(699, 1000, 300, 0, 300), [new FakeArticle(0, 30, "m:0")])).toBe("saved"); + expect(JSON.parse(storage.getItem(key) ?? "{}")).toEqual({ mode: "bottom" }); }); it("captures the session id when scheduling a delayed save", () => { @@ -156,13 +151,22 @@ describe("ChatScrollController", () => { describe("chat scroll helpers", () => { it("finds the first article intersecting the viewport", () => { const scroller = new FakeScroller(0, 1000, 100, 100, 200); - const first = new FakeArticle(20, 80, 0, "m:0"); - const second = new FakeArticle(150, 180, 1, "m:1"); + const first = new FakeArticle(20, 80, "m:0"); + const second = new FakeArticle(150, 180, "m:1"); expect(findFirstVisibleArticle(scroller, [first, second])).toBe(second); }); - it("captures an article-relative scroll position", () => { - expect(captureScrollPosition(new FakeScroller(0, 1000, 100, 100, 200), new FakeArticle(140, 180, 3, "m:3"))).toEqual({ key: "m:3", index: 3, offset: 40 }); + it("finds the visible scroll anchor nearest the viewport top", () => { + const scroller = new FakeScroller(0, 1000, 100, 100, 200); + const wrapper = new FakeArticle(-500, 180, "g:0"); + const firstChild = new FakeArticle(90, 130, "e:0"); + const secondChild = new FakeArticle(140, 180, "e:1"); + + expect(findVisibleScrollAnchor(scroller, [wrapper, firstChild, secondChild])).toBe(firstChild); + }); + + it("captures an anchor-relative scroll position", () => { + expect(captureScrollPosition(new FakeScroller(0, 1000, 100, 100, 200), new FakeArticle(140, 180, "m:3"))).toEqual({ mode: "anchor", anchorId: "m:3", offset: 40 }); }); }); diff --git a/src/client/src/chatScrollPosition.ts b/src/client/src/chatScrollPosition.ts index 50e3388..a120759 100644 --- a/src/client/src/chatScrollPosition.ts +++ b/src/client/src/chatScrollPosition.ts @@ -1,7 +1,12 @@ -export interface ChatScrollPosition { - index?: number; - endIndex?: number; - key?: string; +export type ChatScrollPosition = ChatBottomScrollPosition | ChatAnchorScrollPosition; + +export interface ChatBottomScrollPosition { + mode: "bottom"; +} + +export interface ChatAnchorScrollPosition { + mode: "anchor"; + anchorId: string; offset: number; } @@ -13,7 +18,7 @@ export interface ChatScrollViewport { } export interface ChatScrollElement { - readonly dataset: { readonly anchorKey?: string | undefined; readonly index?: string | undefined; readonly endIndex?: string | undefined }; + readonly dataset: { readonly scrollAnchorId?: string | undefined }; getBoundingClientRect(): Pick; } @@ -28,14 +33,15 @@ export interface ChatScrollScheduler { clearTimeout(id: number): void; } -export type ChatScrollSaveResult = "saved" | "removed" | "skipped"; +export type ChatScrollSaveResult = "saved" | "skipped"; export type ChatScrollRestoreResult = | { status: "bottom" | "restored" | "skipped" } - | { status: "missing"; position: ChatScrollPosition }; + | { status: "missing"; position: ChatAnchorScrollPosition }; const SCROLL_STORAGE_PREFIX = "pi-web:chat-scroll:"; const DEFAULT_SAVE_DELAY_MS = 180; const DEFAULT_NEAR_BOTTOM_THRESHOLD = 48; +const DEFAULT_BOTTOM_SAVE_THRESHOLD = 2; const browserScrollStorage: ChatScrollStorage = { getItem(key: string): string | null { @@ -87,21 +93,19 @@ export class ChatScrollController { }, delayMs); } - savePosition(sessionId: string, scroller: ChatScrollViewport | undefined, articles: ChatScrollElement[], nearBottomThreshold = DEFAULT_NEAR_BOTTOM_THRESHOLD): ChatScrollSaveResult { + savePosition(sessionId: string, scroller: ChatScrollViewport | undefined, anchors: ChatScrollElement[], bottomThreshold = DEFAULT_BOTTOM_SAVE_THRESHOLD): ChatScrollSaveResult { if (sessionId === "" || scroller === undefined || !hasUsableScrollViewport(scroller)) return "skipped"; try { - if (isNearScrollBottom(scroller, nearBottomThreshold)) { - this.storage.removeItem(chatScrollStorageKey(sessionId)); - return "removed"; + if (isNearScrollBottom(scroller, bottomThreshold)) { + const position: ChatBottomScrollPosition = { mode: "bottom" }; + this.storage.setItem(chatScrollStorageKey(sessionId), JSON.stringify(position)); + return "saved"; } - const firstVisible = findFirstVisibleArticle(scroller, articles); - if (firstVisible === undefined) { - this.storage.removeItem(chatScrollStorageKey(sessionId)); - return "removed"; - } + const anchor = findVisibleScrollAnchor(scroller, anchors); + if (anchor === undefined) return "skipped"; - const position = captureScrollPosition(scroller, firstVisible); + const position = captureScrollPosition(scroller, anchor); this.storage.setItem(chatScrollStorageKey(sessionId), JSON.stringify(position)); return "saved"; } catch { @@ -109,21 +113,22 @@ export class ChatScrollController { } } - restorePosition(sessionId: string, scroller: ChatScrollViewport | undefined, articles: ChatScrollElement[], options?: { fallbackToBottom?: boolean | undefined }): ChatScrollRestoreResult { + restorePosition(sessionId: string, scroller: ChatScrollViewport | undefined, anchors: ChatScrollElement[], options?: { fallbackToBottom?: boolean | undefined }): ChatScrollRestoreResult { const stored = this.readPosition(sessionId); if (stored === undefined) return this.scrollToBottom(scroller); - return this.restoreExplicitPosition(stored, scroller, articles, options); + return this.restoreExplicitPosition(stored, scroller, anchors, options); } - restoreExplicitPosition(position: ChatScrollPosition, scroller: ChatScrollViewport | undefined, articles: ChatScrollElement[], options?: { fallbackToBottom?: boolean | undefined }): ChatScrollRestoreResult { + restoreExplicitPosition(position: ChatScrollPosition, scroller: ChatScrollViewport | undefined, anchors: ChatScrollElement[], options?: { fallbackToBottom?: boolean | undefined }): ChatScrollRestoreResult { + if (position.mode === "bottom") return this.scrollToBottom(scroller); if (scroller === undefined || !hasUsableScrollViewport(scroller)) return { status: "skipped" }; - const article = findArticleAt(articles, position); - if (article === undefined) { + const anchor = findAnchorById(anchors, position.anchorId); + if (anchor === undefined) { if (options?.fallbackToBottom === false) return { status: "missing", position }; return this.scrollToBottom(scroller); } const scrollerTop = scroller.getBoundingClientRect().top; - const currentOffset = article.getBoundingClientRect().top - scrollerTop; + const currentOffset = anchor.getBoundingClientRect().top - scrollerTop; scroller.scrollTop += currentOffset - position.offset; return { status: "restored" }; } @@ -134,8 +139,7 @@ export class ChatScrollController { const raw = this.storage.getItem(chatScrollStorageKey(sessionId)); if (raw === null || raw === "") return undefined; const value: unknown = JSON.parse(raw); - if (!isScrollPosition(value)) return undefined; - return value; + return isScrollPosition(value) ? value : undefined; } catch { return undefined; } @@ -153,11 +157,14 @@ export function chatScrollStorageKey(sessionId: string): string { } export function isScrollPosition(value: unknown): value is ChatScrollPosition { - return typeof value === "object" - && value !== null + if (typeof value !== "object" || value === null || !("mode" in value)) return false; + if (value.mode === "bottom") return true; + return value.mode === "anchor" + && "anchorId" in value + && typeof value.anchorId === "string" + && value.anchorId !== "" && "offset" in value - && typeof value.offset === "number" - && (("key" in value && typeof value.key === "string") || ("index" in value && typeof value.index === "number") || ("endIndex" in value && typeof value.endIndex === "number")); + && typeof value.offset === "number"; } export function hasUsableScrollViewport(scroller: Pick): boolean { @@ -172,19 +179,40 @@ export function isNearScrollBottom(scroller: Pick(scroller: ChatScrollViewport, anchors: T[]): T | undefined { + const scrollerRect = scroller.getBoundingClientRect(); + let nearestAbove: T | undefined; + let nearestAboveOffset = Number.NEGATIVE_INFINITY; + let nearestBelow: T | undefined; + let nearestBelowOffset = Number.POSITIVE_INFINITY; + + for (const anchor of anchors) { + if (anchorIdForElement(anchor) === undefined) continue; + const rect = anchor.getBoundingClientRect(); + if (rect.bottom <= rect.top) continue; + if (rect.bottom < scrollerRect.top || rect.top > scrollerRect.bottom) continue; + const offset = rect.top - scrollerRect.top; + if (offset <= 0 && offset >= nearestAboveOffset) { + nearestAbove = anchor; + nearestAboveOffset = offset; + } else if (offset > 0 && offset < nearestBelowOffset) { + nearestBelow = anchor; + nearestBelowOffset = offset; + } + } + + return nearestAbove ?? nearestBelow; +} + export function findFirstVisibleArticle(scroller: ChatScrollViewport, articles: T[]): T | undefined { const scrollerRect = scroller.getBoundingClientRect(); return articles.find((article) => { @@ -193,16 +221,10 @@ export function findFirstVisibleArticle(scroller: C }); } -export function findArticleAt(articles: T[], position: { index?: number | undefined; endIndex?: number | undefined; key?: string | undefined }): T | undefined { - const keyed = position.key === undefined ? undefined : articles.find((article) => article.dataset.anchorKey === position.key); - if (keyed !== undefined) return keyed; - const startMatched = position.index === undefined ? undefined : articles.find((article) => numericDatasetValue(article.dataset.index) === position.index); - if (startMatched !== undefined) return startMatched; - return position.endIndex === undefined ? undefined : articles.find((article) => numericDatasetValue(article.dataset.endIndex) === position.endIndex); +function findAnchorById(anchors: T[], anchorId: string): T | undefined { + return anchors.find((anchor) => anchorIdForElement(anchor) === anchorId); } -function numericDatasetValue(value: string | undefined): number | undefined { - if (value === undefined || value === "") return undefined; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; +function anchorIdForElement(element: ChatScrollElement): string | undefined { + return element.dataset.scrollAnchorId !== undefined && element.dataset.scrollAnchorId !== "" ? element.dataset.scrollAnchorId : undefined; } diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 8aa2cd0..8b8d994 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -5,7 +5,7 @@ import { ChatDisclosureController } from "../chatDisclosure"; import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups"; import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring"; import { shouldRequestEarlierMessages } from "../chatHistoryLoading"; -import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition"; +import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition"; import type { SessionActivity, SessionStatus } from "../api"; import type { ChatLine, ChatPart } from "./shared"; import { chatStyles } from "./shared"; @@ -74,7 +74,7 @@ export class ChatView extends LitElement { private lastClientHeight = 0; private touchStartY: number | undefined; private pendingScrollRestoreSessionId: string | undefined; - private pendingScrollRestorePosition: ChatScrollPosition | undefined; + private pendingScrollRestorePosition: ChatAnchorScrollPosition | undefined; private restoreScrollFrame: number | undefined; private prependRestoreToken = 0; @state() private loadMoreRequested = false; @@ -82,10 +82,14 @@ export class ChatView extends LitElement { if (this.pinnedToBottom) this.scrollToBottom(); else this.lastClientHeight = this.chat?.clientHeight ?? 0; }; + private readonly onPageHide = () => { + this.saveScrollPosition(); + }; override connectedCallback(): void { super.connectedCallback(); window.addEventListener("resize", this.onViewportResize); + window.addEventListener("pagehide", this.onPageHide); window.visualViewport?.addEventListener("resize", this.onViewportResize); } @@ -94,6 +98,7 @@ export class ChatView extends LitElement { } override disconnectedCallback(): void { + this.saveScrollPosition(); this.scrollController.dispose(); this.prependRestoreToken += 1; if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame); @@ -101,10 +106,16 @@ export class ChatView extends LitElement { if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame); if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame); window.removeEventListener("resize", this.onViewportResize); + window.removeEventListener("pagehide", this.onPageHide); window.visualViewport?.removeEventListener("resize", this.onViewportResize); super.disconnectedCallback(); } + private savePreviousSessionScrollPosition(previousSessionId: unknown): void { + if (typeof previousSessionId !== "string" || previousSessionId === "" || previousSessionId === this.sessionId) return; + this.saveScrollPosition(previousSessionId); + } + private prepareSessionUiState(): void { this.disclosures.syncSession(this.sessionId); this.scrollController.clearScheduledSave(); @@ -120,7 +131,10 @@ export class ChatView extends LitElement { } protected override willUpdate(changed: Map): void { - if (changed.has("sessionId")) this.prepareSessionUiState(); + if (changed.has("sessionId")) { + this.savePreviousSessionScrollPosition(changed.get("sessionId")); + this.prepareSessionUiState(); + } if (changed.has("isReceivingPartialStream") || (changed.has("sessionId") && this.isReceivingPartialStream)) this.syncPartialStreamNoticeBody(); if (changed.has("messages")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom()); } @@ -134,6 +148,7 @@ export class ChatView extends LitElement { protected override updated(changed: Map): void { if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false; if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false; + if (changed.has("sessionId")) this.restoreScrollPosition(); if (!changed.has("sessionId") && changed.has("messages") && this.pinnedToBottom) this.scrollToBottom(); if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate(); if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore")) this.continuePendingScrollRestore(); @@ -300,7 +315,7 @@ export class ChatView extends LitElement { const toolOnly = this.isToolExecutionOnlyMessage(message); return html` ${this.renderScrollMarker(this.messageScrollMarkerId(index))} -
+
${toolOnly ? null : this.renderMessageHeader(message, String(index))} ${message.parts.map((part) => this.renderPart(part, message))}
@@ -316,7 +331,7 @@ export class ChatView extends LitElement { const open = this.disclosures.isOpen(disclosureKey, defaultOpen); return html` ${this.renderScrollMarker(this.groupScrollMarkerId(endIndex))} -
{ this.onGroupToggle(disclosureKey, event, defaultOpen); }}> +
{ this.onGroupToggle(disclosureKey, event, defaultOpen); }}> ${defaultOpen ? "live events" : "events"} ${summarizeChatGroup(messages)} @@ -325,7 +340,7 @@ export class ChatView extends LitElement { ${messages.map((message, offset) => { const toolOnly = this.isToolExecutionOnlyMessage(message); return html` -
+
${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)} ${message.parts.map((part) => this.renderPart(part, message))}
@@ -584,7 +599,7 @@ export class ChatView extends LitElement { this.restoreScrollFrame = undefined; if (this.sessionId !== sessionId) return; this.withSuppressedScrollSave(() => { - const result = this.scrollController.restorePosition(sessionId, this.chat, this.articles(), { fallbackToBottom: !this.hasMore }); + const result = this.scrollController.restorePosition(sessionId, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() }); this.handleScrollRestoreResult(sessionId, result); }); }); @@ -598,7 +613,7 @@ export class ChatView extends LitElement { this.restoreScrollFrame = undefined; if (this.sessionId !== sessionId) return; this.withSuppressedScrollSave(() => { - const result = this.scrollController.restoreExplicitPosition(position, this.chat, this.articles(), { fallbackToBottom: !this.hasMore }); + const result = this.scrollController.restoreExplicitPosition(position, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() }); this.handleScrollRestoreResult(sessionId, result); }); }); @@ -607,12 +622,14 @@ export class ChatView extends LitElement { private handleScrollRestoreResult(sessionId: string, result: ChatScrollRestoreResult): void { this.syncScrollMetrics(); if (result.status !== "missing") { + this.updatePinnedToBottomAfterRestore(result.status); if (result.status === "restored" || result.status === "bottom") this.cancelPrependRestore(); this.pendingScrollRestoreSessionId = undefined; this.pendingScrollRestorePosition = undefined; return; } + this.pinnedToBottom = false; this.pendingScrollRestoreSessionId = sessionId; this.pendingScrollRestorePosition = result.position; const chat = this.chat; @@ -622,6 +639,18 @@ export class ChatView extends LitElement { this.requestLoadMore(); } + private shouldFallbackToBottomForMissingAnchor(): boolean { + // While catching up to a stream, history can temporarily omit the in-flight + // assistant message that a previous scroll save anchored to. Keep retrying + // until the final refreshed transcript has a chance to render that anchor. + return !this.hasMore && !this.isReceivingPartialStream; + } + + private updatePinnedToBottomAfterRestore(status: Exclude): void { + if (status === "bottom") this.pinnedToBottom = true; + else if (status === "restored") this.pinnedToBottom = this.isNearBottom(); + } + private syncScrollMetrics(): void { const chat = this.chat; if (chat === undefined) return; @@ -671,7 +700,7 @@ export class ChatView extends LitElement { saveScrollPosition(sessionId = this.sessionId) { if (!sessionId) return; - this.scrollController.savePosition(sessionId, this.chat, this.articles()); + this.scrollController.savePosition(sessionId, this.chat, this.scrollAnchorElements()); } private scheduleScrollPositionSave() { @@ -723,6 +752,10 @@ export class ChatView extends LitElement { return Array.from(this.renderRoot.querySelectorAll("article.msg, details.msg")); } + private scrollAnchorElements(): HTMLElement[] { + return Array.from(this.renderRoot.querySelectorAll("[data-scroll-anchor-id]")); + } + private withSuppressedScrollSave(callback: () => void) { this.suppressScrollSave = true; callback(); @@ -749,6 +782,10 @@ export class ChatView extends LitElement { return `g:${String(startIndex)}`; } + private eventAnchorKey(index: number): string { + return `e:${String(index)}`; + } + private messageScrollMarkerId(index: number): string { return `m:${String(index)}`; }