perf: virtualize chat transcript rendering

This commit is contained in:
Shakker
2026-07-15 02:27:06 +01:00
committed by Shakker
parent aa9997b756
commit 59acd601f2
7 changed files with 539 additions and 210 deletions
+18
View File
@@ -2218,6 +2218,9 @@ importers:
'@openclaw/workboard-contract':
specifier: workspace:*
version: link:../packages/workboard-contract
'@tanstack/lit-virtual':
specifier: 3.13.32
version: 3.13.32(lit@3.3.3)
dompurify:
specifier: 3.4.11
version: 3.4.11
@@ -4587,6 +4590,14 @@ packages:
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@tanstack/lit-virtual@3.13.32':
resolution: {integrity: sha512-VhYOnKhMjG8Pl/oigcoV6PqQuxdvh5KH5q0dHJucQnk12jplJxRJR9tulRc+GPrYHnz1qXGdyglpmSvcYksqNQ==}
peerDependencies:
lit: ^3.1.0
'@tanstack/virtual-core@3.17.3':
resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==}
'@tencent-connect/qqbot-connector@1.1.0':
resolution: {integrity: sha512-3nQ2mdyzPRKpBHjd3QiKZDwNzw1F7fBN+rSq8Xms2gg+JWZR4SY2Zdf+doqTyXdyVjG4Y0QM7IA4U42zT9xxzw==}
engines: {node: '>=18.0.0'}
@@ -10813,6 +10824,13 @@ snapshots:
dependencies:
tslib: 2.8.1
'@tanstack/lit-virtual@3.13.32(lit@3.3.3)':
dependencies:
'@tanstack/virtual-core': 3.17.3
lit: 3.3.3
'@tanstack/virtual-core@3.17.3': {}
'@tencent-connect/qqbot-connector@1.1.0':
dependencies:
qrcode-terminal: 0.12.0
+1
View File
@@ -27,6 +27,7 @@
"@openclaw/normalization-core": "workspace:*",
"@openclaw/workboard-contract": "workspace:*",
"@openclaw/uirouter": "0.1.0",
"@tanstack/lit-virtual": "3.13.32",
"dompurify": "3.4.11",
"ghostty-web": "0.4.0",
"highlight.js": "11.11.1",
+3
View File
@@ -128,6 +128,7 @@ import {
type DetailFullMessageResult,
type SidebarFullMessageRequest,
} from "./components/chat-sidebar.ts";
import { ChatTranscriptController } from "./components/chat-thread.ts";
import {
CHAT_COMPOSER_DRAFT_STORAGE_ERROR,
loadChatComposerSnapshot,
@@ -281,6 +282,7 @@ class ChatPane extends OpenClawLightDomElement {
@property({ attribute: false }) onClosePane?: (paneId: string) => void;
private readonly chatState = new ChatStateController<ChatPageHost>(this);
private readonly transcript = new ChatTranscriptController(this);
private state: ChatPageHost | undefined;
/* Infinity until the first ResizeObserver tick so an unmeasured pane keeps
* the wide side-by-side layout instead of flashing the stacked one. */
@@ -1963,6 +1965,7 @@ class ChatPane extends OpenClawLightDomElement {
const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0);
const detailSplitWidth = this.paneWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH;
const props: ChatProps = {
transcript: this.transcript,
paneId: this.paneId,
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
+54 -12
View File
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { expectDefined } from "@openclaw/normalization-core";
import { html, render } from "lit";
import { html, render, type ReactiveControllerHost } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type {
@@ -34,6 +34,7 @@ import {
type ChatModelControlsProps,
} from "./components/chat-model-controls.ts";
import {
ChatTranscriptController,
isChatThreadSearchOpen,
resetChatThreadPresentationState,
toggleChatThreadSearch,
@@ -98,17 +99,36 @@ const buildChatItemsMock = vi.hoisted(() =>
}
const items: unknown[] = [];
if (props.messages.length > 0) {
items.push({
kind: "group",
key: "group:assistant:test",
role: "assistant",
messages: props.messages.map((message, index) => ({
key: `message:${index}`,
message,
})),
timestamp: 1,
isStreaming: false,
});
const virtualRows = props.messages.every(
(message) =>
typeof message === "object" &&
message !== null &&
(message as { __testVirtualRow?: unknown }).__testVirtualRow === true,
);
if (virtualRows) {
items.push(
...props.messages.map((message, index) => ({
kind: "group",
key: `group:${index}`,
role: index % 2 === 0 ? "user" : "assistant",
messages: [{ key: `message:${index}`, message }],
timestamp: index + 1,
isStreaming: false,
})),
);
} else {
items.push({
kind: "group",
key: "group:assistant:test",
role: "assistant",
messages: props.messages.map((message, index) => ({
key: `message:${index}`,
message,
})),
timestamp: 1,
isStreaming: false,
});
}
}
// Mirrors buildChatItems: streamed text renders as a stream item; an
// empty stream or a working run with no stream shows the reading
@@ -562,7 +582,14 @@ function itemAt<T>(items: ArrayLike<T>, index: number, label: string): T {
function createChatProps(
overrides: Partial<Parameters<typeof renderChat>[0]> = {},
): Parameters<typeof renderChat>[0] {
const transcript = new ChatTranscriptController({
addController: () => undefined,
removeController: () => undefined,
requestUpdate: () => undefined,
updateComplete: Promise.resolve(true),
} satisfies ReactiveControllerHost);
return {
transcript,
paneId: "single",
sessionKey: "main",
onSessionKeyChange: () => undefined,
@@ -1000,6 +1027,21 @@ describe("chat transcript rendering", () => {
expect(onChatScroll).toHaveBeenCalledOnce();
expect(onRequestUpdate).not.toHaveBeenCalled();
});
it("mounts a bounded end-anchored range for long transcripts", () => {
const messages = Array.from({ length: 500 }, (_, index) => ({
__testVirtualRow: true,
content: `message ${index}`,
}));
const container = renderChatView({ messages });
const rows = [...container.querySelectorAll(".chat-virtual-row")];
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThan(40);
expect(container.textContent).toContain("message 499");
expect(container.textContent).not.toContain("message 0");
});
});
describe("chat goal status", () => {
+57 -52
View File
@@ -48,6 +48,7 @@ import type {
} from "./components/chat-sidebar.ts";
import { renderChatTaskSuggestions } from "./components/chat-task-suggestions.ts";
import {
type ChatTranscriptController,
isChatThreadSearchOpen,
renderChatPinnedMessages,
renderChatSearchBar,
@@ -68,6 +69,7 @@ function isFileDrag(dataTransfer: DataTransfer | null): boolean {
}
export type ChatProps = {
transcript: ChatTranscriptController;
paneId: string;
sessionKey: string;
onSessionKeyChange: (next: string) => void;
@@ -258,58 +260,61 @@ export function renderChat(props: ChatProps) {
}
};
const thread = renderChatThread({
paneId: props.paneId,
sessionKey: props.sessionKey,
loading: props.loading,
historyPagination: props.historyPagination,
messages: props.messages,
toolMessages: props.toolMessages,
streamSegments: props.streamSegments,
stream: props.stream,
streamStartedAt: props.streamStartedAt,
queue: props.queue,
showThinking: props.showThinking,
showToolCalls: props.showToolCalls,
runActive: Boolean(props.canAbort),
runWorking: isChatRunWorking(props),
sessions: props.sessions,
sessionHost: props.sessionHost,
assistantName: props.assistantName,
assistantAvatar: props.assistantAvatar,
assistantAvatarUrl: props.assistantAvatarUrl,
userName: props.userName,
userAvatar: props.userAvatar,
basePath: props.basePath,
fullMessageAgentId: props.fullMessageAgentId,
localMediaPreviewRoots: props.localMediaPreviewRoots,
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken,
canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl,
embedSandboxMode: props.embedSandboxMode,
allowExternalEmbedUrls: props.allowExternalEmbedUrls,
autoExpandToolCalls: props.autoExpandToolCalls,
realtimeTalkConversation: props.realtimeTalkConversation,
onOpenSidebar: props.onOpenSidebar,
onOpenWorkspaceFile: props.onOpenWorkspaceFile,
onOpenSessionCheckpoints: props.onOpenSessionCheckpoints,
onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded,
onRequestUpdate: requestUpdate,
onChatScroll: props.onChatScroll,
onHistoryIntent: props.onHistoryIntent,
onDraftChange: props.onDraftChange,
getDraft: props.getDraft,
onSend: props.onSend,
onSetReply: props.onSetReply,
// Archived/non-composable sessions must not offer selection actions:
// withholding the callback keeps the popup from rendering at all.
onSideQuestion: props.canSend ? props.onSideQuestion : undefined,
onOpenSession: props.onSessionSelect,
backgroundTasks: props.backgroundTasks,
onFocusComposer: () =>
chatSection
?.querySelector<HTMLTextAreaElement>(".agent-chat__composer-combobox > textarea")
?.focus({ preventScroll: true }),
});
const thread = renderChatThread(
{
paneId: props.paneId,
sessionKey: props.sessionKey,
loading: props.loading,
historyPagination: props.historyPagination,
messages: props.messages,
toolMessages: props.toolMessages,
streamSegments: props.streamSegments,
stream: props.stream,
streamStartedAt: props.streamStartedAt,
queue: props.queue,
showThinking: props.showThinking,
showToolCalls: props.showToolCalls,
runActive: Boolean(props.canAbort),
runWorking: isChatRunWorking(props),
sessions: props.sessions,
sessionHost: props.sessionHost,
assistantName: props.assistantName,
assistantAvatar: props.assistantAvatar,
assistantAvatarUrl: props.assistantAvatarUrl,
userName: props.userName,
userAvatar: props.userAvatar,
basePath: props.basePath,
fullMessageAgentId: props.fullMessageAgentId,
localMediaPreviewRoots: props.localMediaPreviewRoots,
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken,
canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl,
embedSandboxMode: props.embedSandboxMode,
allowExternalEmbedUrls: props.allowExternalEmbedUrls,
autoExpandToolCalls: props.autoExpandToolCalls,
realtimeTalkConversation: props.realtimeTalkConversation,
onOpenSidebar: props.onOpenSidebar,
onOpenWorkspaceFile: props.onOpenWorkspaceFile,
onOpenSessionCheckpoints: props.onOpenSessionCheckpoints,
onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded,
onRequestUpdate: requestUpdate,
onChatScroll: props.onChatScroll,
onHistoryIntent: props.onHistoryIntent,
onDraftChange: props.onDraftChange,
getDraft: props.getDraft,
onSend: props.onSend,
onSetReply: props.onSetReply,
// Archived/non-composable sessions must not offer selection actions:
// withholding the callback keeps the popup from rendering at all.
onSideQuestion: props.canSend ? props.onSideQuestion : undefined,
onOpenSession: props.onSessionSelect,
backgroundTasks: props.backgroundTasks,
onFocusComposer: () =>
chatSection
?.querySelector<HTMLTextAreaElement>(".agent-chat__composer-combobox > textarea")
?.focus({ preventScroll: true }),
},
props.transcript,
);
const chatColumnFooter = renderChatComposer({
paneId: props.paneId,
+384 -146
View File
@@ -1,8 +1,17 @@
// Chat-owned message thread presentation and thread-local interaction state.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { html, nothing, type TemplateResult } from "lit";
import { VirtualizerController } from "@tanstack/lit-virtual";
import {
html,
nothing,
type ReactiveController,
type ReactiveControllerHost,
type TemplateResult,
} from "lit";
import { guard } from "lit/directives/guard.js";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { styleMap } from "lit/directives/style-map.js";
import { classifySessionKind } from "../../../../../src/sessions/classify-session-kind.js";
import type { SessionsListResult } from "../../../api/types.ts";
import { beginNativeWindowDragFromTopInset } from "../../../app/native-window-drag.ts";
@@ -140,6 +149,204 @@ type ChatPinnedMessagesProps = Pick<
"paneId" | "sessionKey" | "messages" | "userName" | "userAvatar"
>;
type ChatRenderItem = ReturnType<typeof collapseCompletedTurnWork>[number];
type ChatTranscriptRow =
| { kind: "item"; key: string; item: ChatRenderItem }
| { kind: "content"; key: string; content: unknown };
const CHAT_TRANSCRIPT_ESTIMATED_ROW_PX = 120;
const CHAT_TRANSCRIPT_OVERSCAN = 6;
const CHAT_TRANSCRIPT_END_THRESHOLD_PX = 8;
function initialTranscriptRect(host: ReactiveControllerHost) {
const width = host instanceof HTMLElement ? host.clientWidth : 0;
const height = host instanceof HTMLElement ? host.clientHeight : 0;
return {
width: width || (typeof window === "undefined" ? 0 : window.innerWidth),
height: height || (typeof window === "undefined" ? 0 : window.innerHeight),
};
}
class ChatSessionVirtualizerHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>();
private readonly virtualizerController: VirtualizerController<HTMLDivElement, HTMLElement>;
private scrollElement: HTMLDivElement | null = null;
private rowKeys: readonly string[] = [];
constructor(private readonly host: ReactiveControllerHost) {
this.virtualizerController = new VirtualizerController(this, {
count: 0,
getScrollElement: () => this.scrollElement,
estimateSize: () => CHAT_TRANSCRIPT_ESTIMATED_ROW_PX,
getItemKey: () => "",
initialRect: initialTranscriptRect(host),
initialOffset: Number.MAX_SAFE_INTEGER,
anchorTo: "end",
followOnAppend: false,
scrollEndThreshold: CHAT_TRANSCRIPT_END_THRESHOLD_PX,
overscan: CHAT_TRANSCRIPT_OVERSCAN,
});
}
get updateComplete() {
return this.host.updateComplete;
}
requestUpdate = () => {
this.host.requestUpdate();
};
addController(controller: ReactiveController): void {
this.controllers.add(controller);
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller);
}
connect(): void {
for (const controller of this.controllers) {
controller.hostConnected?.();
}
}
update(): void {
for (const controller of this.controllers) {
controller.hostUpdated?.();
}
}
disconnect(): void {
for (const controller of this.controllers) {
controller.hostDisconnected?.();
}
this.scrollElement = null;
}
render(
rows: readonly ChatTranscriptRow[],
renderRow: (row: ChatTranscriptRow) => unknown,
): TemplateResult {
this.syncRows(rows);
const virtualizer = this.virtualizerController.getVirtualizer();
const virtualRows = virtualizer.getVirtualItems();
const firstRow = virtualRows[0];
return html`
<div
class="chat-thread-inner chat-thread-inner--virtual"
${ref((element) => {
this.scrollElement =
element?.parentElement instanceof HTMLDivElement ? element.parentElement : null;
})}
>
<div
class="chat-virtual-sizer"
style=${styleMap({ height: `${virtualizer.getTotalSize()}px` })}
>
<div
class="chat-virtual-range"
style=${styleMap({
transform: `translateY(${firstRow?.start ?? 0}px)`,
})}
>
${repeat(
virtualRows,
(virtualRow) => virtualRow.key,
(virtualRow) => {
const row = rows[virtualRow.index];
if (!row) {
return nothing;
}
return html`
<div
class="chat-virtual-row ${virtualRow.index === 0
? "chat-virtual-row--first"
: ""}"
data-index=${String(virtualRow.index)}
data-virtual-row-key=${row.key}
${ref((element) =>
virtualizer.measureElement(element instanceof HTMLElement ? element : null),
)}
>
${renderRow(row)}
</div>
`;
},
)}
</div>
</div>
</div>
`;
}
scrollToEnd(options: { behavior?: ScrollBehavior } = {}): void {
this.virtualizerController.getVirtualizer().scrollToEnd(options);
}
private syncRows(rows: readonly ChatTranscriptRow[]): void {
const nextKeys = rows.map((row) => row.key);
if (
nextKeys.length === this.rowKeys.length &&
nextKeys.every((key, index) => key === this.rowKeys[index])
) {
return;
}
this.rowKeys = Object.freeze(nextKeys);
const keys = this.rowKeys;
const virtualizer = this.virtualizerController.getVirtualizer();
virtualizer.setOptions({
...virtualizer.options,
count: keys.length,
getItemKey: (index) => keys[index] ?? `missing:${index}`,
});
}
}
export class ChatTranscriptController implements ReactiveController {
private sessionKey: string | null = null;
private sessionVirtualizer: ChatSessionVirtualizerHost | null = null;
private connected = false;
constructor(private readonly host: ReactiveControllerHost) {
host.addController(this);
}
render(props: ChatThreadProps): TemplateResult {
if (
!this.sessionVirtualizer ||
this.sessionKey === null ||
!areUiSessionKeysEquivalent(this.sessionKey, props.sessionKey)
) {
this.sessionVirtualizer?.disconnect();
this.sessionKey = props.sessionKey;
this.sessionVirtualizer = new ChatSessionVirtualizerHost(this.host);
if (this.connected) {
this.sessionVirtualizer.connect();
}
}
return renderChatThreadContents(props, this.sessionVirtualizer);
}
scrollToEnd(options: { behavior?: ScrollBehavior } = {}): void {
this.sessionVirtualizer?.scrollToEnd(options);
}
hostConnected(): void {
this.connected = true;
this.sessionVirtualizer?.connect();
}
hostUpdated(): void {
this.sessionVirtualizer?.update();
}
hostDisconnected(): void {
this.connected = false;
this.sessionVirtualizer?.disconnect();
}
}
function createChatThreadState(): ChatThreadState {
return {
searchOpen: false,
@@ -530,9 +737,22 @@ function renderLoadingSkeleton() {
`;
}
function chatRenderItemGuardDependencies(
item: ReturnType<typeof collapseCompletedTurnWork>[number],
): readonly unknown[] {
function renderHistorySentinel(loading: boolean) {
return html`
<div class="chat-history-sentinel">
${loading
? html`
<div class="chat-history-loading" role="status">
<span class="session-run-spinner" aria-hidden="true"></span>
<span>${t("common.loading")}</span>
</div>
`
: nothing}
</div>
`;
}
function chatRenderItemGuardDependencies(item: ChatRenderItem): readonly unknown[] {
if (item.kind === "stream-run") {
return [item.key, ...item.parts];
}
@@ -561,17 +781,24 @@ function trackTranscriptRenderDependencies(
return dependencies;
}
function guardChatRenderItems(
state: ChatThreadState,
render: (item: ReturnType<typeof collapseCompletedTurnWork>[number]) => unknown,
) {
return (item: ReturnType<typeof collapseCompletedTurnWork>[number]) =>
function guardChatRenderItems(state: ChatThreadState, render: (item: ChatRenderItem) => unknown) {
return (item: ChatRenderItem) =>
guard([...chatRenderItemGuardDependencies(item), state.transcriptRenderContext], () =>
render(item),
);
}
export function renderChatThread(props: ChatThreadProps) {
export function renderChatThread(
props: ChatThreadProps,
transcript: ChatTranscriptController,
): TemplateResult {
return transcript.render(props);
}
function renderChatThreadContents(
props: ChatThreadProps,
transcript: ChatSessionVirtualizerHost,
): TemplateResult {
const state = getChatThreadState(props.paneId);
const requestUpdate = props.onRequestUpdate ?? (() => {});
const displayStream = props.stream ?? null;
@@ -646,6 +873,137 @@ export function renderChatThread(props: ChatThreadProps) {
const showLoadingSkeleton = props.loading && chatItems.length === 0;
const threadContextWindow =
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null;
const renderGroupItem = (item: MessageGroup) => {
if (deleted.has(item.key)) {
return nothing;
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
onOpenWorkspaceFile: props.onOpenWorkspaceFile,
sessionKey: props.sessionKey,
agentId: props.fullMessageAgentId,
showReasoning,
showToolCalls: props.showToolCalls,
runActive: props.runActive,
autoExpandToolCalls: Boolean(props.autoExpandToolCalls),
isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId),
onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => {
expandedToolCards.set(messageId, !(expanded ?? expandedToolCards.get(messageId) ?? false));
requestUpdate();
},
isToolExpanded: (toolCardId: string) => expandedToolCards.get(toolCardId) ?? false,
onToggleToolExpanded: toggleToolCardExpanded,
onRequestUpdate: requestUpdate,
onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
userName: props.userName ?? null,
userAvatar: props.userAvatar ?? null,
basePath: props.basePath,
localMediaPreviewRoots: props.localMediaPreviewRoots ?? [],
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null,
canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
contextWindow: threadContextWindow,
onDelete: () => {
deleted.delete(item.key);
requestUpdate();
},
});
};
const renderItem = guardChatRenderItems(state, (item) => {
if (item.kind === "divider") {
return renderChatDivider(item, props.onOpenSessionCheckpoints);
}
if (item.kind === "stream-run") {
return renderStreamGroup(item.parts, {
onOpenSidebar: props.onOpenSidebar,
assistant: assistantIdentity,
basePath: props.basePath,
authToken: props.assistantAttachmentAuthToken ?? null,
});
}
if (item.kind === "work-group") {
const workExpanded = expandedToolCards.get(item.key) ?? item.hasError;
return html`
${renderWorkGroupSummary(item, {
expanded: workExpanded,
onToggle: () => {
expandedToolCards.set(item.key, !workExpanded);
requestUpdate();
},
})}
${workExpanded ? item.groups.map((group) => renderGroupItem(group)) : nothing}
`;
}
if (item.kind === "group") {
return renderGroupItem(item);
}
return nothing;
});
const collapsedItems = collapseCompletedTurnWork(coalesceStreamRuns(chatItems), {
runWorking: Boolean(props.runWorking),
searchActive: state.searchOpen && Boolean(state.searchQuery.trim()),
});
const transcriptRows: ChatTranscriptRow[] = [];
if (props.historyPagination) {
transcriptRows.push({
kind: "content",
key: "history",
content: renderHistorySentinel(props.historyPagination.loading),
});
}
for (const item of collapsedItems) {
transcriptRows.push({ kind: "item", key: item.key, item });
}
const realtimeConversation = renderRealtimeTalkConversation(props);
if (realtimeConversation !== nothing) {
transcriptRows.push({
kind: "content",
key: "realtime-talk",
content: realtimeConversation,
});
}
const backgroundTasks =
!props.runWorking && !isEmpty && !showLoadingSkeleton
? renderBackgroundTasksStatusRow(props.backgroundTasks)
: nothing;
if (backgroundTasks !== nothing) {
transcriptRows.push({
kind: "content",
key: "background-tasks",
content: backgroundTasks,
});
}
trackTranscriptRenderDependencies(state, [
chatItems,
locale,
deletedChatItemsSignature(deleted, chatItems),
stableBooleanMapSignature(expandedToolCards),
getAssistantAttachmentAvailabilityRenderVersion(),
// The host minute poll requests an update; this key crosses row guard() memoization.
Math.floor(Date.now() / 60_000),
getToolTitlesVersion(),
props.sessionKey,
props.fullMessageAgentId,
showReasoning,
props.showToolCalls,
Boolean(props.runActive),
Boolean(props.runWorking),
Boolean(props.autoExpandToolCalls),
props.assistantName,
assistantIdentity.avatar,
props.userName,
props.userAvatar,
props.basePath,
(props.localMediaPreviewRoots ?? []).join("\u0000"),
props.assistantAttachmentAuthToken,
props.canvasPluginSurfaceUrl,
props.embedSandboxMode ?? "scripts",
props.allowExternalEmbedUrls ?? false,
threadContextWindow,
]);
return html`
<div
class="chat-thread ${isDirectThread ? "chat-thread--direct" : ""}"
@@ -674,142 +1032,22 @@ export function renderChatThread(props: ChatThreadProps) {
@contextmenu=${(event: MouseEvent) => handleChatContextMenu(event, props)}
@pointerup=${(event: PointerEvent) => handleChatThreadSelectionPointerUp(event, props)}
>
<div class="chat-thread-inner">
${props.historyPagination
? html`
<div class="chat-history-sentinel">
${props.historyPagination.loading
? html`
<div class="chat-history-loading" role="status">
<span class="session-run-spinner" aria-hidden="true"></span>
<span>${t("common.loading")}</span>
</div>
`
: nothing}
</div>
`
: nothing}
${showLoadingSkeleton ? renderLoadingSkeleton() : nothing}
${isEmpty && !state.searchOpen ? renderWelcomeState(props) : nothing}
${isEmpty && state.searchOpen
? html` <div class="agent-chat__empty">${t("chat.thread.noMatches")}</div> `
: nothing}
${guard(
trackTranscriptRenderDependencies(state, [
chatItems,
locale,
deletedChatItemsSignature(deleted, chatItems),
stableBooleanMapSignature(expandedToolCards),
getAssistantAttachmentAvailabilityRenderVersion(),
// The host minute poll requests an update; this key crosses guard() memoization.
Math.floor(Date.now() / 60_000),
getToolTitlesVersion(),
props.sessionKey,
props.fullMessageAgentId,
showReasoning,
props.showToolCalls,
Boolean(props.runActive),
Boolean(props.runWorking),
Boolean(props.autoExpandToolCalls),
props.assistantName,
assistantIdentity.avatar,
props.userName,
props.userAvatar,
props.basePath,
(props.localMediaPreviewRoots ?? []).join("\u0000"),
props.assistantAttachmentAuthToken,
props.canvasPluginSurfaceUrl,
props.embedSandboxMode ?? "scripts",
props.allowExternalEmbedUrls ?? false,
threadContextWindow,
]),
() => {
const renderGroupItem = (item: MessageGroup) => {
if (deleted.has(item.key)) {
return nothing;
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
onOpenWorkspaceFile: props.onOpenWorkspaceFile,
sessionKey: props.sessionKey,
agentId: props.fullMessageAgentId,
showReasoning,
showToolCalls: props.showToolCalls,
runActive: props.runActive,
autoExpandToolCalls: Boolean(props.autoExpandToolCalls),
isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId),
onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => {
expandedToolCards.set(
messageId,
!(expanded ?? expandedToolCards.get(messageId) ?? false),
);
requestUpdate();
},
isToolExpanded: (toolCardId: string) => expandedToolCards.get(toolCardId) ?? false,
onToggleToolExpanded: toggleToolCardExpanded,
onRequestUpdate: requestUpdate,
onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
userName: props.userName ?? null,
userAvatar: props.userAvatar ?? null,
basePath: props.basePath,
localMediaPreviewRoots: props.localMediaPreviewRoots ?? [],
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null,
canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
contextWindow: threadContextWindow,
onDelete: () => {
deleted.delete(item.key);
requestUpdate();
},
});
};
return repeat(
collapseCompletedTurnWork(coalesceStreamRuns(chatItems), {
runWorking: Boolean(props.runWorking),
searchActive: state.searchOpen && Boolean(state.searchQuery.trim()),
}),
(item) => item.key,
guardChatRenderItems(state, (item) => {
if (item.kind === "divider") {
return renderChatDivider(item, props.onOpenSessionCheckpoints);
}
if (item.kind === "stream-run") {
return renderStreamGroup(item.parts, {
onOpenSidebar: props.onOpenSidebar,
assistant: assistantIdentity,
basePath: props.basePath,
authToken: props.assistantAttachmentAuthToken ?? null,
});
}
if (item.kind === "work-group") {
const workExpanded = expandedToolCards.get(item.key) ?? item.hasError;
return html`
${renderWorkGroupSummary(item, {
expanded: workExpanded,
onToggle: () => {
expandedToolCards.set(item.key, !workExpanded);
requestUpdate();
},
})}
${workExpanded ? item.groups.map((group) => renderGroupItem(group)) : nothing}
`;
}
if (item.kind === "group") {
return renderGroupItem(item);
}
return nothing;
}),
);
},
)}
${renderRealtimeTalkConversation(props)}
${!props.runWorking && !isEmpty && !showLoadingSkeleton
? renderBackgroundTasksStatusRow(props.backgroundTasks)
: nothing}
</div>
${showLoadingSkeleton || isEmpty
? html`
<div class="chat-thread-inner">
${props.historyPagination
? renderHistorySentinel(props.historyPagination.loading)
: nothing}
${showLoadingSkeleton ? renderLoadingSkeleton() : nothing}
${isEmpty && !state.searchOpen ? renderWelcomeState(props) : nothing}
${isEmpty && state.searchOpen
? html` <div class="agent-chat__empty">${t("chat.thread.noMatches")}</div> `
: nothing}
</div>
`
: transcript.render(transcriptRows, (row) =>
row.kind === "item" ? renderItem(row.item) : row.content,
)}
</div>
`;
}
+22
View File
@@ -109,6 +109,7 @@ openclaw-chat-page {
/* Grow, shrink, and use 0 base for proper scrolling */
overflow-y: auto;
overflow-x: hidden;
overflow-anchor: none;
/* The transcript is document content, unlike the surrounding app chrome. */
user-select: text;
scrollbar-gutter: stable both-edges;
@@ -159,6 +160,27 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
margin-inline: auto;
}
.chat-virtual-sizer {
position: relative;
width: 100%;
}
.chat-virtual-range {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.chat-virtual-row {
display: flow-root;
width: 100%;
}
.chat-virtual-row--first > :first-child {
margin-top: 0 !important;
}
.chat-history-sentinel {
display: flex;
min-height: 28px;