Archived
Persist chat scroll per session
This commit is contained in:
@@ -7,22 +7,38 @@ import "./FormattedText";
|
|||||||
@customElement("chat-view")
|
@customElement("chat-view")
|
||||||
export class ChatView extends LitElement {
|
export class ChatView extends LitElement {
|
||||||
@property({ attribute: false }) messages: ChatLine[] = [];
|
@property({ attribute: false }) messages: ChatLine[] = [];
|
||||||
|
@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;
|
||||||
|
|
||||||
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();
|
this.pinnedToBottom = this.isNearBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected updated(): void {
|
protected updated(): void {
|
||||||
if (this.pinnedToBottom) this.scrollToBottom();
|
if (this.restoreAfterUpdate) {
|
||||||
|
this.restoreAfterUpdate = false;
|
||||||
|
this.restoreScrollPosition();
|
||||||
|
} else if (this.pinnedToBottom) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="chat" @scroll=${this.onScroll}>
|
<div class="chat" @scroll=${this.onScroll}>
|
||||||
${this.messages.map((message) => html`
|
${this.messages.map((message, index) => html`
|
||||||
<article class="msg ${message.role}">
|
<article class="msg ${message.role}" data-index=${index}>
|
||||||
<b class="label">${message.role}</b>
|
<b class="label">${message.role}</b>
|
||||||
${message.parts.map((part) => this.renderPart(part))}
|
${message.parts.map((part) => this.renderPart(part))}
|
||||||
</article>
|
</article>
|
||||||
@@ -46,6 +62,7 @@ export class ChatView extends LitElement {
|
|||||||
|
|
||||||
private onScroll() {
|
private onScroll() {
|
||||||
this.pinnedToBottom = this.isNearBottom();
|
this.pinnedToBottom = this.isNearBottom();
|
||||||
|
if (!this.suppressScrollSave) this.saveScrollPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
private isNearBottom(): boolean {
|
private isNearBottom(): boolean {
|
||||||
@@ -55,8 +72,108 @@ export class ChatView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private scrollToBottom() {
|
private scrollToBottom() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
const chat = this.chat;
|
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;
|
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;
|
static styles = chatStyles;
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export class PiWebApp extends LitElement {
|
|||||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||||
${state.selectedSession ? html`
|
${state.selectedSession ? html`
|
||||||
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
|
<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>
|
<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}
|
${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>`}
|
` : html`<div class="empty">Select or start a session.</div>`}
|
||||||
|
|||||||
Reference in New Issue
Block a user