Coordinate chat scroll restoration

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 12:48:02 +02:00
parent 1db6b5f8f6
commit fd28af3c93
2 changed files with 39 additions and 26 deletions
+17 -19
View File
@@ -10,28 +10,21 @@ export class ChatView extends LitElement {
@property() sessionId = "";
@query(".chat") private chat?: HTMLDivElement;
@state() private pinnedToBottom = true;
private restoreAfterUpdate = true;
private suppressScrollSave = false;
private saveScrollTimer?: number;
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;
disconnectedCallback(): void {
window.clearTimeout(this.saveScrollTimer);
super.disconnectedCallback();
}
protected willUpdate(): void {
this.pinnedToBottom = this.isNearBottom();
}
protected updated(): void {
if (this.restoreAfterUpdate) {
this.restoreAfterUpdate = false;
this.restoreScrollPosition();
} else if (this.pinnedToBottom) {
this.scrollToBottom();
}
protected updated(changed: Map<string, unknown>): void {
if (changed.has("sessionId")) return;
if (changed.has("messages") && this.pinnedToBottom) this.scrollToBottom();
}
render() {
@@ -62,7 +55,7 @@ export class ChatView extends LitElement {
private onScroll() {
this.pinnedToBottom = this.isNearBottom();
if (!this.suppressScrollSave) this.saveScrollPosition();
if (!this.suppressScrollSave) this.scheduleScrollPositionSave();
}
private isNearBottom(): boolean {
@@ -81,7 +74,7 @@ export class ChatView extends LitElement {
});
}
private restoreScrollPosition() {
restoreScrollPosition() {
requestAnimationFrame(() => {
const chat = this.chat;
const stored = this.readStoredScrollPosition();
@@ -107,7 +100,7 @@ export class ChatView extends LitElement {
});
}
private saveScrollPosition(sessionId = this.sessionId) {
saveScrollPosition(sessionId = this.sessionId) {
const chat = this.chat;
if (!chat || !sessionId) return;
try {
@@ -131,6 +124,11 @@ export class ChatView extends LitElement {
}
}
private scheduleScrollPositionSave() {
window.clearTimeout(this.saveScrollTimer);
this.saveScrollTimer = window.setTimeout(() => this.saveScrollPosition(), 180);
}
private readStoredScrollPosition(): { index: number; offset: number } | undefined {
if (!this.sessionId) return undefined;
try {
+21 -6
View File
@@ -1,5 +1,5 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement, query, state } from "lit/decorators.js";
import type { Project, SessionInfo, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState";
import { ProjectController } from "../controllers/projectController";
@@ -10,6 +10,7 @@ import "./ProjectList";
import "./WorkspaceList";
import "./SessionList";
import "./ChatView";
import type { ChatView } from "./ChatView";
import "./PromptEditor";
import "./StatusBar";
import "./CommandPicker";
@@ -18,6 +19,7 @@ import { appStyles } from "./shared";
@customElement("pi-web-poc")
export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState();
@query("chat-view") private chatView?: ChatView;
private readonly sessions = new SessionController(
() => this.state,
@@ -35,7 +37,7 @@ export class PiWebApp extends LitElement {
(patch) => this.setState(patch),
this.workspaces,
);
private readonly onPopState = () => void this.restoreRoute(false);
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
connectedCallback(): void {
super.connectedCallback();
@@ -55,7 +57,7 @@ export class PiWebApp extends LitElement {
private async loadProjectsAndRestoreRoute() {
await this.projects.loadProjects();
await this.restoreRoute(false);
await this.withChatScrollTransition(() => this.restoreRoute(false));
}
private async restoreRoute(updateUrl: boolean) {
@@ -66,6 +68,15 @@ export class PiWebApp extends LitElement {
await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl });
}
private async withChatScrollTransition(action: () => Promise<void>) {
this.chatView?.saveScrollPosition();
await action();
await this.updateComplete;
await this.chatView?.updateComplete;
await nextFrame();
this.chatView?.restoreScrollPosition();
}
private updateUrl() {
writeRoute({
projectId: this.state.selectedProject?.id,
@@ -83,9 +94,9 @@ export class PiWebApp extends LitElement {
<strong>Pi Web POC</strong>
<button @click=${() => this.projects.addProject()}>+ Project</button>
</header>
<project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.workspaces.selectProject(project)}></project-list>
<workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.workspaces.selectWorkspace(workspace)}></workspace-list>
<session-list .sessions=${state.sessions} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.sessions.startSession()} .onSelect=${(session: SessionInfo) => this.sessions.selectSession(session)}></session-list>
<project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))}></project-list>
<workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
<session-list .sessions=${state.sessions} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))}></session-list>
</aside>
<main>
${state.error ? html`<div class="error">${state.error}</div>` : null}
@@ -102,3 +113,7 @@ export class PiWebApp extends LitElement {
static styles = appStyles;
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}