Archived
Coordinate chat scroll restoration
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user