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 = ""; @property() sessionId = "";
@query(".chat") private chat?: HTMLDivElement; @query(".chat") private chat?: HTMLDivElement;
@state() private pinnedToBottom = true; @state() private pinnedToBottom = true;
private restoreAfterUpdate = true;
private suppressScrollSave = false; private suppressScrollSave = false;
private saveScrollTimer?: number;
protected willUpdate(changed: Map<string, unknown>): void { disconnectedCallback(): void {
if (changed.has("sessionId")) { window.clearTimeout(this.saveScrollTimer);
const previousSessionId = changed.get("sessionId"); super.disconnectedCallback();
if (typeof previousSessionId === "string" && previousSessionId) this.saveScrollPosition(previousSessionId);
this.suppressScrollSave = true;
this.pinnedToBottom = true;
this.restoreAfterUpdate = true;
return;
} }
protected willUpdate(): void {
this.pinnedToBottom = this.isNearBottom(); this.pinnedToBottom = this.isNearBottom();
} }
protected updated(): void { protected updated(changed: Map<string, unknown>): void {
if (this.restoreAfterUpdate) { if (changed.has("sessionId")) return;
this.restoreAfterUpdate = false; if (changed.has("messages") && this.pinnedToBottom) this.scrollToBottom();
this.restoreScrollPosition();
} else if (this.pinnedToBottom) {
this.scrollToBottom();
}
} }
render() { render() {
@@ -62,7 +55,7 @@ export class ChatView extends LitElement {
private onScroll() { private onScroll() {
this.pinnedToBottom = this.isNearBottom(); this.pinnedToBottom = this.isNearBottom();
if (!this.suppressScrollSave) this.saveScrollPosition(); if (!this.suppressScrollSave) this.scheduleScrollPositionSave();
} }
private isNearBottom(): boolean { private isNearBottom(): boolean {
@@ -81,7 +74,7 @@ export class ChatView extends LitElement {
}); });
} }
private restoreScrollPosition() { restoreScrollPosition() {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const chat = this.chat; const chat = this.chat;
const stored = this.readStoredScrollPosition(); 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; const chat = this.chat;
if (!chat || !sessionId) return; if (!chat || !sessionId) return;
try { 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 { private readStoredScrollPosition(): { index: number; offset: number } | undefined {
if (!this.sessionId) return undefined; if (!this.sessionId) return undefined;
try { try {
+21 -6
View File
@@ -1,5 +1,5 @@
import { LitElement, html } from "lit"; 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 type { Project, SessionInfo, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { ProjectController } from "../controllers/projectController"; import { ProjectController } from "../controllers/projectController";
@@ -10,6 +10,7 @@ import "./ProjectList";
import "./WorkspaceList"; import "./WorkspaceList";
import "./SessionList"; import "./SessionList";
import "./ChatView"; import "./ChatView";
import type { ChatView } from "./ChatView";
import "./PromptEditor"; import "./PromptEditor";
import "./StatusBar"; import "./StatusBar";
import "./CommandPicker"; import "./CommandPicker";
@@ -18,6 +19,7 @@ import { appStyles } from "./shared";
@customElement("pi-web-poc") @customElement("pi-web-poc")
export class PiWebApp extends LitElement { export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState(); @state() private state: AppState = initialAppState();
@query("chat-view") private chatView?: ChatView;
private readonly sessions = new SessionController( private readonly sessions = new SessionController(
() => this.state, () => this.state,
@@ -35,7 +37,7 @@ export class PiWebApp extends LitElement {
(patch) => this.setState(patch), (patch) => this.setState(patch),
this.workspaces, this.workspaces,
); );
private readonly onPopState = () => void this.restoreRoute(false); private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
@@ -55,7 +57,7 @@ export class PiWebApp extends LitElement {
private async loadProjectsAndRestoreRoute() { private async loadProjectsAndRestoreRoute() {
await this.projects.loadProjects(); await this.projects.loadProjects();
await this.restoreRoute(false); await this.withChatScrollTransition(() => this.restoreRoute(false));
} }
private async restoreRoute(updateUrl: boolean) { 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 }); 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() { private updateUrl() {
writeRoute({ writeRoute({
projectId: this.state.selectedProject?.id, projectId: this.state.selectedProject?.id,
@@ -83,9 +94,9 @@ export class PiWebApp extends LitElement {
<strong>Pi Web POC</strong> <strong>Pi Web POC</strong>
<button @click=${() => this.projects.addProject()}>+ Project</button> <button @click=${() => this.projects.addProject()}>+ Project</button>
</header> </header>
<project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.workspaces.selectProject(project)}></project-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.workspaces.selectWorkspace(workspace)}></workspace-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.sessions.startSession()} .onSelect=${(session: SessionInfo) => this.sessions.selectSession(session)}></session-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> </aside>
<main> <main>
${state.error ? html`<div class="error">${state.error}</div>` : null} ${state.error ? html`<div class="error">${state.error}</div>` : null}
@@ -102,3 +113,7 @@ export class PiWebApp extends LitElement {
static styles = appStyles; static styles = appStyles;
} }
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}