Archived
fix: stabilize chat scroll restoration
This commit is contained in:
@@ -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.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 {
|
class MemoryScrollStorage implements ChatScrollStorage {
|
||||||
readonly values = new Map<string, string>();
|
readonly values = new Map<string, string>();
|
||||||
@@ -60,19 +60,15 @@ class FakeScroller implements ChatScrollViewport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakeArticle implements ChatScrollElement {
|
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(
|
constructor(
|
||||||
private readonly top: number,
|
private readonly top: number,
|
||||||
private readonly bottom: number,
|
private readonly bottom: number,
|
||||||
index: number,
|
anchorId?: string,
|
||||||
key?: string,
|
|
||||||
endIndex?: number,
|
|
||||||
) {
|
) {
|
||||||
this.dataset = {
|
this.dataset = {
|
||||||
...(key === undefined ? {} : { anchorKey: key }),
|
...(anchorId === undefined ? {} : { scrollAnchorId: anchorId }),
|
||||||
index: String(index),
|
|
||||||
...(endIndex === undefined ? {} : { endIndex: String(endIndex) }),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,56 +84,55 @@ describe("ChatScrollController", () => {
|
|||||||
const key = chatScrollStorageKey("s1");
|
const key = chatScrollStorageKey("s1");
|
||||||
storage.setItem(key, "old");
|
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(result).toBe("skipped");
|
||||||
expect(storage.getItem(key)).toBe("old");
|
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 storage = new MemoryScrollStorage();
|
||||||
const controller = new ChatScrollController(storage, new ManualScheduler());
|
const controller = new ChatScrollController(storage, new ManualScheduler());
|
||||||
const scroller = new FakeScroller(200, 1000, 300, 100, 400);
|
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;
|
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(controller.restorePosition("s1", scroller, rerenderedAnchors)).toEqual({ status: "restored" });
|
||||||
expect(scroller.scrollTop).toBe(580);
|
expect(scroller.scrollTop).toBe(550);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports missing stored anchors instead of forcing bottom when requested", () => {
|
it("reports missing stored anchors instead of forcing bottom when requested", () => {
|
||||||
const storage = new MemoryScrollStorage();
|
const storage = new MemoryScrollStorage();
|
||||||
const controller = new ChatScrollController(storage, new ManualScheduler());
|
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));
|
storage.setItem(chatScrollStorageKey("s1"), JSON.stringify(position));
|
||||||
const scroller = new FakeScroller(100, 900, 300, 0, 300);
|
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);
|
expect(scroller.scrollTop).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can restore by end index when a group's primary key and start index changed", () => {
|
it("saves explicit bottom mode when the user is at the bottom", () => {
|
||||||
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", () => {
|
|
||||||
const storage = new MemoryScrollStorage();
|
const storage = new MemoryScrollStorage();
|
||||||
const controller = new ChatScrollController(storage, new ManualScheduler());
|
const controller = new ChatScrollController(storage, new ManualScheduler());
|
||||||
const key = chatScrollStorageKey("s1");
|
const key = chatScrollStorageKey("s1");
|
||||||
storage.setItem(key, "old");
|
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(controller.savePosition("s1", new FakeScroller(699, 1000, 300, 0, 300), [new FakeArticle(0, 30, "m:0")])).toBe("saved");
|
||||||
expect(storage.getItem(key)).toBeNull();
|
expect(JSON.parse(storage.getItem(key) ?? "{}")).toEqual({ mode: "bottom" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("captures the session id when scheduling a delayed save", () => {
|
it("captures the session id when scheduling a delayed save", () => {
|
||||||
@@ -156,13 +151,22 @@ describe("ChatScrollController", () => {
|
|||||||
describe("chat scroll helpers", () => {
|
describe("chat scroll helpers", () => {
|
||||||
it("finds the first article intersecting the viewport", () => {
|
it("finds the first article intersecting the viewport", () => {
|
||||||
const scroller = new FakeScroller(0, 1000, 100, 100, 200);
|
const scroller = new FakeScroller(0, 1000, 100, 100, 200);
|
||||||
const first = new FakeArticle(20, 80, 0, "m:0");
|
const first = new FakeArticle(20, 80, "m:0");
|
||||||
const second = new FakeArticle(150, 180, 1, "m:1");
|
const second = new FakeArticle(150, 180, "m:1");
|
||||||
|
|
||||||
expect(findFirstVisibleArticle(scroller, [first, second])).toBe(second);
|
expect(findFirstVisibleArticle(scroller, [first, second])).toBe(second);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("captures an article-relative scroll position", () => {
|
it("finds the visible scroll anchor nearest the viewport top", () => {
|
||||||
expect(captureScrollPosition(new FakeScroller(0, 1000, 100, 100, 200), new FakeArticle(140, 180, 3, "m:3"))).toEqual({ key: "m:3", index: 3, offset: 40 });
|
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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
export interface ChatScrollPosition {
|
export type ChatScrollPosition = ChatBottomScrollPosition | ChatAnchorScrollPosition;
|
||||||
index?: number;
|
|
||||||
endIndex?: number;
|
export interface ChatBottomScrollPosition {
|
||||||
key?: string;
|
mode: "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatAnchorScrollPosition {
|
||||||
|
mode: "anchor";
|
||||||
|
anchorId: string;
|
||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +18,7 @@ export interface ChatScrollViewport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatScrollElement {
|
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<DOMRectReadOnly, "top" | "bottom">;
|
getBoundingClientRect(): Pick<DOMRectReadOnly, "top" | "bottom">;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,14 +33,15 @@ export interface ChatScrollScheduler {
|
|||||||
clearTimeout(id: number): void;
|
clearTimeout(id: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatScrollSaveResult = "saved" | "removed" | "skipped";
|
export type ChatScrollSaveResult = "saved" | "skipped";
|
||||||
export type ChatScrollRestoreResult =
|
export type ChatScrollRestoreResult =
|
||||||
| { status: "bottom" | "restored" | "skipped" }
|
| { status: "bottom" | "restored" | "skipped" }
|
||||||
| { status: "missing"; position: ChatScrollPosition };
|
| { status: "missing"; position: ChatAnchorScrollPosition };
|
||||||
|
|
||||||
const SCROLL_STORAGE_PREFIX = "pi-web:chat-scroll:";
|
const SCROLL_STORAGE_PREFIX = "pi-web:chat-scroll:";
|
||||||
const DEFAULT_SAVE_DELAY_MS = 180;
|
const DEFAULT_SAVE_DELAY_MS = 180;
|
||||||
const DEFAULT_NEAR_BOTTOM_THRESHOLD = 48;
|
const DEFAULT_NEAR_BOTTOM_THRESHOLD = 48;
|
||||||
|
const DEFAULT_BOTTOM_SAVE_THRESHOLD = 2;
|
||||||
|
|
||||||
const browserScrollStorage: ChatScrollStorage = {
|
const browserScrollStorage: ChatScrollStorage = {
|
||||||
getItem(key: string): string | null {
|
getItem(key: string): string | null {
|
||||||
@@ -87,21 +93,19 @@ export class ChatScrollController {
|
|||||||
}, delayMs);
|
}, 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";
|
if (sessionId === "" || scroller === undefined || !hasUsableScrollViewport(scroller)) return "skipped";
|
||||||
try {
|
try {
|
||||||
if (isNearScrollBottom(scroller, nearBottomThreshold)) {
|
if (isNearScrollBottom(scroller, bottomThreshold)) {
|
||||||
this.storage.removeItem(chatScrollStorageKey(sessionId));
|
const position: ChatBottomScrollPosition = { mode: "bottom" };
|
||||||
return "removed";
|
this.storage.setItem(chatScrollStorageKey(sessionId), JSON.stringify(position));
|
||||||
|
return "saved";
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstVisible = findFirstVisibleArticle(scroller, articles);
|
const anchor = findVisibleScrollAnchor(scroller, anchors);
|
||||||
if (firstVisible === undefined) {
|
if (anchor === undefined) return "skipped";
|
||||||
this.storage.removeItem(chatScrollStorageKey(sessionId));
|
|
||||||
return "removed";
|
|
||||||
}
|
|
||||||
|
|
||||||
const position = captureScrollPosition(scroller, firstVisible);
|
const position = captureScrollPosition(scroller, anchor);
|
||||||
this.storage.setItem(chatScrollStorageKey(sessionId), JSON.stringify(position));
|
this.storage.setItem(chatScrollStorageKey(sessionId), JSON.stringify(position));
|
||||||
return "saved";
|
return "saved";
|
||||||
} catch {
|
} 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);
|
const stored = this.readPosition(sessionId);
|
||||||
if (stored === undefined) return this.scrollToBottom(scroller);
|
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" };
|
if (scroller === undefined || !hasUsableScrollViewport(scroller)) return { status: "skipped" };
|
||||||
const article = findArticleAt(articles, position);
|
const anchor = findAnchorById(anchors, position.anchorId);
|
||||||
if (article === undefined) {
|
if (anchor === undefined) {
|
||||||
if (options?.fallbackToBottom === false) return { status: "missing", position };
|
if (options?.fallbackToBottom === false) return { status: "missing", position };
|
||||||
return this.scrollToBottom(scroller);
|
return this.scrollToBottom(scroller);
|
||||||
}
|
}
|
||||||
const scrollerTop = scroller.getBoundingClientRect().top;
|
const scrollerTop = scroller.getBoundingClientRect().top;
|
||||||
const currentOffset = article.getBoundingClientRect().top - scrollerTop;
|
const currentOffset = anchor.getBoundingClientRect().top - scrollerTop;
|
||||||
scroller.scrollTop += currentOffset - position.offset;
|
scroller.scrollTop += currentOffset - position.offset;
|
||||||
return { status: "restored" };
|
return { status: "restored" };
|
||||||
}
|
}
|
||||||
@@ -134,8 +139,7 @@ export class ChatScrollController {
|
|||||||
const raw = this.storage.getItem(chatScrollStorageKey(sessionId));
|
const raw = this.storage.getItem(chatScrollStorageKey(sessionId));
|
||||||
if (raw === null || raw === "") return undefined;
|
if (raw === null || raw === "") return undefined;
|
||||||
const value: unknown = JSON.parse(raw);
|
const value: unknown = JSON.parse(raw);
|
||||||
if (!isScrollPosition(value)) return undefined;
|
return isScrollPosition(value) ? value : undefined;
|
||||||
return value;
|
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -153,11 +157,14 @@ export function chatScrollStorageKey(sessionId: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isScrollPosition(value: unknown): value is ChatScrollPosition {
|
export function isScrollPosition(value: unknown): value is ChatScrollPosition {
|
||||||
return typeof value === "object"
|
if (typeof value !== "object" || value === null || !("mode" in value)) return false;
|
||||||
&& value !== null
|
if (value.mode === "bottom") return true;
|
||||||
|
return value.mode === "anchor"
|
||||||
|
&& "anchorId" in value
|
||||||
|
&& typeof value.anchorId === "string"
|
||||||
|
&& value.anchorId !== ""
|
||||||
&& "offset" in value
|
&& "offset" in value
|
||||||
&& typeof value.offset === "number"
|
&& 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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasUsableScrollViewport(scroller: Pick<ChatScrollViewport, "clientHeight" | "scrollHeight">): boolean {
|
export function hasUsableScrollViewport(scroller: Pick<ChatScrollViewport, "clientHeight" | "scrollHeight">): boolean {
|
||||||
@@ -172,19 +179,40 @@ export function isNearScrollBottom(scroller: Pick<ChatScrollViewport, "scrollHei
|
|||||||
return distanceFromScrollBottom(scroller) < threshold;
|
return distanceFromScrollBottom(scroller) < threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function captureScrollPosition(scroller: ChatScrollViewport, article: ChatScrollElement): ChatScrollPosition {
|
export function captureScrollPosition(scroller: ChatScrollViewport, anchor: ChatScrollElement): ChatAnchorScrollPosition {
|
||||||
const chatTop = scroller.getBoundingClientRect().top;
|
const chatTop = scroller.getBoundingClientRect().top;
|
||||||
const key = article.dataset.anchorKey;
|
|
||||||
const index = numericDatasetValue(article.dataset.index);
|
|
||||||
const endIndex = numericDatasetValue(article.dataset.endIndex);
|
|
||||||
return {
|
return {
|
||||||
...(key === undefined ? {} : { key }),
|
mode: "anchor",
|
||||||
...(index === undefined ? {} : { index }),
|
anchorId: anchorIdForElement(anchor) ?? "",
|
||||||
...(endIndex === undefined ? {} : { endIndex }),
|
offset: anchor.getBoundingClientRect().top - chatTop,
|
||||||
offset: article.getBoundingClientRect().top - chatTop,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findVisibleScrollAnchor<T extends ChatScrollElement>(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<T extends ChatScrollElement>(scroller: ChatScrollViewport, articles: T[]): T | undefined {
|
export function findFirstVisibleArticle<T extends ChatScrollElement>(scroller: ChatScrollViewport, articles: T[]): T | undefined {
|
||||||
const scrollerRect = scroller.getBoundingClientRect();
|
const scrollerRect = scroller.getBoundingClientRect();
|
||||||
return articles.find((article) => {
|
return articles.find((article) => {
|
||||||
@@ -193,16 +221,10 @@ export function findFirstVisibleArticle<T extends ChatScrollElement>(scroller: C
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findArticleAt<T extends ChatScrollElement>(articles: T[], position: { index?: number | undefined; endIndex?: number | undefined; key?: string | undefined }): T | undefined {
|
function findAnchorById<T extends ChatScrollElement>(anchors: T[], anchorId: string): T | undefined {
|
||||||
const keyed = position.key === undefined ? undefined : articles.find((article) => article.dataset.anchorKey === position.key);
|
return anchors.find((anchor) => anchorIdForElement(anchor) === anchorId);
|
||||||
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 numericDatasetValue(value: string | undefined): number | undefined {
|
function anchorIdForElement(element: ChatScrollElement): string | undefined {
|
||||||
if (value === undefined || value === "") return undefined;
|
return element.dataset.scrollAnchorId !== undefined && element.dataset.scrollAnchorId !== "" ? element.dataset.scrollAnchorId : undefined;
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChatDisclosureController } from "../chatDisclosure";
|
|||||||
import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups";
|
import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups";
|
||||||
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
||||||
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
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 { SessionActivity, SessionStatus } from "../api";
|
||||||
import type { ChatLine, ChatPart } from "./shared";
|
import type { ChatLine, ChatPart } from "./shared";
|
||||||
import { chatStyles } from "./shared";
|
import { chatStyles } from "./shared";
|
||||||
@@ -74,7 +74,7 @@ export class ChatView extends LitElement {
|
|||||||
private lastClientHeight = 0;
|
private lastClientHeight = 0;
|
||||||
private touchStartY: number | undefined;
|
private touchStartY: number | undefined;
|
||||||
private pendingScrollRestoreSessionId: string | undefined;
|
private pendingScrollRestoreSessionId: string | undefined;
|
||||||
private pendingScrollRestorePosition: ChatScrollPosition | undefined;
|
private pendingScrollRestorePosition: ChatAnchorScrollPosition | undefined;
|
||||||
private restoreScrollFrame: number | undefined;
|
private restoreScrollFrame: number | undefined;
|
||||||
private prependRestoreToken = 0;
|
private prependRestoreToken = 0;
|
||||||
@state() private loadMoreRequested = false;
|
@state() private loadMoreRequested = false;
|
||||||
@@ -82,10 +82,14 @@ export class ChatView extends LitElement {
|
|||||||
if (this.pinnedToBottom) this.scrollToBottom();
|
if (this.pinnedToBottom) this.scrollToBottom();
|
||||||
else this.lastClientHeight = this.chat?.clientHeight ?? 0;
|
else this.lastClientHeight = this.chat?.clientHeight ?? 0;
|
||||||
};
|
};
|
||||||
|
private readonly onPageHide = () => {
|
||||||
|
this.saveScrollPosition();
|
||||||
|
};
|
||||||
|
|
||||||
override connectedCallback(): void {
|
override connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
window.addEventListener("resize", this.onViewportResize);
|
window.addEventListener("resize", this.onViewportResize);
|
||||||
|
window.addEventListener("pagehide", this.onPageHide);
|
||||||
window.visualViewport?.addEventListener("resize", this.onViewportResize);
|
window.visualViewport?.addEventListener("resize", this.onViewportResize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +98,7 @@ export class ChatView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
override disconnectedCallback(): void {
|
||||||
|
this.saveScrollPosition();
|
||||||
this.scrollController.dispose();
|
this.scrollController.dispose();
|
||||||
this.prependRestoreToken += 1;
|
this.prependRestoreToken += 1;
|
||||||
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
|
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.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame);
|
||||||
if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame);
|
if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame);
|
||||||
window.removeEventListener("resize", this.onViewportResize);
|
window.removeEventListener("resize", this.onViewportResize);
|
||||||
|
window.removeEventListener("pagehide", this.onPageHide);
|
||||||
window.visualViewport?.removeEventListener("resize", this.onViewportResize);
|
window.visualViewport?.removeEventListener("resize", this.onViewportResize);
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private savePreviousSessionScrollPosition(previousSessionId: unknown): void {
|
||||||
|
if (typeof previousSessionId !== "string" || previousSessionId === "" || previousSessionId === this.sessionId) return;
|
||||||
|
this.saveScrollPosition(previousSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
private prepareSessionUiState(): void {
|
private prepareSessionUiState(): void {
|
||||||
this.disclosures.syncSession(this.sessionId);
|
this.disclosures.syncSession(this.sessionId);
|
||||||
this.scrollController.clearScheduledSave();
|
this.scrollController.clearScheduledSave();
|
||||||
@@ -120,7 +131,10 @@ export class ChatView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override willUpdate(changed: Map<string, unknown>): void {
|
protected override willUpdate(changed: Map<string, unknown>): 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("isReceivingPartialStream") || (changed.has("sessionId") && this.isReceivingPartialStream)) this.syncPartialStreamNoticeBody();
|
||||||
if (changed.has("messages")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
|
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<string, unknown>): void {
|
protected override updated(changed: Map<string, unknown>): void {
|
||||||
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
|
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
|
||||||
if (changed.has("hasMore") && !this.hasMore) 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("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("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
|
||||||
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore")) this.continuePendingScrollRestore();
|
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);
|
const toolOnly = this.isToolExecutionOnlyMessage(message);
|
||||||
return html`
|
return html`
|
||||||
${this.renderScrollMarker(this.messageScrollMarkerId(index))}
|
${this.renderScrollMarker(this.messageScrollMarkerId(index))}
|
||||||
<article class=${toolOnly ? "msg tool-execution-shell" : `msg ${message.role}`} data-index=${index} data-end-index=${index} data-anchor-key=${this.messageAnchorKey(index)}>
|
<article class=${toolOnly ? "msg tool-execution-shell" : `msg ${message.role}`} data-index=${index} data-scroll-anchor-id=${this.messageAnchorKey(index)}>
|
||||||
${toolOnly ? null : this.renderMessageHeader(message, String(index))}
|
${toolOnly ? null : this.renderMessageHeader(message, String(index))}
|
||||||
${message.parts.map((part) => this.renderPart(part, message))}
|
${message.parts.map((part) => this.renderPart(part, message))}
|
||||||
</article>
|
</article>
|
||||||
@@ -316,7 +331,7 @@ export class ChatView extends LitElement {
|
|||||||
const open = this.disclosures.isOpen(disclosureKey, defaultOpen);
|
const open = this.disclosures.isOpen(disclosureKey, defaultOpen);
|
||||||
return html`
|
return html`
|
||||||
${this.renderScrollMarker(this.groupScrollMarkerId(endIndex))}
|
${this.renderScrollMarker(this.groupScrollMarkerId(endIndex))}
|
||||||
<details class=${defaultOpen ? "msg event-group live" : "msg event-group"} data-index=${startIndex} data-end-index=${endIndex} data-anchor-key=${this.groupAnchorKey(startIndex)} ?open=${open} @toggle=${(event: Event) => { this.onGroupToggle(disclosureKey, event, defaultOpen); }}>
|
<details class=${defaultOpen ? "msg event-group live" : "msg event-group"} data-index=${startIndex} data-scroll-anchor-id=${this.groupAnchorKey(startIndex)} ?open=${open} @toggle=${(event: Event) => { this.onGroupToggle(disclosureKey, event, defaultOpen); }}>
|
||||||
<summary>
|
<summary>
|
||||||
<b class="label">${defaultOpen ? "live events" : "events"}</b>
|
<b class="label">${defaultOpen ? "live events" : "events"}</b>
|
||||||
<span>${summarizeChatGroup(messages)}</span>
|
<span>${summarizeChatGroup(messages)}</span>
|
||||||
@@ -325,7 +340,7 @@ export class ChatView extends LitElement {
|
|||||||
${messages.map((message, offset) => {
|
${messages.map((message, offset) => {
|
||||||
const toolOnly = this.isToolExecutionOnlyMessage(message);
|
const toolOnly = this.isToolExecutionOnlyMessage(message);
|
||||||
return html`
|
return html`
|
||||||
<section class=${toolOnly ? "group-msg tool-execution-shell" : `group-msg ${message.role}`}>
|
<section class=${toolOnly ? "group-msg tool-execution-shell" : `group-msg ${message.role}`} data-index=${startIndex + offset} data-scroll-anchor-id=${this.eventAnchorKey(startIndex + offset)}>
|
||||||
${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
|
${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
|
||||||
${message.parts.map((part) => this.renderPart(part, message))}
|
${message.parts.map((part) => this.renderPart(part, message))}
|
||||||
</section>
|
</section>
|
||||||
@@ -584,7 +599,7 @@ export class ChatView extends LitElement {
|
|||||||
this.restoreScrollFrame = undefined;
|
this.restoreScrollFrame = undefined;
|
||||||
if (this.sessionId !== sessionId) return;
|
if (this.sessionId !== sessionId) return;
|
||||||
this.withSuppressedScrollSave(() => {
|
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);
|
this.handleScrollRestoreResult(sessionId, result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -598,7 +613,7 @@ export class ChatView extends LitElement {
|
|||||||
this.restoreScrollFrame = undefined;
|
this.restoreScrollFrame = undefined;
|
||||||
if (this.sessionId !== sessionId) return;
|
if (this.sessionId !== sessionId) return;
|
||||||
this.withSuppressedScrollSave(() => {
|
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);
|
this.handleScrollRestoreResult(sessionId, result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -607,12 +622,14 @@ export class ChatView extends LitElement {
|
|||||||
private handleScrollRestoreResult(sessionId: string, result: ChatScrollRestoreResult): void {
|
private handleScrollRestoreResult(sessionId: string, result: ChatScrollRestoreResult): void {
|
||||||
this.syncScrollMetrics();
|
this.syncScrollMetrics();
|
||||||
if (result.status !== "missing") {
|
if (result.status !== "missing") {
|
||||||
|
this.updatePinnedToBottomAfterRestore(result.status);
|
||||||
if (result.status === "restored" || result.status === "bottom") this.cancelPrependRestore();
|
if (result.status === "restored" || result.status === "bottom") this.cancelPrependRestore();
|
||||||
this.pendingScrollRestoreSessionId = undefined;
|
this.pendingScrollRestoreSessionId = undefined;
|
||||||
this.pendingScrollRestorePosition = undefined;
|
this.pendingScrollRestorePosition = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.pinnedToBottom = false;
|
||||||
this.pendingScrollRestoreSessionId = sessionId;
|
this.pendingScrollRestoreSessionId = sessionId;
|
||||||
this.pendingScrollRestorePosition = result.position;
|
this.pendingScrollRestorePosition = result.position;
|
||||||
const chat = this.chat;
|
const chat = this.chat;
|
||||||
@@ -622,6 +639,18 @@ export class ChatView extends LitElement {
|
|||||||
this.requestLoadMore();
|
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<ChatScrollRestoreResult["status"], "missing">): void {
|
||||||
|
if (status === "bottom") this.pinnedToBottom = true;
|
||||||
|
else if (status === "restored") this.pinnedToBottom = this.isNearBottom();
|
||||||
|
}
|
||||||
|
|
||||||
private syncScrollMetrics(): void {
|
private syncScrollMetrics(): void {
|
||||||
const chat = this.chat;
|
const chat = this.chat;
|
||||||
if (chat === undefined) return;
|
if (chat === undefined) return;
|
||||||
@@ -671,7 +700,7 @@ export class ChatView extends LitElement {
|
|||||||
|
|
||||||
saveScrollPosition(sessionId = this.sessionId) {
|
saveScrollPosition(sessionId = this.sessionId) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
this.scrollController.savePosition(sessionId, this.chat, this.articles());
|
this.scrollController.savePosition(sessionId, this.chat, this.scrollAnchorElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleScrollPositionSave() {
|
private scheduleScrollPositionSave() {
|
||||||
@@ -723,6 +752,10 @@ export class ChatView extends LitElement {
|
|||||||
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>("article.msg, details.msg"));
|
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>("article.msg, details.msg"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scrollAnchorElements(): HTMLElement[] {
|
||||||
|
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>("[data-scroll-anchor-id]"));
|
||||||
|
}
|
||||||
|
|
||||||
private withSuppressedScrollSave(callback: () => void) {
|
private withSuppressedScrollSave(callback: () => void) {
|
||||||
this.suppressScrollSave = true;
|
this.suppressScrollSave = true;
|
||||||
callback();
|
callback();
|
||||||
@@ -749,6 +782,10 @@ export class ChatView extends LitElement {
|
|||||||
return `g:${String(startIndex)}`;
|
return `g:${String(startIndex)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private eventAnchorKey(index: number): string {
|
||||||
|
return `e:${String(index)}`;
|
||||||
|
}
|
||||||
|
|
||||||
private messageScrollMarkerId(index: number): string {
|
private messageScrollMarkerId(index: number): string {
|
||||||
return `m:${String(index)}`;
|
return `m:${String(index)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user