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