diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 4247552..f2b330d 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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): 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): 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 { diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 4a7bb20..00c1fd3 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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) { + 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 { Pi Web POC - this.workspaces.selectProject(project)}> - this.workspaces.selectWorkspace(workspace)}> - this.sessions.startSession()} .onSelect=${(session: SessionInfo) => this.sessions.selectSession(session)}> + this.withChatScrollTransition(() => this.workspaces.selectProject(project))}> + this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}> + this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))}>
${state.error ? html`
${state.error}
` : null} @@ -102,3 +113,7 @@ export class PiWebApp extends LitElement { static styles = appStyles; } + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +}