From ea5d86329765cf6ad71320585a661dd6c9fd90d6 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 20 May 2026 20:27:54 +0200 Subject: [PATCH] fix: stabilize chat scroll and disclosure state --- .../stabilize-chat-scroll-disclosure.md | 5 + src/client/src/chatDisclosure.test.ts | 70 +++++ src/client/src/chatDisclosure.ts | 121 ++++++++ src/client/src/chatScrollPosition.test.ts | 168 +++++++++++ src/client/src/chatScrollPosition.ts | 208 ++++++++++++++ src/client/src/components/ChatView.ts | 265 ++++++++---------- 6 files changed, 682 insertions(+), 155 deletions(-) create mode 100644 .changeset/stabilize-chat-scroll-disclosure.md create mode 100644 src/client/src/chatDisclosure.test.ts create mode 100644 src/client/src/chatDisclosure.ts create mode 100644 src/client/src/chatScrollPosition.test.ts create mode 100644 src/client/src/chatScrollPosition.ts diff --git a/.changeset/stabilize-chat-scroll-disclosure.md b/.changeset/stabilize-chat-scroll-disclosure.md new file mode 100644 index 0000000..c063731 --- /dev/null +++ b/.changeset/stabilize-chat-scroll-disclosure.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Preserve chat scroll positions more reliably across session and workspace changes, and keep live event groups collapsed when users close them during streaming. diff --git a/src/client/src/chatDisclosure.test.ts b/src/client/src/chatDisclosure.test.ts new file mode 100644 index 0000000..af707f6 --- /dev/null +++ b/src/client/src/chatDisclosure.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { ChatDisclosureController, parseDisclosureSnapshot, type ChatDisclosureSnapshot, type ChatDisclosureStorage } from "./chatDisclosure"; + +class MemoryDisclosureStorage implements ChatDisclosureStorage { + readonly snapshots = new Map(); + + read(sessionId: string): ChatDisclosureSnapshot | undefined { + const snapshot = this.snapshots.get(sessionId); + return snapshot === undefined ? undefined : cloneSnapshot(snapshot); + } + + write(sessionId: string, snapshot: ChatDisclosureSnapshot): void { + this.snapshots.set(sessionId, cloneSnapshot(snapshot)); + } +} + +function cloneSnapshot(snapshot: ChatDisclosureSnapshot): ChatDisclosureSnapshot { + return { open: [...snapshot.open], closedDefaultOpen: [...snapshot.closedDefaultOpen] }; +} + +describe("ChatDisclosureController", () => { + it("keeps a default-open live group closed after the user closes it", () => { + const storage = new MemoryDisclosureStorage(); + const controller = new ChatDisclosureController(storage); + const key = "s1:live:12"; + + controller.syncSession("s1"); + + expect(controller.isOpen(key, true)).toBe(true); + expect(controller.applyToggle(key, false, true)).toBe(true); + expect(controller.isOpen(key, true)).toBe(false); + expect(storage.read("s1")).toEqual({ open: [], closedDefaultOpen: [key] }); + }); + + it("allows a closed default-open group to be reopened by the user", () => { + const controller = new ChatDisclosureController(new MemoryDisclosureStorage()); + const key = "s1:live:12"; + + controller.syncSession("s1"); + controller.applyToggle(key, false, true); + + expect(controller.applyToggle(key, true, true)).toBe(true); + expect(controller.isOpen(key, true)).toBe(true); + expect(controller.snapshot()).toEqual({ open: [], closedDefaultOpen: [] }); + }); + + it("persists explicit opens for groups that are closed by default", () => { + const storage = new MemoryDisclosureStorage(); + const key = "s1:44"; + + const first = new ChatDisclosureController(storage); + first.syncSession("s1"); + first.applyToggle(key, true, false); + + const second = new ChatDisclosureController(storage); + second.syncSession("s1"); + + expect(second.isOpen(key, false)).toBe(true); + }); +}); + +describe("parseDisclosureSnapshot", () => { + it("hydrates legacy array storage as open group keys", () => { + expect(parseDisclosureSnapshot(["a", 1, "b"])).toEqual({ open: ["a", "b"], closedDefaultOpen: [] }); + }); + + it("hydrates object storage", () => { + expect(parseDisclosureSnapshot({ open: ["a"], closedDefaultOpen: ["b", false] })).toEqual({ open: ["a"], closedDefaultOpen: ["b"] }); + }); +}); diff --git a/src/client/src/chatDisclosure.ts b/src/client/src/chatDisclosure.ts new file mode 100644 index 0000000..70e6dfb --- /dev/null +++ b/src/client/src/chatDisclosure.ts @@ -0,0 +1,121 @@ +export interface ChatDisclosureSnapshot { + open: string[]; + closedDefaultOpen: string[]; +} + +export interface ChatDisclosureStorage { + read(sessionId: string): ChatDisclosureSnapshot | undefined; + write(sessionId: string, snapshot: ChatDisclosureSnapshot): void; +} + +const GROUP_STORAGE_PREFIX = "pi-web:chat-groups:"; + +const browserChatDisclosureStorage: ChatDisclosureStorage = { + read(sessionId: string): ChatDisclosureSnapshot | undefined { + try { + if (typeof localStorage === "undefined") return undefined; + const raw = localStorage.getItem(groupStorageKey(sessionId)); + if (raw === null || raw === "") return undefined; + return parseDisclosureSnapshot(JSON.parse(raw)); + } catch { + return undefined; + } + }, + + write(sessionId: string, snapshot: ChatDisclosureSnapshot): void { + try { + if (typeof localStorage === "undefined") return; + localStorage.setItem(groupStorageKey(sessionId), JSON.stringify(snapshot)); + } catch { + // Ignore storage failures; group disclosure should still work for this render. + } + }, +}; + +export class ChatDisclosureController { + private sessionId = ""; + private openGroupKeys = new Set(); + private closedDefaultOpenGroupKeys = new Set(); + + constructor(private readonly storage: ChatDisclosureStorage = browserChatDisclosureStorage) {} + + syncSession(sessionId: string): void { + if (this.sessionId === sessionId) return; + this.sessionId = sessionId; + const snapshot = sessionId === "" ? undefined : this.storage.read(sessionId); + this.openGroupKeys = new Set(snapshot?.open ?? []); + this.closedDefaultOpenGroupKeys = new Set(snapshot?.closedDefaultOpen ?? []); + } + + isOpen(groupKey: string, defaultOpen: boolean): boolean { + if (defaultOpen) return !this.closedDefaultOpenGroupKeys.has(groupKey); + return this.openGroupKeys.has(groupKey); + } + + applyToggle(groupKey: string, open: boolean, defaultOpen: boolean): boolean { + const wasOpen = this.isOpen(groupKey, defaultOpen); + const nextOpenKeys = new Set(this.openGroupKeys); + const nextClosedDefaultOpenKeys = new Set(this.closedDefaultOpenGroupKeys); + + if (defaultOpen) { + nextOpenKeys.delete(groupKey); + if (open) nextClosedDefaultOpenKeys.delete(groupKey); + else nextClosedDefaultOpenKeys.add(groupKey); + } else { + nextClosedDefaultOpenKeys.delete(groupKey); + if (open) nextOpenKeys.add(groupKey); + else nextOpenKeys.delete(groupKey); + } + + const nextOpen = defaultOpen ? !nextClosedDefaultOpenKeys.has(groupKey) : nextOpenKeys.has(groupKey); + if (nextOpen === wasOpen && setsEqual(nextOpenKeys, this.openGroupKeys) && setsEqual(nextClosedDefaultOpenKeys, this.closedDefaultOpenGroupKeys)) return false; + + this.openGroupKeys = nextOpenKeys; + this.closedDefaultOpenGroupKeys = nextClosedDefaultOpenKeys; + this.persist(); + return true; + } + + snapshot(): ChatDisclosureSnapshot { + return { + open: [...this.openGroupKeys], + closedDefaultOpen: [...this.closedDefaultOpenGroupKeys], + }; + } + + private persist(): void { + if (this.sessionId === "") return; + this.storage.write(this.sessionId, this.snapshot()); + } +} + +export function groupStorageKey(sessionId: string): string { + return `${GROUP_STORAGE_PREFIX}${sessionId}`; +} + +export function parseDisclosureSnapshot(value: unknown): ChatDisclosureSnapshot | undefined { + if (Array.isArray(value)) { + return { open: stringItems(value), closedDefaultOpen: [] }; + } + if (!isRecord(value)) return undefined; + return { + open: Array.isArray(value["open"]) ? stringItems(value["open"]) : [], + closedDefaultOpen: Array.isArray(value["closedDefaultOpen"]) ? stringItems(value["closedDefaultOpen"]) : [], + }; +} + +function stringItems(items: unknown[]): string[] { + return items.filter((item): item is string => typeof item === "string"); +} + +function setsEqual(left: Set, right: Set): boolean { + if (left.size !== right.size) return false; + for (const item of left) { + if (!right.has(item)) return false; + } + return true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/client/src/chatScrollPosition.test.ts b/src/client/src/chatScrollPosition.test.ts new file mode 100644 index 0000000..784ec21 --- /dev/null +++ b/src/client/src/chatScrollPosition.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { ChatScrollController, captureScrollPosition, chatScrollStorageKey, findFirstVisibleArticle, type ChatScrollElement, type ChatScrollScheduler, type ChatScrollStorage, type ChatScrollViewport } from "./chatScrollPosition"; + +class MemoryScrollStorage implements ChatScrollStorage { + readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } + + removeItem(key: string): void { + this.values.delete(key); + } +} + +class ManualScheduler implements ChatScrollScheduler { + private nextId = 1; + private readonly callbacks = new Map void>(); + + setTimeout(callback: () => void): number { + const id = this.nextId; + this.nextId += 1; + this.callbacks.set(id, callback); + return id; + } + + clearTimeout(id: number): void { + this.callbacks.delete(id); + } + + run(id: number): void { + const callback = this.callbacks.get(id); + if (callback === undefined) return; + this.callbacks.delete(id); + callback(); + } + + runAll(): void { + const ids = [...this.callbacks.keys()]; + for (const id of ids) this.run(id); + } +} + +class FakeScroller implements ChatScrollViewport { + constructor( + public scrollTop: number, + public scrollHeight: number, + public clientHeight: number, + private readonly top: number, + private readonly bottom: number, + ) {} + + getBoundingClientRect(): Pick { + return { top: this.top, bottom: this.bottom }; + } +} + +class FakeArticle implements ChatScrollElement { + readonly dataset: { readonly anchorKey?: string | undefined; readonly index?: string | undefined; readonly endIndex?: string | undefined }; + + constructor( + private readonly top: number, + private readonly bottom: number, + index: number, + key?: string, + endIndex?: number, + ) { + this.dataset = { + ...(key === undefined ? {} : { anchorKey: key }), + index: String(index), + ...(endIndex === undefined ? {} : { endIndex: String(endIndex) }), + }; + } + + getBoundingClientRect(): Pick { + return { top: this.top, bottom: this.bottom }; + } +} + +describe("ChatScrollController", () => { + it("skips saving while the scroll viewport is hidden", () => { + const storage = new MemoryScrollStorage(); + const controller = new ChatScrollController(storage, new ManualScheduler()); + 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")]); + + expect(result).toBe("skipped"); + expect(storage.getItem(key)).toBe("old"); + }); + + it("saves and restores the first visible article", () => { + 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")]; + + expect(controller.savePosition("s1", scroller, articles)).toBe("saved"); + + scroller.scrollTop = 500; + const rerenderedArticles = [new FakeArticle(120, 160, 0, "m:0"), new FakeArticle(220, 260, 1, "m:1")]; + + expect(controller.restorePosition("s1", scroller, rerenderedArticles)).toEqual({ status: "restored" }); + expect(scroller.scrollTop).toBe(580); + }); + + 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 }; + 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(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", () => { + 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(); + }); + + it("captures the session id when scheduling a delayed save", () => { + const scheduler = new ManualScheduler(); + const controller = new ChatScrollController(new MemoryScrollStorage(), scheduler); + const saved: string[] = []; + + controller.scheduleSave("s1", (sessionId) => { saved.push(sessionId); }); + controller.scheduleSave("s2", (sessionId) => { saved.push(sessionId); }); + scheduler.runAll(); + + expect(saved).toEqual(["s2"]); + }); +}); + +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"); + + 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 }); + }); +}); diff --git a/src/client/src/chatScrollPosition.ts b/src/client/src/chatScrollPosition.ts new file mode 100644 index 0000000..50e3388 --- /dev/null +++ b/src/client/src/chatScrollPosition.ts @@ -0,0 +1,208 @@ +export interface ChatScrollPosition { + index?: number; + endIndex?: number; + key?: string; + offset: number; +} + +export interface ChatScrollViewport { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + getBoundingClientRect(): Pick; +} + +export interface ChatScrollElement { + readonly dataset: { readonly anchorKey?: string | undefined; readonly index?: string | undefined; readonly endIndex?: string | undefined }; + getBoundingClientRect(): Pick; +} + +export interface ChatScrollStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export interface ChatScrollScheduler { + setTimeout(callback: () => void, delayMs: number): number; + clearTimeout(id: number): void; +} + +export type ChatScrollSaveResult = "saved" | "removed" | "skipped"; +export type ChatScrollRestoreResult = + | { status: "bottom" | "restored" | "skipped" } + | { status: "missing"; position: ChatScrollPosition }; + +const SCROLL_STORAGE_PREFIX = "pi-web:chat-scroll:"; +const DEFAULT_SAVE_DELAY_MS = 180; +const DEFAULT_NEAR_BOTTOM_THRESHOLD = 48; + +const browserScrollStorage: ChatScrollStorage = { + getItem(key: string): string | null { + if (typeof localStorage === "undefined") return null; + return localStorage.getItem(key); + }, + setItem(key: string, value: string): void { + if (typeof localStorage === "undefined") return; + localStorage.setItem(key, value); + }, + removeItem(key: string): void { + if (typeof localStorage === "undefined") return; + localStorage.removeItem(key); + }, +}; + +const browserScrollScheduler: ChatScrollScheduler = { + setTimeout(callback: () => void, delayMs: number): number { + return window.setTimeout(callback, delayMs); + }, + clearTimeout(id: number): void { + window.clearTimeout(id); + }, +}; + +export class ChatScrollController { + private saveTimer: number | undefined; + + constructor( + private readonly storage: ChatScrollStorage = browserScrollStorage, + private readonly scheduler: ChatScrollScheduler = browserScrollScheduler, + ) {} + + dispose(): void { + this.clearScheduledSave(); + } + + clearScheduledSave(): void { + if (this.saveTimer === undefined) return; + this.scheduler.clearTimeout(this.saveTimer); + this.saveTimer = undefined; + } + + scheduleSave(sessionId: string, save: (sessionId: string) => void, delayMs = DEFAULT_SAVE_DELAY_MS): void { + this.clearScheduledSave(); + this.saveTimer = this.scheduler.setTimeout(() => { + this.saveTimer = undefined; + save(sessionId); + }, delayMs); + } + + savePosition(sessionId: string, scroller: ChatScrollViewport | undefined, articles: ChatScrollElement[], nearBottomThreshold = DEFAULT_NEAR_BOTTOM_THRESHOLD): ChatScrollSaveResult { + if (sessionId === "" || scroller === undefined || !hasUsableScrollViewport(scroller)) return "skipped"; + try { + if (isNearScrollBottom(scroller, nearBottomThreshold)) { + this.storage.removeItem(chatScrollStorageKey(sessionId)); + return "removed"; + } + + const firstVisible = findFirstVisibleArticle(scroller, articles); + if (firstVisible === undefined) { + this.storage.removeItem(chatScrollStorageKey(sessionId)); + return "removed"; + } + + const position = captureScrollPosition(scroller, firstVisible); + this.storage.setItem(chatScrollStorageKey(sessionId), JSON.stringify(position)); + return "saved"; + } catch { + return "skipped"; + } + } + + restorePosition(sessionId: string, scroller: ChatScrollViewport | undefined, articles: 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); + } + + restoreExplicitPosition(position: ChatScrollPosition, scroller: ChatScrollViewport | undefined, articles: ChatScrollElement[], options?: { fallbackToBottom?: boolean | undefined }): ChatScrollRestoreResult { + if (scroller === undefined || !hasUsableScrollViewport(scroller)) return { status: "skipped" }; + const article = findArticleAt(articles, position); + if (article === undefined) { + if (options?.fallbackToBottom === false) return { status: "missing", position }; + return this.scrollToBottom(scroller); + } + const scrollerTop = scroller.getBoundingClientRect().top; + const currentOffset = article.getBoundingClientRect().top - scrollerTop; + scroller.scrollTop += currentOffset - position.offset; + return { status: "restored" }; + } + + readPosition(sessionId: string): ChatScrollPosition | undefined { + if (sessionId === "") return undefined; + try { + 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; + } catch { + return undefined; + } + } + + scrollToBottom(scroller: ChatScrollViewport | undefined): ChatScrollRestoreResult { + if (scroller === undefined || !hasUsableScrollViewport(scroller)) return { status: "skipped" }; + scroller.scrollTop = scroller.scrollHeight; + return { status: "bottom" }; + } +} + +export function chatScrollStorageKey(sessionId: string): string { + return `${SCROLL_STORAGE_PREFIX}${sessionId}`; +} + +export function isScrollPosition(value: unknown): value is ChatScrollPosition { + return typeof value === "object" + && value !== null + && "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")); +} + +export function hasUsableScrollViewport(scroller: Pick): boolean { + return scroller.clientHeight > 0 && scroller.scrollHeight > 0; +} + +export function distanceFromScrollBottom(scroller: Pick): number { + return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight; +} + +export function isNearScrollBottom(scroller: Pick, threshold = DEFAULT_NEAR_BOTTOM_THRESHOLD): boolean { + return distanceFromScrollBottom(scroller) < threshold; +} + +export function captureScrollPosition(scroller: ChatScrollViewport, article: ChatScrollElement): ChatScrollPosition { + const chatTop = scroller.getBoundingClientRect().top; + const key = article.dataset.anchorKey; + const index = numericDatasetValue(article.dataset.index); + const endIndex = numericDatasetValue(article.dataset.endIndex); + return { + ...(key === undefined ? {} : { key }), + ...(index === undefined ? {} : { index }), + ...(endIndex === undefined ? {} : { endIndex }), + offset: article.getBoundingClientRect().top - chatTop, + }; +} + +export function findFirstVisibleArticle(scroller: ChatScrollViewport, articles: T[]): T | undefined { + const scrollerRect = scroller.getBoundingClientRect(); + return articles.find((article) => { + const rect = article.getBoundingClientRect(); + return rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom; + }); +} + +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 numericDatasetValue(value: string | undefined): number | undefined { + if (value === undefined || value === "") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 8d26bf1..8aa2cd0 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -1,9 +1,11 @@ import { LitElement, html } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; +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 type { SessionActivity, SessionStatus } from "../api"; import type { ChatLine, ChatPart } from "./shared"; import { chatStyles } from "./shared"; @@ -27,14 +29,6 @@ function randomPartialStreamNoticeBody(): string { return partialStreamNoticeBodies[Math.floor(Math.random() * partialStreamNoticeBodies.length)] ?? partialStreamNoticeBodies[0]; } -function isScrollPosition(value: unknown): value is { index?: number; key?: string; offset: number } { - return typeof value === "object" - && value !== null - && "offset" in value - && typeof value.offset === "number" - && (("key" in value && typeof value.key === "string") || ("index" in value && typeof value.index === "number")); -} - function clampPercent(value: number): number { return clampNumber(value, 0, 100); } @@ -60,13 +54,13 @@ export class ChatView extends LitElement { @property({ attribute: false }) onLoadMore?: () => void; @query(".chat") private chat?: HTMLDivElement; @state() private pinnedToBottom = true; - @state() private openGroupKeys = new Set(); @state() private expandedMetaKey: string | undefined; @state() private copiedMessageKey: string | undefined; @state() private currentConversationIndex: number | undefined; + private readonly disclosures = new ChatDisclosureController(); + private readonly scrollController = new ChatScrollController(); private suppressScrollSave = false; private suppressLoadMoreRequests = false; - private saveScrollTimer?: number; private loadMoreCheckFrame: number | undefined; private scrollToBottomFrame: number | undefined; private conversationRailFrame: number | undefined; @@ -79,6 +73,10 @@ export class ChatView extends LitElement { private lastScrollTop = 0; private lastClientHeight = 0; private touchStartY: number | undefined; + private pendingScrollRestoreSessionId: string | undefined; + private pendingScrollRestorePosition: ChatScrollPosition | undefined; + private restoreScrollFrame: number | undefined; + private prependRestoreToken = 0; @state() private loadMoreRequested = false; private readonly onViewportResize = () => { if (this.pinnedToBottom) this.scrollToBottom(); @@ -96,7 +94,9 @@ export class ChatView extends LitElement { } override disconnectedCallback(): void { - window.clearTimeout(this.saveScrollTimer); + this.scrollController.dispose(); + this.prependRestoreToken += 1; + if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame); if (this.loadMoreCheckFrame !== undefined) cancelAnimationFrame(this.loadMoreCheckFrame); if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame); if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame); @@ -105,8 +105,22 @@ export class ChatView extends LitElement { super.disconnectedCallback(); } + private prepareSessionUiState(): void { + this.disclosures.syncSession(this.sessionId); + this.scrollController.clearScheduledSave(); + this.suppressScrollSave = false; + this.suppressLoadMoreRequests = false; + this.pendingScrollRestoreSessionId = undefined; + this.pendingScrollRestorePosition = undefined; + this.prependRestoreToken += 1; + if (this.restoreScrollFrame !== undefined) { + cancelAnimationFrame(this.restoreScrollFrame); + this.restoreScrollFrame = undefined; + } + } + protected override willUpdate(changed: Map): void { - if (changed.has("sessionId")) this.openGroupKeys = this.readOpenGroupKeys(); + if (changed.has("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()); } @@ -122,6 +136,7 @@ export class ChatView extends LitElement { if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false; 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(); if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded(); } @@ -134,7 +149,7 @@ export class ChatView extends LitElement { ${this.renderHistoryBoundary()} ${repeat( groups, - (group) => group.kind === "message" ? this.messageAnchorKey(group.index) : this.groupAnchorKey(group.endIndex), + (group) => group.kind === "message" ? this.messageAnchorKey(group.index) : this.groupRenderKey(group.startIndex), (group, index) => group.kind === "message" ? this.renderMessage(group.message, group.index) : this.renderMessageGroup(group.messages, group.startIndex, group.endIndex, this.isLiveTailGroup(groups, index)), @@ -285,7 +300,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))}
@@ -296,14 +311,14 @@ export class ChatView extends LitElement { return message.role === "tool" && message.parts.length > 0 && message.parts.every((part) => part.type === "toolExecution"); } - private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number, autoOpen: boolean) { - const key = this.groupKey(endIndex); - const open = autoOpen || this.openGroupKeys.has(key); + private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) { + const disclosureKey = this.groupDisclosureKey(startIndex, endIndex, defaultOpen); + const open = this.disclosures.isOpen(disclosureKey, defaultOpen); return html` ${this.renderScrollMarker(this.groupScrollMarkerId(endIndex))} -
{ this.onGroupToggle(key, event, autoOpen); }}> +
{ this.onGroupToggle(disclosureKey, event, defaultOpen); }}> - ${autoOpen ? "live events" : "events"} + ${defaultOpen ? "live events" : "events"} ${summarizeChatGroup(messages)}
@@ -452,17 +467,10 @@ export class ChatView extends LitElement { return null; } - private onGroupToggle(key: string, event: Event, autoOpen: boolean) { + private onGroupToggle(key: string, event: Event, defaultOpen: boolean) { const details = event.currentTarget; if (!(details instanceof HTMLDetailsElement)) return; - if (autoOpen && details.open) return; - const openGroupKeys = new Set(this.openGroupKeys); - const wasOpen = openGroupKeys.has(key); - if (details.open) openGroupKeys.add(key); - else openGroupKeys.delete(key); - if (openGroupKeys.has(key) === wasOpen) return; - this.openGroupKeys = openGroupKeys; - this.saveOpenGroupKeys(); + if (this.disclosures.applyToggle(key, details.open, defaultOpen)) this.requestUpdate(); } private onScroll() { @@ -541,13 +549,13 @@ export class ChatView extends LitElement { private isNearBottom(): boolean { const chat = this.chat; if (!chat) return true; - return this.distanceFromBottom(chat) < 48; + return isNearScrollBottom(chat); } private isAtBottom(): boolean { const chat = this.chat; if (!chat) return true; - return this.distanceFromBottom(chat) < 2; + return distanceFromScrollBottom(chat) < 2; } private canScrollUp(): boolean { @@ -555,10 +563,6 @@ export class ChatView extends LitElement { return chat !== undefined && chat.scrollTop > 0; } - private distanceFromBottom(chat: HTMLDivElement): number { - return chat.scrollHeight - chat.scrollTop - chat.clientHeight; - } - private scrollToBottom() { if (this.scrollToBottomFrame !== undefined) return; this.scrollToBottomFrame = requestAnimationFrame(() => { @@ -574,36 +578,62 @@ export class ChatView extends LitElement { } restoreScrollPosition() { - requestAnimationFrame(() => { - const chat = this.chat; - const stored = this.readStoredScrollPosition(); - if (!chat || !stored) { - this.withSuppressedScrollSave(() => { - if (chat) { - chat.scrollTop = chat.scrollHeight; - this.lastScrollTop = chat.scrollTop; - } - }); - return; - } - - const article = this.articleAt(stored); - if (!article) { - this.withSuppressedScrollSave(() => { - chat.scrollTop = chat.scrollHeight; - this.lastScrollTop = chat.scrollTop; - }); - return; - } + const sessionId = this.sessionId; + if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame); + this.restoreScrollFrame = requestAnimationFrame(() => { + this.restoreScrollFrame = undefined; + if (this.sessionId !== sessionId) return; this.withSuppressedScrollSave(() => { - const chatTop = chat.getBoundingClientRect().top; - const currentOffset = article.getBoundingClientRect().top - chatTop; - chat.scrollTop += currentOffset - stored.offset; - this.lastScrollTop = chat.scrollTop; + const result = this.scrollController.restorePosition(sessionId, this.chat, this.articles(), { fallbackToBottom: !this.hasMore }); + this.handleScrollRestoreResult(sessionId, result); }); }); } + private continuePendingScrollRestore(): void { + const sessionId = this.pendingScrollRestoreSessionId; + const position = this.pendingScrollRestorePosition; + if (sessionId === undefined || position === undefined || sessionId !== this.sessionId || this.restoreScrollFrame !== undefined) return; + this.restoreScrollFrame = requestAnimationFrame(() => { + this.restoreScrollFrame = undefined; + if (this.sessionId !== sessionId) return; + this.withSuppressedScrollSave(() => { + const result = this.scrollController.restoreExplicitPosition(position, this.chat, this.articles(), { fallbackToBottom: !this.hasMore }); + this.handleScrollRestoreResult(sessionId, result); + }); + }); + } + + private handleScrollRestoreResult(sessionId: string, result: ChatScrollRestoreResult): void { + this.syncScrollMetrics(); + if (result.status !== "missing") { + if (result.status === "restored" || result.status === "bottom") this.cancelPrependRestore(); + this.pendingScrollRestoreSessionId = undefined; + this.pendingScrollRestorePosition = undefined; + return; + } + + this.pendingScrollRestoreSessionId = sessionId; + this.pendingScrollRestorePosition = result.position; + const chat = this.chat; + if (chat === undefined || !this.hasMore || this.loadingMore) return; + chat.scrollTop = 0; + this.syncScrollMetrics(); + this.requestLoadMore(); + } + + private syncScrollMetrics(): void { + const chat = this.chat; + if (chat === undefined) return; + this.lastScrollTop = chat.scrollTop; + this.lastClientHeight = chat.clientHeight; + } + + private cancelPrependRestore(): void { + this.prependRestoreToken += 1; + this.suppressLoadMoreRequests = false; + } + capturePrependScrollAnchor(): PrependScrollAnchor | undefined { const chat = this.chat; if (!chat) return undefined; @@ -614,10 +644,12 @@ export class ChatView extends LitElement { if (!this.chat || !anchor) return; this.suppressLoadMoreRequests = true; this.suppressScrollSave = true; + const token = this.prependRestoreToken + 1; + this.prependRestoreToken = token; let frames = 0; const settle = () => { const chat = this.chat; - if (!chat) return; + if (!chat || token !== this.prependRestoreToken) return; restorePrependScrollAnchor(chat, anchor, anchor.markerId === undefined ? undefined : this.scrollMarkerAt(anchor.markerId)); this.lastScrollTop = chat.scrollTop; frames += 1; @@ -629,6 +661,7 @@ export class ChatView extends LitElement { return; } requestAnimationFrame(() => { + if (token !== this.prependRestoreToken) return; this.suppressScrollSave = false; this.suppressLoadMoreRequests = false; }); @@ -637,33 +670,15 @@ export class ChatView extends LitElement { } saveScrollPosition(sessionId = this.sessionId) { - const chat = this.chat; - if (!chat || !sessionId) return; - try { - if (this.isNearBottom()) { - localStorage.removeItem(this.storageKey(sessionId)); - return; - } - const firstVisible = this.firstVisibleArticle(); - if (!firstVisible) { - localStorage.removeItem(this.storageKey(sessionId)); - return; - } - const chatTop = chat.getBoundingClientRect().top; - const position = { - key: firstVisible.dataset["anchorKey"], - index: Number(firstVisible.dataset["index"] ?? 0), - offset: firstVisible.getBoundingClientRect().top - chatTop, - }; - localStorage.setItem(this.storageKey(sessionId), JSON.stringify(position)); - } catch { - // Ignore storage failures; scrolling should keep working without persistence. - } + if (!sessionId) return; + this.scrollController.savePosition(sessionId, this.chat, this.articles()); } private scheduleScrollPositionSave() { - window.clearTimeout(this.saveScrollTimer); - this.saveScrollTimer = window.setTimeout(() => { this.saveScrollPosition(); }, 180); + const sessionId = this.sessionId; + this.scrollController.scheduleSave(sessionId, (scheduledSessionId) => { + if (this.sessionId === scheduledSessionId) this.saveScrollPosition(scheduledSessionId); + }); } private scheduleConversationRailUpdate(): void { @@ -689,19 +704,6 @@ export class ChatView extends LitElement { this.currentConversationIndex = clampNumber(this.pinnedToBottom ? this.messageStart + this.messages.length - 1 : this.messageStart, 0, Math.max(0, total - 1)); } - private readStoredScrollPosition(): { index?: number; key?: string; offset: number } | undefined { - if (this.sessionId === "") return undefined; - try { - const raw = localStorage.getItem(this.storageKey()); - if (raw === null || raw === "") return undefined; - const value: unknown = JSON.parse(raw); - if (!isScrollPosition(value)) return undefined; - return value; - } catch { - return undefined; - } - } - private scrollMarkers(): HTMLElement[] { return Array.from(this.renderRoot.querySelectorAll(".scroll-marker")); } @@ -712,22 +714,9 @@ export class ChatView extends LitElement { private firstVisibleArticle(): HTMLElement | undefined { const chat = this.chat; - if (!chat) return undefined; - 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 { - const articles = this.articles(); - const keyed = position.key === undefined ? undefined : articles.find((article) => article.dataset["anchorKey"] === position.key); - if (keyed !== undefined) return keyed; - return articles.find((article) => Number(article.dataset["index"]) === position.index); + if (chat === undefined) return undefined; + const primaryArticles = Array.from(this.renderRoot.querySelectorAll("article.msg")); + return findFirstVisibleArticle(chat, primaryArticles) ?? findFirstVisibleArticle(chat, this.articles()); } private articles(): HTMLElement[] { @@ -744,34 +733,20 @@ 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}`; - } - - private groupStorageKey(sessionId = this.sessionId): string { - return `pi-web:chat-groups:${sessionId}`; - } - - private groupKey(endIndex: number): string { - return `${this.sessionId}:${String(endIndex)}`; + private groupDisclosureKey(startIndex: number, endIndex: number, defaultOpen: boolean): string { + return defaultOpen ? `${this.sessionId}:live:${String(startIndex)}` : `${this.sessionId}:${String(endIndex)}`; } private messageAnchorKey(index: number): string { return `m:${String(index)}`; } - private groupAnchorKey(endIndex: number): string { - return `g:${String(endIndex)}`; + private groupRenderKey(startIndex: number): string { + return `g:${String(startIndex)}`; + } + + private groupAnchorKey(startIndex: number): string { + return `g:${String(startIndex)}`; } private messageScrollMarkerId(index: number): string { @@ -782,25 +757,5 @@ export class ChatView extends LitElement { return `g:${String(endIndex)}`; } - private readOpenGroupKeys(): Set { - if (this.sessionId === "") return new Set(); - try { - const raw = localStorage.getItem(this.groupStorageKey()); - const value: unknown = raw !== null && raw !== "" ? JSON.parse(raw) : []; - return new Set(Array.isArray(value) ? value.filter((item) => typeof item === "string") : []); - } catch { - return new Set(); - } - } - - private saveOpenGroupKeys(): void { - if (this.sessionId === "") return; - try { - localStorage.setItem(this.groupStorageKey(), JSON.stringify([...this.openGroupKeys])); - } catch { - // Ignore storage failures; group expansion should still work for this render. - } - } - static override styles = chatStyles; }