Archived
Improve UI render responsiveness
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user