mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
perf: virtualize chat transcript rendering
This commit is contained in:
Generated
+18
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user