Archived
fix: stabilize chat scroll and disclosure state
This commit is contained in:
@@ -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.
|
||||
@@ -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<string, ChatDisclosureSnapshot>();
|
||||
|
||||
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"] });
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
private closedDefaultOpenGroupKeys = new Set<string>();
|
||||
|
||||
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<string>, right: Set<string>): 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<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -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<string, string>();
|
||||
|
||||
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<number, () => 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<DOMRectReadOnly, "top" | "bottom"> {
|
||||
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<DOMRectReadOnly, "top" | "bottom"> {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<DOMRectReadOnly, "top" | "bottom">;
|
||||
}
|
||||
|
||||
export interface ChatScrollElement {
|
||||
readonly dataset: { readonly anchorKey?: string | undefined; readonly index?: string | undefined; readonly endIndex?: string | undefined };
|
||||
getBoundingClientRect(): Pick<DOMRectReadOnly, "top" | "bottom">;
|
||||
}
|
||||
|
||||
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<ChatScrollViewport, "clientHeight" | "scrollHeight">): boolean {
|
||||
return scroller.clientHeight > 0 && scroller.scrollHeight > 0;
|
||||
}
|
||||
|
||||
export function distanceFromScrollBottom(scroller: Pick<ChatScrollViewport, "scrollHeight" | "scrollTop" | "clientHeight">): number {
|
||||
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
||||
}
|
||||
|
||||
export function isNearScrollBottom(scroller: Pick<ChatScrollViewport, "scrollHeight" | "scrollTop" | "clientHeight">, 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<T extends ChatScrollElement>(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<T extends ChatScrollElement>(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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
@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<string, unknown>): 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))}
|
||||
<article class=${toolOnly ? "msg tool-execution-shell" : `msg ${message.role}`} data-index=${index} data-anchor-key=${this.messageAnchorKey(index)}>
|
||||
<article class=${toolOnly ? "msg tool-execution-shell" : `msg ${message.role}`} data-index=${index} data-end-index=${index} data-anchor-key=${this.messageAnchorKey(index)}>
|
||||
${toolOnly ? null : this.renderMessageHeader(message, String(index))}
|
||||
${message.parts.map((part) => this.renderPart(part, message))}
|
||||
</article>
|
||||
@@ -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))}
|
||||
<details class=${autoOpen ? "msg event-group live" : "msg event-group"} data-index=${startIndex} data-anchor-key=${this.groupAnchorKey(endIndex)} ?open=${open} @toggle=${(event: Event) => { this.onGroupToggle(key, event, autoOpen); }}>
|
||||
<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); }}>
|
||||
<summary>
|
||||
<b class="label">${autoOpen ? "live events" : "events"}</b>
|
||||
<b class="label">${defaultOpen ? "live events" : "events"}</b>
|
||||
<span>${summarizeChatGroup(messages)}</span>
|
||||
</summary>
|
||||
<div class="group-body">
|
||||
@@ -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<HTMLElement>(".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<HTMLElement>(selector)).find((article) => {
|
||||
const rect = article.getBoundingClientRect();
|
||||
return rect.bottom >= chatRect.top && rect.top <= chatRect.bottom;
|
||||
});
|
||||
};
|
||||
return firstVisible("article.msg") ?? firstVisible("article.msg, details.msg");
|
||||
}
|
||||
|
||||
private articleAt(position: { index?: number; key?: string }): HTMLElement | undefined {
|
||||
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<HTMLElement>("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<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user