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 { customElement, property, query, state } from "lit/decorators.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 { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import type { SessionActivity, SessionStatus } from "../api";
@@ -9,6 +9,9 @@ import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared";
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 } {
return typeof value === "object"
&& value !== null
@@ -39,6 +42,13 @@ export class ChatView extends LitElement {
private suppressScrollSave = false;
private suppressLoadMoreRequests = false;
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 lastClientHeight = 0;
private touchStartY: number | undefined;
@@ -60,6 +70,8 @@ export class ChatView extends LitElement {
override disconnectedCallback(): void {
window.clearTimeout(this.saveScrollTimer);
if (this.loadMoreCheckFrame !== undefined) cancelAnimationFrame(this.loadMoreCheckFrame);
if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame);
window.removeEventListener("resize", this.onViewportResize);
window.visualViewport?.removeEventListener("resize", this.onViewportResize);
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); }}>
${this.renderHistoryBoundary()}
${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.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() {
const state = this.activityState();
if (state === undefined) return null;
@@ -277,11 +297,15 @@ export class ChatView extends LitElement {
}
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")
.map((part) => part.text.trim())
.filter((text) => text !== "")
.filter((partText) => partText !== "")
.join("\n\n");
this.messageCopyTextCache.set(message, text);
return text;
}
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 } {
const cached = this.messageMetaCache.get(message);
if (cached !== undefined) return cached;
const timestamp = message.meta?.timestamp;
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 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 !== "");
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 {
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return undefined;
return {
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),
};
return { short: shortTimestampFormatter.format(date), full: fullTimestampFormatter.format(date) };
}
private modelLabel(message: ChatLine): string | undefined {
@@ -415,7 +444,9 @@ export class ChatView extends LitElement {
}
private requestLoadMoreIfNeeded(): void {
requestAnimationFrame(() => {
if (this.loadMoreCheckFrame !== undefined) return;
this.loadMoreCheckFrame = requestAnimationFrame(() => {
this.loadMoreCheckFrame = undefined;
if (this.suppressLoadMoreRequests) return;
const chat = this.chat;
if (!chat) return;
@@ -459,7 +490,9 @@ export class ChatView extends LitElement {
}
private scrollToBottom() {
requestAnimationFrame(() => {
if (this.scrollToBottomFrame !== undefined) return;
this.scrollToBottomFrame = requestAnimationFrame(() => {
this.scrollToBottomFrame = undefined;
const chat = this.chat;
if (!chat) return;
this.withSuppressedScrollSave(() => {
+14 -2
View File
@@ -65,13 +65,18 @@ export class PiWebApp extends LitElement {
private readonly keyboard = new KeyboardShortcutDispatcher();
private readonly realtime = new RealtimeSocket();
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 readonly plugins = createPluginRegistry();
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
private readonly onFocus = () => { void this.sessions.refreshSelectedSession(); };
private readonly onVisibilityChange = () => {
if (document.visibilityState === "visible") void this.sessions.refreshSelectedSession();
};
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
this.isMobileNavigationLayout = event.matches;
};
private readonly onKeyDown = (event: KeyboardEvent) => {
if (this.keyboard.handle(event, this.getActions())) {
event.preventDefault();
@@ -85,6 +90,7 @@ export class PiWebApp extends LitElement {
window.addEventListener("focus", this.onFocus);
document.addEventListener("visibilitychange", this.onVisibilityChange);
window.addEventListener("keydown", this.onKeyDown);
this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange);
this.connectRealtime();
void this.loadExternalPlugins();
void this.loadProjectsAndRestoreRoute();
@@ -95,6 +101,7 @@ export class PiWebApp extends LitElement {
window.removeEventListener("focus", this.onFocus);
document.removeEventListener("visibilitychange", this.onVisibilityChange);
window.removeEventListener("keydown", this.onKeyDown);
this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange);
this.keyboard.reset();
this.sessions.dispose();
this.realtime.close();
@@ -103,6 +110,7 @@ export class PiWebApp extends LitElement {
}
private setState(patch: Partial<AppState>) {
if (!patchChangesState(this.state, patch)) return;
const previous = this.state;
this.state = { ...this.state, ...patch };
this.handleActivityTransition(previous, this.state);
@@ -376,7 +384,7 @@ export class PiWebApp extends LitElement {
const state = this.state;
return html`
<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"}>
<div class="mobile-tabs">
<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>
${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`
<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>
@@ -412,6 +420,10 @@ function createPluginRegistry(): PluginRegistry {
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 {
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
}