This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/src/client/src/components/ChatView.ts
T

841 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { ChatDisclosureController } from "../chatDisclosure";
import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups";
import { writeClipboardText } from "../clipboard";
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
import type { QueuedSessionMessage, SessionActivity, SessionStatus } from "../api";
import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared";
import "./ConversationMeter";
import "./FormattedText";
import "./ToolExecutionView";
const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
const partialStreamNoticeBodies = [
"You opened this chat while the assistant was already replying. The complete answer will appear shortly.",
"We joined mid-sentence. Holding the curtain until the full reply is ready.",
"The assistant started before this tab arrived. Well show the full answer when it lands.",
"Catching the reply in one piece — no spoilers, no half-answers.",
"The tokens are still assembling themselves. Full answer incoming.",
"We arrived fashionably late to this response. The complete version will appear soon.",
] as const;
function randomPartialStreamNoticeBody(): string {
return partialStreamNoticeBodies[Math.floor(Math.random() * partialStreamNoticeBodies.length)] ?? partialStreamNoticeBodies[0];
}
function clampPercent(value: number): number {
return clampNumber(value, 0, 100);
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
export interface QueuedMessageSection {
source: "client" | "server";
heading: string;
detail: string;
messages: QueuedSessionMessage[];
}
export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] {
return [
clientQueued.length === 0 ? undefined : { source: "client", heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued },
serverQueued.length === 0 ? undefined : { source: "server", heading: "Queued messages", detail: `${String(serverQueued.length)} pending`, messages: serverQueued },
].filter((section): section is QueuedMessageSection => section !== undefined);
}
export function chatMessageMetadataLabel(message: ChatLine): string {
const timestamp = message.meta?.timestamp;
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
const model = chatMessageModelLabel(message);
const parts = [time, model].filter((part): part is string => part !== undefined && part !== "");
return parts.length === 0 ? "No Pi message metadata available" : parts.join(" · ");
}
function formatMessageTimestamp(timestamp: string): string | undefined {
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return undefined;
return messageTimestampFormatter.format(date);
}
function chatMessageModelLabel(message: ChatLine): string | undefined {
const model = message.meta?.model;
if (model === undefined) return undefined;
const id = model.responseId ?? model.id;
if (id === undefined || id === "") return model.provider;
return model.provider !== undefined && model.provider !== "" ? `${model.provider}/${id}` : id;
}
@customElement("chat-view")
export class ChatView extends LitElement {
@property({ attribute: false }) messages: ChatLine[] = [];
@property() sessionId = "";
@property({ type: Number }) messageStart = 0;
@property({ type: Number }) messageEnd = 0;
@property({ type: Number }) messageTotal = 0;
@property({ type: Boolean }) hasMore = false;
@property({ type: Boolean }) loadingMore = false;
@property({ type: Boolean }) isReceivingPartialStream = false;
@property({ type: Boolean }) isSendingPrompt = false;
@property({ type: Boolean }) isCompacting = false;
@property({ type: Number }) pendingMessageCount = 0;
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
@property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) activity?: SessionActivity;
@property({ type: Boolean }) canClearServerQueue = false;
@property({ attribute: false }) onClearServerQueue?: () => void;
@property({ attribute: false }) onLoadMore?: () => void;
@query(".chat") private chat?: HTMLDivElement;
@state() private pinnedToBottom = true;
@state() private expandedMetaKey: string | undefined;
@state() private copiedMessageKey: string | undefined;
@state() private currentConversationIndex: number | undefined;
private readonly disclosures = new ChatDisclosureController();
private readonly scrollController = new ChatScrollController();
private suppressScrollSave = false;
private suppressLoadMoreRequests = false;
private loadMoreCheckFrame: number | undefined;
private scrollToBottomFrame: number | undefined;
private conversationRailFrame: number | undefined;
private groupedMessagesInput?: ChatLine[];
private groupedMessagesStart = 0;
private groupedMessagesCache: ChatGroup[] = [];
private readonly messageMetaCache = new WeakMap<ChatLine, string>();
private readonly messageCopyTextCache = new WeakMap<ChatLine, string>();
private partialStreamNoticeBody: string | undefined;
private lastScrollTop = 0;
private lastClientHeight = 0;
private touchStartY: number | undefined;
private pendingScrollRestoreSessionId: string | undefined;
private pendingScrollRestorePosition: ChatAnchorScrollPosition | undefined;
private restoreScrollFrame: number | undefined;
private prependRestoreToken = 0;
@state() private loadMoreRequested = false;
private readonly onViewportResize = () => {
if (this.pinnedToBottom) this.scrollToBottom();
else this.lastClientHeight = this.chat?.clientHeight ?? 0;
};
private readonly onPageHide = () => {
this.saveScrollPosition();
};
private readonly handleClearServerQueue = (): void => {
this.onClearServerQueue?.();
};
override connectedCallback(): void {
super.connectedCallback();
window.addEventListener("resize", this.onViewportResize);
window.addEventListener("pagehide", this.onPageHide);
window.visualViewport?.addEventListener("resize", this.onViewportResize);
}
protected override firstUpdated(): void {
this.lastClientHeight = this.chat?.clientHeight ?? 0;
}
override disconnectedCallback(): void {
this.saveScrollPosition();
this.scrollController.dispose();
this.prependRestoreToken += 1;
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
if (this.loadMoreCheckFrame !== undefined) cancelAnimationFrame(this.loadMoreCheckFrame);
if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame);
if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame);
window.removeEventListener("resize", this.onViewportResize);
window.removeEventListener("pagehide", this.onPageHide);
window.visualViewport?.removeEventListener("resize", this.onViewportResize);
super.disconnectedCallback();
}
private savePreviousSessionScrollPosition(previousSessionId: unknown): void {
if (typeof previousSessionId !== "string" || previousSessionId === "" || previousSessionId === this.sessionId) return;
this.saveScrollPosition(previousSessionId);
}
private prepareSessionUiState(): void {
this.disclosures.syncSession(this.sessionId);
this.scrollController.clearScheduledSave();
this.suppressScrollSave = false;
this.suppressLoadMoreRequests = false;
this.pendingScrollRestoreSessionId = undefined;
this.pendingScrollRestorePosition = undefined;
this.prependRestoreToken += 1;
if (this.restoreScrollFrame !== undefined) {
cancelAnimationFrame(this.restoreScrollFrame);
this.restoreScrollFrame = undefined;
}
}
protected override willUpdate(changed: Map<string, unknown>): void {
if (changed.has("sessionId")) {
this.savePreviousSessionScrollPosition(changed.get("sessionId"));
this.prepareSessionUiState();
}
if (changed.has("isReceivingPartialStream") || (changed.has("sessionId") && this.isReceivingPartialStream)) this.syncPartialStreamNoticeBody();
if (changed.has("messages")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
}
protected override update(changed: Map<string, unknown>): void {
const prependAnchor = this.isPrependingMessages(changed) ? this.capturePrependScrollAnchor() : undefined;
super.update(changed);
if (prependAnchor !== undefined) this.restorePrependScrollAnchor(prependAnchor);
}
protected override updated(changed: Map<string, unknown>): void {
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
if (changed.has("sessionId")) this.restoreScrollPosition();
if (!changed.has("sessionId") && changed.has("messages") && this.pinnedToBottom) this.scrollToBottom();
if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore")) this.continuePendingScrollRestore();
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
}
override render() {
const groups = this.groupedMessages();
return html`
<div class="chat-wrap">
${this.renderConversationRail()}
<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(
groups,
(group) => group.kind === "message" ? this.messageAnchorKey(group.index) : this.groupRenderKey(group.startIndex),
(group, index) => group.kind === "message"
? this.renderMessage(group.message, group.index)
: this.renderMessageGroup(group.messages, group.startIndex, group.endIndex, this.isLiveTailGroup(groups, index)),
)}
${this.renderQueuedMessages()}
${this.renderSessionActivity()}
</div>
${this.renderActivityDock()}
</div>
`;
}
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 isLiveTailGroup(groups: ChatGroup[], index: number): boolean {
return index === groups.length - 1 && this.isSessionLive();
}
private isSessionLive(): boolean {
return this.isSendingPrompt
|| this.status?.isStreaming === true
|| this.status?.isCompacting === true
|| this.status?.isBashRunning === true
|| this.activity?.phase === "active";
}
private renderActivityDock() {
if (this.isSendingPrompt) {
return html`
<div class="activity-dock active" aria-live="polite">
<span class="dot"></span>
<span class="activity-text">Sending your message…</span>
</div>
`;
}
const state = this.activityState();
if (state === undefined) return null;
const active = state !== "idle" || this.activity?.phase === "active";
return html`
<div class=${active ? "activity-dock active" : "activity-dock"} aria-live="polite">
<span class="dot"></span>
<span class="activity-text">${this.activityText(state)}</span>
</div>
`;
}
private renderQueuedMessages() {
const serverQueued = this.status?.queuedMessages ?? [];
return html`${chatQueuedMessageSections(this.clientQueuedMessages, serverQueued).map((section) => this.renderQueuedMessageList(section))}`;
}
private renderQueuedMessageList(section: QueuedMessageSection) {
const canClear = section.source === "server" && this.canClearServerQueue && this.onClearServerQueue !== undefined;
return html`
<aside class="queued-messages" aria-live="polite">
<div class="queued-header">
<div class="queued-heading">
<strong>${section.heading}</strong>
<small>${section.detail}</small>
</div>
${canClear ? html`
<button type="button" class="queued-clear-button" title="Clear queued messages without stopping active work" @click=${this.handleClearServerQueue}>Clear queue</button>
` : null}
</div>
${section.messages.map((message, index) => html`
<div class="queued-message">
<span class="queued-kind">${message.kind === "steer" ? "Steer" : "Follow-up"} ${String(index + 1)}</span>
<formatted-text .text=${message.text}></formatted-text>
</div>
`)}
</aside>
`;
}
private renderSessionActivity() {
if (this.isReceivingPartialStream) return html`
<aside class="session-activity receiving" aria-live="polite">
<strong>Catching up…</strong>
<span>${this.currentPartialStreamNoticeBody()}</span>
</aside>
`;
if (!this.isCompacting) return null;
return html`
<aside class="session-activity compacting" aria-live="polite">
<strong>Compacting history…</strong>
<span>The agent is summarizing earlier context. New prompts will be queued until compaction finishes.</span>
${this.pendingMessageCount > 0 ? html`<small>${this.pendingMessageCount} queued ${this.pendingMessageCount === 1 ? "message" : "messages"}</small>` : null}
</aside>
`;
}
private syncPartialStreamNoticeBody(): void {
this.partialStreamNoticeBody = this.isReceivingPartialStream ? randomPartialStreamNoticeBody() : undefined;
}
private currentPartialStreamNoticeBody(): string {
this.partialStreamNoticeBody ??= randomPartialStreamNoticeBody();
return this.partialStreamNoticeBody;
}
private activityState(): string | undefined {
const status = this.status;
if (status === undefined) return this.activity?.label;
if (status.isCompacting) return "compacting";
if (status.isBashRunning) return "bash";
if (status.isStreaming) return "running";
if (status.pendingMessageCount > 0) return "queued";
return "idle";
}
private activityText(state: string): string {
const activity = this.activity;
if (activity === undefined) return state;
if (state !== "idle" && activity.phase === "idle") return state;
return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label;
}
private renderConversationRail() {
if (!this.messages.length || this.messageTotal <= 0) return null;
const total = this.conversationDisplayTotal();
const position = this.conversationPositionPercent(total);
const loadedPercent = this.hasMore ? clampPercent((this.messages.length / total) * 100) : 100;
return html`<conversation-meter .positionPercent=${position} .loadedPercent=${loadedPercent}></conversation-meter>`;
}
private conversationDisplayTotal(): number {
if (!this.hasMore && this.messageStart === 0) return Math.max(1, this.messages.length);
return Math.max(1, this.messageTotal, this.messageStart + this.messages.length);
}
private conversationPositionPercent(total = this.conversationDisplayTotal()): number {
if (total <= 1) return 100;
const fallbackIndex = this.pinnedToBottom ? this.messageStart + this.messages.length - 1 : this.messageStart;
const index = clampNumber(this.currentConversationIndex ?? fallbackIndex, 0, total - 1);
return clampPercent((index / (total - 1)) * 100);
}
private renderHistoryBoundary() {
const range = this.historyRangeLabel();
if (this.loadingMore) return html`<div class="history-boundary"><span>Loading earlier messages…</span>${range}</div>`;
if (this.hasMore) return html`
<div class="history-boundary">
<button type="button" class="history-load-button" ?disabled=${this.loadMoreRequested} @click=${() => { this.requestLoadMore(); }}>Load earlier messages</button>
<span>Scroll up to load earlier messages</span>
${range}
</div>
`;
if (this.messages.length) return html`<div class="history-boundary"><span>Beginning of session</span>${range}</div>`;
return null;
}
private historyRangeLabel() {
if (!this.messages.length || this.messageTotal <= 0) return null;
const from = this.messageStart + 1;
const to = this.loadedRawMessageEnd();
const total = Math.max(this.messageTotal, to);
return html`<small>Showing messages ${from}${to} of ${total}</small>`;
}
private loadedRawMessageEnd(): number {
return Math.max(this.messageEnd, this.messageStart + this.messages.length);
}
private renderMessage(message: ChatLine, index: number) {
const toolOnly = this.isToolExecutionOnlyMessage(message);
return html`
${this.renderScrollMarker(this.messageScrollMarkerId(index))}
<article class=${toolOnly ? "msg tool-execution-shell" : `msg ${message.role}`} data-index=${index} data-scroll-anchor-id=${this.messageAnchorKey(index)}>
${toolOnly ? null : this.renderMessageHeader(message, String(index))}
${message.parts.map((part) => this.renderPart(part, message))}
</article>
`;
}
private isToolExecutionOnlyMessage(message: ChatLine): boolean {
return message.role === "tool" && message.parts.length > 0 && message.parts.every((part) => part.type === "toolExecution");
}
private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) {
const disclosureKey = this.groupDisclosureKey(startIndex, endIndex, defaultOpen);
const open = this.disclosures.isOpen(disclosureKey, defaultOpen);
return html`
${this.renderScrollMarker(this.groupScrollMarkerId(endIndex))}
<details class=${defaultOpen ? "msg event-group live" : "msg event-group"} data-index=${startIndex} data-scroll-anchor-id=${this.groupAnchorKey(startIndex)} ?open=${open} @toggle=${(event: Event) => { this.onGroupToggle(disclosureKey, event, defaultOpen); }}>
<summary>
<b class="label">${defaultOpen ? "live events" : "events"}</b>
<span>${summarizeChatGroup(messages)}</span>
</summary>
${open ? this.renderMessageGroupBody(messages, startIndex) : null}
</details>
`;
}
private renderMessageGroupBody(messages: ChatLine[], startIndex: number) {
return html`
<div class="group-body">
${messages.map((message, offset) => {
const toolOnly = this.isToolExecutionOnlyMessage(message);
return html`
<section class=${toolOnly ? "group-msg tool-execution-shell" : `group-msg ${message.role}`} data-index=${startIndex + offset} data-scroll-anchor-id=${this.eventAnchorKey(startIndex + offset)}>
${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
${message.parts.map((part) => this.renderPart(part, message))}
</section>
`;
})}
</div>
`;
}
private renderScrollMarker(markerId: string) {
return html`<span class="scroll-marker" data-marker-id=${markerId} aria-hidden="true"></span>`;
}
private renderMessageHeader(message: ChatLine, key: string) {
const meta = this.messageMetaLabel(message);
const expanded = this.expandedMetaKey === key;
return html`
<div class="msg-header">
<b class="label">${message.role}</b>
<div class="msg-header-trailing">
${this.renderMessageActions(message, key)}
<span class=${expanded ? "msg-meta expanded" : "msg-meta"} role="button" tabindex="0" title=${meta} aria-label=${meta} aria-expanded=${String(expanded)} @click=${() => { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta}</span>
</div>
</div>
`;
}
private renderMessageActions(message: ChatLine, key: string) {
if (!this.isCopyableMessage(message)) return null;
const copied = this.copiedMessageKey === key;
return html`
<div class="msg-actions" aria-label="Message actions">
<button type="button" class="msg-action" title=${copied ? "Copied" : "Copy message"} aria-label=${`${copied ? "Copied" : "Copy"} ${message.role} message`} @click=${(event: MouseEvent) => { void this.copyMessage(message, key, event); }}>
<span aria-hidden="true">${copied ? "✓" : "⧉"}</span>
</button>
</div>
`;
}
private onMetaKeydown(event: KeyboardEvent, key: string, expanded: boolean) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
this.expandedMetaKey = expanded ? undefined : key;
}
private isCopyableMessage(message: ChatLine): boolean {
return (message.role === "user" || message.role === "assistant") && this.messageCopyText(message) !== "";
}
private messageCopyText(message: ChatLine): string {
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((partText) => partText !== "")
.join("\n\n");
this.messageCopyTextCache.set(message, text);
return text;
}
private async copyMessage(message: ChatLine, key: string, event: MouseEvent): Promise<void> {
event.stopPropagation();
const copied = await writeClipboardText(this.messageCopyText(message));
if (!copied) return;
this.copiedMessageKey = key;
window.setTimeout(() => {
if (this.copiedMessageKey === key) this.copiedMessageKey = undefined;
}, 1200);
}
private messageMetaLabel(message: ChatLine): string {
const cached = this.messageMetaCache.get(message);
if (cached !== undefined) return cached;
const label = chatMessageMetadataLabel(message);
this.messageMetaCache.set(message, label);
return label;
}
private renderPart(part: ChatPart, message?: ChatLine) {
if (part.type === "text" && message?.role === "bash") return html`<pre class="part shell-output">${part.text}</pre>`;
if (part.type === "text") return html`<formatted-text class="part" .text=${part.text}></formatted-text>`;
if (part.type === "thinking") return html`<details class="part"><summary>thinking</summary><formatted-text .text=${part.text}></formatted-text></details>`;
if (part.type === "skillInvocation") return html`
<details class="part skill-invocation">
<summary><b>[skill]</b> ${part.name}</summary>
<small>${part.location}</small>
<formatted-text .text=${part.content}></formatted-text>
</details>
`;
if (part.type === "skillRead") return html`
<div class="part skill-read">
<strong>Loaded ${part.name}</strong>
<small>read ${part.path}</small>
</div>
`;
if (part.type === "image") return html`<img class="part chat-image" src=${`data:${part.mimeType};base64,${part.data}`} alt="attached image" loading="lazy" />`;
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
if (part.type === "toolExecution") return html`<tool-execution-view class="part" .execution=${part}></tool-execution-view>`;
if (part.type === "toolResult") return html`
<details class="part" ?open=${part.isError}>
<summary>${part.isError ? "✖" : "✓"} ${part.toolName} result</summary>
<formatted-text .text=${part.text}></formatted-text>
</details>
`;
return null;
}
private onGroupToggle(key: string, event: Event, defaultOpen: boolean) {
const details = event.currentTarget;
if (!(details instanceof HTMLDetailsElement)) return;
if (this.disclosures.applyToggle(key, details.open, defaultOpen)) this.requestUpdate();
}
private onScroll() {
this.requestLoadMoreIfNeeded();
this.updatePinnedToBottomFromScroll();
this.scheduleConversationRailUpdate();
if (!this.suppressScrollSave) this.scheduleScrollPositionSave();
}
private onWheel(event: WheelEvent) {
if (event.deltaY < 0 && this.canScrollUp()) this.pinnedToBottom = false;
}
private onTouchStart(event: TouchEvent) {
this.touchStartY = event.touches[0]?.clientY;
}
private onTouchMove(event: TouchEvent) {
const y = event.touches[0]?.clientY;
if (this.touchStartY !== undefined && y !== undefined && y > this.touchStartY && this.canScrollUp()) this.pinnedToBottom = false;
}
private updatePinnedToBottomFromScroll() {
const chat = this.chat;
if (!chat) return;
const heightChanged = this.didChatHeightChange();
const wasPinnedToBottom = this.pinnedToBottom;
const scrollingUp = chat.scrollTop < this.lastScrollTop;
if (heightChanged && wasPinnedToBottom) {
this.lastClientHeight = chat.clientHeight;
this.scrollToBottom();
return;
}
if (this.isAtBottom()) this.pinnedToBottom = true;
else if (scrollingUp) this.pinnedToBottom = false;
else this.pinnedToBottom = this.isNearBottom();
this.lastScrollTop = chat.scrollTop;
this.lastClientHeight = chat.clientHeight;
}
private didChatHeightChange(): boolean {
const chat = this.chat;
return chat !== undefined && this.lastClientHeight !== 0 && chat.clientHeight !== this.lastClientHeight;
}
private isPrependingMessages(changed: Map<string, unknown>): boolean {
const oldMessageStart = changed.get("messageStart");
return typeof oldMessageStart === "number" && this.messageStart < oldMessageStart;
}
private requestLoadMoreIfNeeded(): void {
if (this.loadMoreCheckFrame !== undefined) return;
this.loadMoreCheckFrame = requestAnimationFrame(() => {
this.loadMoreCheckFrame = undefined;
if (this.suppressLoadMoreRequests) return;
const chat = this.chat;
if (!chat) return;
if (shouldRequestEarlierMessages({
hasMore: this.hasMore,
loadingMore: this.loadingMore || this.loadMoreRequested,
canRequest: this.onLoadMore !== undefined,
scrollTop: chat.scrollTop,
scrollHeight: chat.scrollHeight,
clientHeight: chat.clientHeight,
})) this.requestLoadMore();
});
}
private requestLoadMore(): void {
if (this.loadMoreRequested) return;
if (!this.hasMore || this.loadingMore || this.onLoadMore === undefined) return;
this.loadMoreRequested = true;
this.onLoadMore();
}
private isNearBottom(): boolean {
const chat = this.chat;
if (!chat) return true;
return isNearScrollBottom(chat);
}
private isAtBottom(): boolean {
const chat = this.chat;
if (!chat) return true;
return distanceFromScrollBottom(chat) < 2;
}
private canScrollUp(): boolean {
const chat = this.chat;
return chat !== undefined && chat.scrollTop > 0;
}
private scrollToBottom() {
if (this.scrollToBottomFrame !== undefined) return;
this.scrollToBottomFrame = requestAnimationFrame(() => {
this.scrollToBottomFrame = undefined;
const chat = this.chat;
if (!chat) return;
this.withSuppressedScrollSave(() => {
chat.scrollTop = chat.scrollHeight;
this.lastScrollTop = chat.scrollTop;
this.lastClientHeight = chat.clientHeight;
});
});
}
restoreScrollPosition() {
const sessionId = this.sessionId;
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
this.restoreScrollFrame = requestAnimationFrame(() => {
this.restoreScrollFrame = undefined;
if (this.sessionId !== sessionId) return;
this.withSuppressedScrollSave(() => {
const result = this.scrollController.restorePosition(sessionId, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() });
this.handleScrollRestoreResult(sessionId, result);
});
});
}
private continuePendingScrollRestore(): void {
const sessionId = this.pendingScrollRestoreSessionId;
const position = this.pendingScrollRestorePosition;
if (sessionId === undefined || position === undefined || sessionId !== this.sessionId || this.restoreScrollFrame !== undefined) return;
this.restoreScrollFrame = requestAnimationFrame(() => {
this.restoreScrollFrame = undefined;
if (this.sessionId !== sessionId) return;
this.withSuppressedScrollSave(() => {
const result = this.scrollController.restoreExplicitPosition(position, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() });
this.handleScrollRestoreResult(sessionId, result);
});
});
}
private handleScrollRestoreResult(sessionId: string, result: ChatScrollRestoreResult): void {
this.syncScrollMetrics();
if (result.status !== "missing") {
this.updatePinnedToBottomAfterRestore(result.status);
if (result.status === "restored" || result.status === "bottom") this.cancelPrependRestore();
this.pendingScrollRestoreSessionId = undefined;
this.pendingScrollRestorePosition = undefined;
return;
}
this.pinnedToBottom = false;
this.pendingScrollRestoreSessionId = sessionId;
this.pendingScrollRestorePosition = result.position;
const chat = this.chat;
if (chat === undefined || !this.hasMore || this.loadingMore) return;
chat.scrollTop = 0;
this.syncScrollMetrics();
this.requestLoadMore();
}
private shouldFallbackToBottomForMissingAnchor(): boolean {
// While catching up to a stream, history can temporarily omit the in-flight
// assistant message that a previous scroll save anchored to. Keep retrying
// until the final refreshed transcript has a chance to render that anchor.
return !this.hasMore && !this.isReceivingPartialStream;
}
private updatePinnedToBottomAfterRestore(status: Exclude<ChatScrollRestoreResult["status"], "missing">): void {
if (status === "bottom") this.pinnedToBottom = true;
else if (status === "restored") this.pinnedToBottom = this.isNearBottom();
}
private syncScrollMetrics(): void {
const chat = this.chat;
if (chat === undefined) return;
this.lastScrollTop = chat.scrollTop;
this.lastClientHeight = chat.clientHeight;
}
private cancelPrependRestore(): void {
this.prependRestoreToken += 1;
this.suppressLoadMoreRequests = false;
}
capturePrependScrollAnchor(): PrependScrollAnchor | undefined {
const chat = this.chat;
if (!chat) return undefined;
return capturePrependScrollAnchor(chat, this.scrollMarkers());
}
restorePrependScrollAnchor(anchor: PrependScrollAnchor | undefined): void {
if (!this.chat || !anchor) return;
this.suppressLoadMoreRequests = true;
this.suppressScrollSave = true;
const token = this.prependRestoreToken + 1;
this.prependRestoreToken = token;
let frames = 0;
const settle = () => {
const chat = this.chat;
if (!chat || token !== this.prependRestoreToken) return;
restorePrependScrollAnchor(chat, anchor, anchor.markerId === undefined ? undefined : this.scrollMarkerAt(anchor.markerId));
this.lastScrollTop = chat.scrollTop;
frames += 1;
// Formatted markdown/code layout can settle after Lit's first render. Re-apply
// the marker anchor briefly so late height changes above the viewport do not
// move the user's reading position.
if (frames < PREPEND_RESTORE_SETTLE_FRAMES) {
requestAnimationFrame(settle);
return;
}
requestAnimationFrame(() => {
if (token !== this.prependRestoreToken) return;
this.suppressScrollSave = false;
this.suppressLoadMoreRequests = false;
});
};
settle();
}
saveScrollPosition(sessionId = this.sessionId) {
if (!sessionId) return;
this.scrollController.savePosition(sessionId, this.chat, this.scrollAnchorElements());
}
private scheduleScrollPositionSave() {
const sessionId = this.sessionId;
this.scrollController.scheduleSave(sessionId, (scheduledSessionId) => {
if (this.sessionId === scheduledSessionId) this.saveScrollPosition(scheduledSessionId);
});
}
private scheduleConversationRailUpdate(): void {
if (this.conversationRailFrame !== undefined) return;
this.conversationRailFrame = requestAnimationFrame(() => {
this.conversationRailFrame = undefined;
this.updateConversationRailPosition();
});
}
private updateConversationRailPosition(): void {
if (!this.messages.length || this.messageTotal <= 0) {
this.currentConversationIndex = undefined;
return;
}
const total = this.conversationDisplayTotal();
const article = this.firstVisibleArticle();
const index = Number(article?.dataset["index"]);
if (Number.isFinite(index)) {
this.currentConversationIndex = clampNumber(index, 0, Math.max(0, total - 1));
return;
}
this.currentConversationIndex = clampNumber(this.pinnedToBottom ? this.messageStart + this.messages.length - 1 : this.messageStart, 0, Math.max(0, total - 1));
}
private scrollMarkers(): HTMLElement[] {
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>(".scroll-marker"));
}
private scrollMarkerAt(markerId: string): HTMLElement | undefined {
return this.scrollMarkers().find((marker) => marker.dataset["markerId"] === markerId);
}
private firstVisibleArticle(): HTMLElement | undefined {
const chat = this.chat;
if (chat === undefined) return undefined;
const primaryArticles = Array.from(this.renderRoot.querySelectorAll<HTMLElement>("article.msg"));
return findFirstVisibleArticle(chat, primaryArticles) ?? findFirstVisibleArticle(chat, this.articles());
}
private articles(): HTMLElement[] {
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>("article.msg, details.msg"));
}
private scrollAnchorElements(): HTMLElement[] {
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>("[data-scroll-anchor-id]"));
}
private withSuppressedScrollSave(callback: () => void) {
this.suppressScrollSave = true;
callback();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.suppressScrollSave = false;
});
});
}
private groupDisclosureKey(startIndex: number, endIndex: number, defaultOpen: boolean): string {
return defaultOpen ? `${this.sessionId}:live:${String(startIndex)}` : `${this.sessionId}:${String(endIndex)}`;
}
private messageAnchorKey(index: number): string {
return `m:${String(index)}`;
}
private groupRenderKey(startIndex: number): string {
return `g:${String(startIndex)}`;
}
private groupAnchorKey(startIndex: number): string {
return `g:${String(startIndex)}`;
}
private eventAnchorKey(index: number): string {
return `e:${String(index)}`;
}
private messageScrollMarkerId(index: number): string {
return `m:${String(index)}`;
}
private groupScrollMarkerId(endIndex: number): string {
return `g:${String(endIndex)}`;
}
static override styles = chatStyles;
}