diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 193c920..ea38527 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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(); + private readonly messageCopyTextCache = new WeakMap(); 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 {
{ 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 => 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 { @@ -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(() => { diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 096c4fb..8c622c2 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -65,13 +65,18 @@ export class PiWebApp extends LitElement { private readonly keyboard = new KeyboardShortcutDispatcher(); private readonly realtime = new RealtimeSocket(); private readonly activeTerminalIds = new Set(); + 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) { + 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`
- +
@@ -386,7 +394,7 @@ export class PiWebApp extends LitElement { `)}
${state.error ? html`
${state.error}
` : null} -
${this.renderNavigationPanel(true)}
+
${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
${state.selectedSession ? html` 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())}> 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(); }}> @@ -412,6 +420,10 @@ function createPluginRegistry(): PluginRegistry { return registry; } +function patchChangesState(state: AppState, patch: Partial): 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; }