Improve UI render responsiveness

This commit is contained in:
Federico Jaramillo Martinez
2026-05-12 11:46:09 +02:00
parent 14657d26a9
commit 01f7d72716
2 changed files with 59 additions and 14 deletions
+45 -12
View File
@@ -1,7 +1,7 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js"; import { repeat } from "lit/directives/repeat.js";
import { groupChatMessages, summarizeChatGroup } from "../chatGroups"; import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups";
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring"; import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
import { shouldRequestEarlierMessages } from "../chatHistoryLoading"; import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import type { SessionActivity, SessionStatus } from "../api"; import type { SessionActivity, SessionStatus } from "../api";
@@ -9,6 +9,9 @@ import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared"; import { chatStyles } from "./shared";
import "./FormattedText"; import "./FormattedText";
const shortTimestampFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
const fullTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
function isScrollPosition(value: unknown): value is { index?: number; key?: string; offset: number } { function isScrollPosition(value: unknown): value is { index?: number; key?: string; offset: number } {
return typeof value === "object" return typeof value === "object"
&& value !== null && value !== null
@@ -39,6 +42,13 @@ export class ChatView extends LitElement {
private suppressScrollSave = false; private suppressScrollSave = false;
private suppressLoadMoreRequests = false; private suppressLoadMoreRequests = false;
private saveScrollTimer?: number; private saveScrollTimer?: number;
private loadMoreCheckFrame: number | undefined;
private scrollToBottomFrame: number | undefined;
private groupedMessagesInput?: ChatLine[];
private groupedMessagesStart = 0;
private groupedMessagesCache: ChatGroup[] = [];
private readonly messageMetaCache = new WeakMap<ChatLine, { short: string; full: string }>();
private readonly messageCopyTextCache = new WeakMap<ChatLine, string>();
private lastScrollTop = 0; private lastScrollTop = 0;
private lastClientHeight = 0; private lastClientHeight = 0;
private touchStartY: number | undefined; private touchStartY: number | undefined;
@@ -60,6 +70,8 @@ export class ChatView extends LitElement {
override disconnectedCallback(): void { override disconnectedCallback(): void {
window.clearTimeout(this.saveScrollTimer); window.clearTimeout(this.saveScrollTimer);
if (this.loadMoreCheckFrame !== undefined) cancelAnimationFrame(this.loadMoreCheckFrame);
if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame);
window.removeEventListener("resize", this.onViewportResize); window.removeEventListener("resize", this.onViewportResize);
window.visualViewport?.removeEventListener("resize", this.onViewportResize); window.visualViewport?.removeEventListener("resize", this.onViewportResize);
super.disconnectedCallback(); super.disconnectedCallback();
@@ -90,7 +102,7 @@ export class ChatView extends LitElement {
<div class="chat" @scroll=${() => { this.onScroll(); }} @wheel=${(event: WheelEvent) => { this.onWheel(event); }} @touchstart=${(event: TouchEvent) => { this.onTouchStart(event); }} @touchmove=${(event: TouchEvent) => { this.onTouchMove(event); }}> <div class="chat" @scroll=${() => { this.onScroll(); }} @wheel=${(event: WheelEvent) => { this.onWheel(event); }} @touchstart=${(event: TouchEvent) => { this.onTouchStart(event); }} @touchmove=${(event: TouchEvent) => { this.onTouchMove(event); }}>
${this.renderHistoryBoundary()} ${this.renderHistoryBoundary()}
${repeat( ${repeat(
groupChatMessages(this.messages, this.messageStart), this.groupedMessages(),
(group) => group.kind === "message" ? this.messageAnchorKey(group.index) : this.groupAnchorKey(group.endIndex), (group) => group.kind === "message" ? this.messageAnchorKey(group.index) : this.groupAnchorKey(group.endIndex),
(group) => group.kind === "message" (group) => group.kind === "message"
? this.renderMessage(group.message, group.index) ? this.renderMessage(group.message, group.index)
@@ -104,6 +116,14 @@ export class ChatView extends LitElement {
`; `;
} }
private groupedMessages(): ChatGroup[] {
if (this.groupedMessagesInput === this.messages && this.groupedMessagesStart === this.messageStart) return this.groupedMessagesCache;
this.groupedMessagesInput = this.messages;
this.groupedMessagesStart = this.messageStart;
this.groupedMessagesCache = groupChatMessages(this.messages, this.messageStart);
return this.groupedMessagesCache;
}
private renderActivityDock() { private renderActivityDock() {
const state = this.activityState(); const state = this.activityState();
if (state === undefined) return null; if (state === undefined) return null;
@@ -277,11 +297,15 @@ export class ChatView extends LitElement {
} }
private messageCopyText(message: ChatLine): string { private messageCopyText(message: ChatLine): string {
return message.parts const cached = this.messageCopyTextCache.get(message);
if (cached !== undefined) return cached;
const text = message.parts
.filter((part): part is Extract<ChatPart, { type: "text" }> => part.type === "text") .filter((part): part is Extract<ChatPart, { type: "text" }> => part.type === "text")
.map((part) => part.text.trim()) .map((part) => part.text.trim())
.filter((text) => text !== "") .filter((partText) => partText !== "")
.join("\n\n"); .join("\n\n");
this.messageCopyTextCache.set(message, text);
return text;
} }
private async copyMessage(message: ChatLine, key: string, event: MouseEvent): Promise<void> { private async copyMessage(message: ChatLine, key: string, event: MouseEvent): Promise<void> {
@@ -304,22 +328,27 @@ export class ChatView extends LitElement {
} }
private messageMetaLabel(message: ChatLine): { short: string; full: string } { private messageMetaLabel(message: ChatLine): { short: string; full: string } {
const cached = this.messageMetaCache.get(message);
if (cached !== undefined) return cached;
const timestamp = message.meta?.timestamp; const timestamp = message.meta?.timestamp;
const model = this.modelLabel(message); const model = this.modelLabel(message);
if (timestamp === undefined && model === undefined) return { short: "no info", full: "No Pi message metadata available" }; if (timestamp === undefined && model === undefined) {
const empty = { short: "no info", full: "No Pi message metadata available" };
this.messageMetaCache.set(message, empty);
return empty;
}
const time = timestamp === undefined ? undefined : this.formatTimestamp(timestamp); const time = timestamp === undefined ? undefined : this.formatTimestamp(timestamp);
const parts = [time?.short, model].filter((part): part is string => part !== undefined && part !== ""); const parts = [time?.short, model].filter((part): part is string => part !== undefined && part !== "");
const fullParts = [time?.full, model === undefined ? undefined : `Model: ${model}`].filter((part): part is string => part !== undefined && part !== ""); const fullParts = [time?.full, model === undefined ? undefined : `Model: ${model}`].filter((part): part is string => part !== undefined && part !== "");
return { short: parts.join(" · "), full: fullParts.join(" · ") }; const label = { short: parts.join(" · "), full: fullParts.join(" · ") };
this.messageMetaCache.set(message, label);
return label;
} }
private formatTimestamp(timestamp: string): { short: string; full: string } | undefined { private formatTimestamp(timestamp: string): { short: string; full: string } | undefined {
const date = new Date(timestamp); const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return undefined; if (!Number.isFinite(date.getTime())) return undefined;
return { return { short: shortTimestampFormatter.format(date), full: fullTimestampFormatter.format(date) };
short: new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(date),
full: new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(date),
};
} }
private modelLabel(message: ChatLine): string | undefined { private modelLabel(message: ChatLine): string | undefined {
@@ -415,7 +444,9 @@ export class ChatView extends LitElement {
} }
private requestLoadMoreIfNeeded(): void { private requestLoadMoreIfNeeded(): void {
requestAnimationFrame(() => { if (this.loadMoreCheckFrame !== undefined) return;
this.loadMoreCheckFrame = requestAnimationFrame(() => {
this.loadMoreCheckFrame = undefined;
if (this.suppressLoadMoreRequests) return; if (this.suppressLoadMoreRequests) return;
const chat = this.chat; const chat = this.chat;
if (!chat) return; if (!chat) return;
@@ -459,7 +490,9 @@ export class ChatView extends LitElement {
} }
private scrollToBottom() { private scrollToBottom() {
requestAnimationFrame(() => { if (this.scrollToBottomFrame !== undefined) return;
this.scrollToBottomFrame = requestAnimationFrame(() => {
this.scrollToBottomFrame = undefined;
const chat = this.chat; const chat = this.chat;
if (!chat) return; if (!chat) return;
this.withSuppressedScrollSave(() => { this.withSuppressedScrollSave(() => {
+14 -2
View File
@@ -65,13 +65,18 @@ export class PiWebApp extends LitElement {
private readonly keyboard = new KeyboardShortcutDispatcher(); private readonly keyboard = new KeyboardShortcutDispatcher();
private readonly realtime = new RealtimeSocket(); private readonly realtime = new RealtimeSocket();
private readonly activeTerminalIds = new Set<string>(); private readonly activeTerminalIds = new Set<string>();
private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined;
private terminalAutoStartWorkspaceId: string | undefined; private terminalAutoStartWorkspaceId: string | undefined;
private readonly plugins = createPluginRegistry(); private readonly plugins = createPluginRegistry();
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
private readonly onFocus = () => { void this.sessions.refreshSelectedSession(); }; private readonly onFocus = () => { void this.sessions.refreshSelectedSession(); };
private readonly onVisibilityChange = () => { private readonly onVisibilityChange = () => {
if (document.visibilityState === "visible") void this.sessions.refreshSelectedSession(); if (document.visibilityState === "visible") void this.sessions.refreshSelectedSession();
}; };
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
this.isMobileNavigationLayout = event.matches;
};
private readonly onKeyDown = (event: KeyboardEvent) => { private readonly onKeyDown = (event: KeyboardEvent) => {
if (this.keyboard.handle(event, this.getActions())) { if (this.keyboard.handle(event, this.getActions())) {
event.preventDefault(); event.preventDefault();
@@ -85,6 +90,7 @@ export class PiWebApp extends LitElement {
window.addEventListener("focus", this.onFocus); window.addEventListener("focus", this.onFocus);
document.addEventListener("visibilitychange", this.onVisibilityChange); document.addEventListener("visibilitychange", this.onVisibilityChange);
window.addEventListener("keydown", this.onKeyDown); window.addEventListener("keydown", this.onKeyDown);
this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange);
this.connectRealtime(); this.connectRealtime();
void this.loadExternalPlugins(); void this.loadExternalPlugins();
void this.loadProjectsAndRestoreRoute(); void this.loadProjectsAndRestoreRoute();
@@ -95,6 +101,7 @@ export class PiWebApp extends LitElement {
window.removeEventListener("focus", this.onFocus); window.removeEventListener("focus", this.onFocus);
document.removeEventListener("visibilitychange", this.onVisibilityChange); document.removeEventListener("visibilitychange", this.onVisibilityChange);
window.removeEventListener("keydown", this.onKeyDown); window.removeEventListener("keydown", this.onKeyDown);
this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange);
this.keyboard.reset(); this.keyboard.reset();
this.sessions.dispose(); this.sessions.dispose();
this.realtime.close(); this.realtime.close();
@@ -103,6 +110,7 @@ export class PiWebApp extends LitElement {
} }
private setState(patch: Partial<AppState>) { private setState(patch: Partial<AppState>) {
if (!patchChangesState(this.state, patch)) return;
const previous = this.state; const previous = this.state;
this.state = { ...this.state, ...patch }; this.state = { ...this.state, ...patch };
this.handleActivityTransition(previous, this.state); this.handleActivityTransition(previous, this.state);
@@ -376,7 +384,7 @@ export class PiWebApp extends LitElement {
const state = this.state; const state = this.state;
return html` return html`
<div class=${`shell ${state.mainView === "navigation" ? "navigation-view" : state.mainView === "chat" ? "chat-view" : "workspace-view"}`}> <div class=${`shell ${state.mainView === "navigation" ? "navigation-view" : state.mainView === "chat" ? "chat-view" : "workspace-view"}`}>
<aside>${this.renderNavigationPanel(false)}</aside> <aside>${this.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
<main class=${state.mainView === "chat" ? "chat-view" : state.mainView === "navigation" ? "navigation-view" : "workspace-view"}> <main class=${state.mainView === "chat" ? "chat-view" : state.mainView === "navigation" ? "navigation-view" : "workspace-view"}>
<div class="mobile-tabs"> <div class="mobile-tabs">
<button class=${state.mainView === "navigation" ? "mobile-navigation-tab selected" : "mobile-navigation-tab"} @click=${() => { this.selectMainView("navigation"); }}>Sessions</button> <button class=${state.mainView === "navigation" ? "mobile-navigation-tab selected" : "mobile-navigation-tab"} @click=${() => { this.selectMainView("navigation"); }}>Sessions</button>
@@ -386,7 +394,7 @@ export class PiWebApp extends LitElement {
`)} `)}
</div> </div>
${state.error ? html`<div class="error">${state.error}</div>` : null} ${state.error ? html`<div class="error">${state.error}</div>` : null}
<div class="mobile-navigation-panel">${this.renderNavigationPanel(true)}</div> <div class="mobile-navigation-panel">${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
${state.selectedSession ? html` ${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view> <chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor> <prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
@@ -412,6 +420,10 @@ function createPluginRegistry(): PluginRegistry {
return registry; return registry;
} }
function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
}
function isActive(status: AppState["status"]): boolean { function isActive(status: AppState["status"]): boolean {
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true; return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
} }