Persist chat scroll per session

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 12:39:47 +02:00
parent 9be9d410e1
commit 1db6b5f8f6
2 changed files with 123 additions and 6 deletions
+121 -4
View File
@@ -7,22 +7,38 @@ import "./FormattedText";
@customElement("chat-view")
export class ChatView extends LitElement {
@property({ attribute: false }) messages: ChatLine[] = [];
@property() sessionId = "";
@query(".chat") private chat?: HTMLDivElement;
@state() private pinnedToBottom = true;
private restoreAfterUpdate = true;
private suppressScrollSave = false;
protected willUpdate(): void {
protected willUpdate(changed: Map<string, unknown>): void {
if (changed.has("sessionId")) {
const previousSessionId = changed.get("sessionId");
if (typeof previousSessionId === "string" && previousSessionId) this.saveScrollPosition(previousSessionId);
this.suppressScrollSave = true;
this.pinnedToBottom = true;
this.restoreAfterUpdate = true;
return;
}
this.pinnedToBottom = this.isNearBottom();
}
protected updated(): void {
if (this.pinnedToBottom) this.scrollToBottom();
if (this.restoreAfterUpdate) {
this.restoreAfterUpdate = false;
this.restoreScrollPosition();
} else if (this.pinnedToBottom) {
this.scrollToBottom();
}
}
render() {
return html`
<div class="chat" @scroll=${this.onScroll}>
${this.messages.map((message) => html`
<article class="msg ${message.role}">
${this.messages.map((message, index) => html`
<article class="msg ${message.role}" data-index=${index}>
<b class="label">${message.role}</b>
${message.parts.map((part) => this.renderPart(part))}
</article>
@@ -46,6 +62,7 @@ export class ChatView extends LitElement {
private onScroll() {
this.pinnedToBottom = this.isNearBottom();
if (!this.suppressScrollSave) this.saveScrollPosition();
}
private isNearBottom(): boolean {
@@ -55,8 +72,108 @@ export class ChatView extends LitElement {
}
private scrollToBottom() {
requestAnimationFrame(() => {
const chat = this.chat;
if (!chat) return;
this.withSuppressedScrollSave(() => {
chat.scrollTop = chat.scrollHeight;
});
});
}
private restoreScrollPosition() {
requestAnimationFrame(() => {
const chat = this.chat;
const stored = this.readStoredScrollPosition();
if (!chat || !stored) {
this.withSuppressedScrollSave(() => {
if (chat) chat.scrollTop = chat.scrollHeight;
});
return;
}
const article = this.articleAt(stored.index);
if (!article) {
this.withSuppressedScrollSave(() => {
chat.scrollTop = chat.scrollHeight;
});
return;
}
this.withSuppressedScrollSave(() => {
const chatTop = chat.getBoundingClientRect().top;
const currentOffset = article.getBoundingClientRect().top - chatTop;
chat.scrollTop += currentOffset - stored.offset;
});
});
}
private 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 = {
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.
}
}
private readStoredScrollPosition(): { index: number; offset: number } | undefined {
if (!this.sessionId) return undefined;
try {
const raw = localStorage.getItem(this.storageKey());
if (!raw) return undefined;
const value = JSON.parse(raw);
if (typeof value?.index !== "number" || typeof value?.offset !== "number") return undefined;
return { index: value.index, offset: value.offset };
} catch {
return undefined;
}
}
private firstVisibleArticle(): HTMLElement | undefined {
const chat = this.chat;
if (!chat) return undefined;
const chatRect = chat.getBoundingClientRect();
return this.articles().find((article) => {
const rect = article.getBoundingClientRect();
return rect.bottom >= chatRect.top && rect.top <= chatRect.bottom;
});
}
private articleAt(index: number): HTMLElement | undefined {
return this.articles().find((article) => Number(article.dataset.index) === index);
}
private articles(): HTMLElement[] {
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>("article.msg"));
}
private withSuppressedScrollSave(callback: () => void) {
this.suppressScrollSave = true;
callback();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.suppressScrollSave = false;
});
});
}
private storageKey(sessionId = this.sessionId): string {
return `pi-web:chat-scroll:${sessionId}`;
}
static styles = chatStyles;
+1 -1
View File
@@ -91,7 +91,7 @@ export class PiWebApp extends LitElement {
${state.error ? html`<div class="error">${state.error}</div>` : null}
${state.selectedSession ? html`
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
<chat-view .messages=${state.messages}></chat-view>
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .onSend=${(text: string) => this.sessions.send(text)} .onCloseSession=${() => this.sessions.closeSession()}></prompt-editor>
${state.commandDialog ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog!.requestId, value)} .onCancel=${() => this.sessions.cancelCommand()}></command-picker>` : null}
` : html`<div class="empty">Select or start a session.</div>`}