fix: stabilize virtualized chat rows

This commit is contained in:
Shakker
2026-07-15 00:18:45 +01:00
committed by Shakker
parent 59acd601f2
commit d1c8bd48af
7 changed files with 421 additions and 94 deletions

21
pnpm-lock.yaml generated
View File

@@ -2219,8 +2219,11 @@ importers:
specifier: workspace:*
version: link:../packages/workboard-contract
'@tanstack/lit-virtual':
specifier: 3.13.32
version: 3.13.32(lit@3.3.3)
specifier: 3.13.33
version: 3.13.33(lit@3.3.3)
'@tanstack/virtual-core':
specifier: 3.17.4
version: 3.17.4
dompurify:
specifier: 3.4.11
version: 3.4.11
@@ -4590,13 +4593,13 @@ 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==}
'@tanstack/lit-virtual@3.13.33':
resolution: {integrity: sha512-dZWtvNYZjRIIHmxB4V3DEmjsUkXu2qtLVZETloozRhHK+9SNxlln2tjkreTouVPO6zmwfeCZVfXBaEPBPSZi8g==}
peerDependencies:
lit: ^3.1.0
'@tanstack/virtual-core@3.17.3':
resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==}
'@tanstack/virtual-core@3.17.4':
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
'@tencent-connect/qqbot-connector@1.1.0':
resolution: {integrity: sha512-3nQ2mdyzPRKpBHjd3QiKZDwNzw1F7fBN+rSq8Xms2gg+JWZR4SY2Zdf+doqTyXdyVjG4Y0QM7IA4U42zT9xxzw==}
@@ -10824,12 +10827,12 @@ snapshots:
dependencies:
tslib: 2.8.1
'@tanstack/lit-virtual@3.13.32(lit@3.3.3)':
'@tanstack/lit-virtual@3.13.33(lit@3.3.3)':
dependencies:
'@tanstack/virtual-core': 3.17.3
'@tanstack/virtual-core': 3.17.4
lit: 3.3.3
'@tanstack/virtual-core@3.17.3': {}
'@tanstack/virtual-core@3.17.4': {}
'@tencent-connect/qqbot-connector@1.1.0':
dependencies:

View File

@@ -27,7 +27,8 @@
"@openclaw/normalization-core": "workspace:*",
"@openclaw/workboard-contract": "workspace:*",
"@openclaw/uirouter": "0.1.0",
"@tanstack/lit-virtual": "3.13.32",
"@tanstack/lit-virtual": "3.13.33",
"@tanstack/virtual-core": "3.17.4",
"dompurify": "3.4.11",
"ghostty-web": "0.4.0",
"highlight.js": "11.11.1",

View File

@@ -292,6 +292,72 @@ describe("collapseCompletedTurnWork", () => {
});
describe("buildCachedChatItems row identity", () => {
it("keeps a persistent message key across live-to-authoritative replacement", () => {
resetChatThreadState();
const initial = groupAt(
messageGroups({
messages: [
{
__openclaw: { id: "terminal-message" },
role: "assistant",
content: "Draft reply",
timestamp: 1,
},
],
}),
0,
);
const reconciled = groupAt(
messageGroups({
messages: [
{
__openclaw: { id: "terminal-message", seq: 42 },
role: "assistant",
content: "Final reply",
timestamp: 2,
},
],
}),
0,
);
expect(messageAt(reconciled, 0).key).toBe(messageAt(initial, 0).key);
});
it("keeps a persistent tool message key when its projected content changes", () => {
resetChatThreadState();
const initial = groupAt(
messageGroups({
messages: [
{
__openclaw: { id: "tool-message" },
role: "assistant",
toolCallId: "call-1",
content: "Running",
timestamp: 1,
},
],
}),
0,
);
const reconciled = groupAt(
messageGroups({
messages: [
{
__openclaw: { id: "tool-message", seq: 43 },
role: "assistant",
toolCallId: "call-1",
content: "Finished",
timestamp: 2,
},
],
}),
0,
);
expect(messageAt(reconciled, 0).key).toBe(messageAt(initial, 0).key);
});
it("preserves a same-role group key as messages are prepended and appended", () => {
resetChatThreadState();
const first = {
@@ -345,13 +411,13 @@ describe("buildCachedChatItems row identity", () => {
resetChatThreadState();
const siblings = [
{
__openclaw: { id: "source-message", seq: 2 },
__openclaw: { seq: 2 },
role: "assistant",
content: "First projection",
timestamp: 2,
},
{
__openclaw: { id: "source-message", seq: 2 },
__openclaw: { seq: 2 },
role: "assistant",
content: "Second projection",
timestamp: 2,
@@ -367,7 +433,13 @@ describe("buildCachedChatItems row identity", () => {
content: "Earlier",
timestamp: 1,
},
...siblings,
{
__openclaw: { seq: 2 },
role: "assistant",
content: "Earlier projection from the same record",
timestamp: 2,
},
...siblings.map((message) => ({ ...message, __openclaw: { ...message.__openclaw } })),
],
}),
1,
@@ -375,6 +447,9 @@ describe("buildCachedChatItems row identity", () => {
expect(new Set(initial.messages.map((entry) => entry.key)).size).toBe(2);
expect(prepended.key).toBe(initial.key);
expect(prepended.messages.slice(1).map((entry) => entry.key)).toEqual(
initial.messages.map((entry) => entry.key),
);
});
});

View File

@@ -810,6 +810,69 @@ function sourceMessageId(message: unknown): string | null {
return id || null;
}
function transcriptMessageSourceKey(message: unknown): string | null {
const record = asRecord(message);
if (!record) {
return null;
}
const id = sourceMessageId(message);
if (id) {
return `id:${id}`;
}
const seq = asRecord(record["__openclaw"])?.seq;
const normalizedSeq =
typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : null;
return normalizedSeq == null ? null : `seq:${normalizedSeq}`;
}
const messageProjectionDigests = new WeakMap<object, string>();
function messageProjectionDigest(message: unknown): string {
if (message && typeof message === "object") {
const cached = messageProjectionDigests.get(message);
if (cached) {
return cached;
}
}
const record = asRecord(message);
const source = [
typeof record?.role === "string" ? record.role : "",
typeof record?.toolCallId === "string" ? record.toolCallId : "",
record ? extractTextCached(message) : "",
].join("\u0000");
let hash = 0x811c9dc5;
for (let index = 0; index < source.length; index += 1) {
hash ^= source.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
const digest = `p${(hash >>> 0).toString(36)}${source.length.toString(36)}`;
if (message && typeof message === "object") {
messageProjectionDigests.set(message, digest);
}
return digest;
}
function buildMessageKeys(messages: unknown[], indexOffset = 0): string[] {
const sourceKeys = messages.map(transcriptMessageSourceKey);
const sourceCounts = new Map<string, number>();
for (const sourceKey of sourceKeys) {
if (sourceKey) {
sourceCounts.set(sourceKey, (sourceCounts.get(sourceKey) ?? 0) + 1);
}
}
const projectionOccurrences = new Map<string, number>();
return messages.map((message, index) => {
const sourceKey = sourceKeys[index];
const needsProjectionIdentity = sourceKey == null || (sourceCounts.get(sourceKey) ?? 0) > 1;
const projectionKey = needsProjectionIdentity
? `${sourceKey ?? "legacy"}:projection:${messageProjectionDigest(message)}`
: (sourceKey ?? "legacy");
const occurrence = projectionOccurrences.get(projectionKey) ?? 0;
projectionOccurrences.set(projectionKey, occurrence + 1);
return messageKey(message, index + indexOffset, `${projectionKey}:${occurrence}`);
});
}
function collapseDuplicateSourceKey(message: unknown): string | null {
if (isPendingSendMessage(message)) {
return null;
@@ -1090,6 +1153,8 @@ function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGro
(message) => !isAssistantHeartbeatAckForDisplay(message),
);
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
const historyKeys = buildMessageKeys(history);
const toolKeys = buildMessageKeys(tools, history.length);
const liftedCanvasSources = tools.flatMap((message, index) => {
const source = extractChatMessagePreview(message);
return source ? [{ ...source, message, index }] : [];
@@ -1110,6 +1175,7 @@ function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGro
}
for (let i = 0; i < history.length; i++) {
const msg = history[i];
const itemKey = historyKeys[i] ?? messageKey(msg, i);
const normalized = safeNormalizeMessage(msg);
if (!normalized) {
continue;
@@ -1129,7 +1195,7 @@ function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGro
if (persistedCanvasSource && renderPersistedPreview) {
items.push({
kind: "message",
key: `${messageKey(msg, i)}:canvas`,
key: `${itemKey}:canvas`,
message: createCanvasAssistantMessage(
persistedCanvasSource,
persistedCanvasSource.timestamp ?? transcriptPositionTimestamp(history, i),
@@ -1151,7 +1217,7 @@ function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGro
items.push({
kind: "message",
key: messageKey(msg, i),
key: itemKey,
message: msg,
});
}
@@ -1214,10 +1280,10 @@ function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGro
: liftedCanvasSource.timestamp;
items.splice(insertionIndex, 0, {
kind: "message",
key: `${messageKey(
liftedCanvasSource.message,
liftedCanvasSource.index + history.length,
)}:canvas`,
key: `${
toolKeys[liftedCanvasSource.index] ??
messageKey(liftedCanvasSource.message, liftedCanvasSource.index + history.length)
}:canvas`,
message: createCanvasAssistantMessage(liftedCanvasSource, timestamp),
});
continue;
@@ -1245,7 +1311,7 @@ function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGro
const keyedSegments = segments.filter(streamSegmentHasItemId);
const indexedSegments = segments.filter((segment) => !streamSegmentHasItemId(segment));
const toolItems = tools.map((message, index) => ({
key: messageKey(message, index + history.length),
key: toolKeys[index] ?? messageKey(message, index + history.length),
message,
}));
const toolKeysByCallId = new Map<string, string>();
@@ -1832,11 +1898,14 @@ export function syncToolCardExpansionState(
lastAutoExpandPrefBySession.set(sessionKey, autoExpandToolCalls);
}
function messageKey(message: unknown, index: number): string {
function messageKey(message: unknown, index: number, transcriptKey?: string): string {
const m = asRecord(message) ?? {};
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
if (toolCallId) {
const role = typeof m.role === "string" ? m.role : "unknown";
if (transcriptKey) {
return `tool:${role}:${toolCallId}:${transcriptKey}`;
}
const id = typeof m.id === "string" ? m.id : "";
if (id) {
return `tool:${role}:${toolCallId}:${id}`;
@@ -1851,6 +1920,9 @@ function messageKey(message: unknown, index: number): string {
}
return `tool:${role}:${toolCallId}:${index}`;
}
if (transcriptKey) {
return `msg:${transcriptKey}`;
}
const id = typeof m.id === "string" ? m.id : "";
if (id) {
return `msg:${id}`;

View File

@@ -107,14 +107,21 @@ const buildChatItemsMock = vi.hoisted(() =>
);
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,
})),
...props.messages.map((message, index) => {
const testMessage = message as {
__testVirtualKey?: string;
__testVirtualRole?: string;
};
const key = testMessage.__testVirtualKey ?? String(index);
return {
kind: "group",
key: `group:${key}`,
role: testMessage.__testVirtualRole ?? (index % 2 === 0 ? "user" : "assistant"),
messages: [{ key: `message:${key}`, message }],
timestamp: index + 1,
isStreaming: false,
};
}),
);
} else {
items.push({
@@ -1041,6 +1048,60 @@ describe("chat transcript rendering", () => {
expect(rows.length).toBeLessThan(40);
expect(container.textContent).toContain("message 499");
expect(container.textContent).not.toContain("message 0");
expect(container.querySelector(".chat-thread")?.getAttribute("aria-live")).toBe("off");
expect(rows.every((row) => row.getAttribute("aria-live") === null)).toBe(true);
const announcement = requireElement(
container,
".chat-transcript-announcement",
"chat transcript announcement",
);
expect(announcement.getAttribute("role")).toBe("status");
expect(announcement.getAttribute("aria-live")).toBe("polite");
expect(announcement.getAttribute("aria-atomic")).toBe("true");
expect(announcement.textContent).toBe("");
});
it("announces only a genuinely appended assistant row", () => {
const transcript = new ChatTranscriptController({
addController: () => undefined,
removeController: () => undefined,
requestUpdate: () => undefined,
updateComplete: Promise.resolve(true),
} satisfies ReactiveControllerHost);
const container = document.createElement("div");
const message = (key: string, role: "user" | "assistant", content: string) => ({
__testVirtualRow: true,
__testVirtualKey: key,
__testVirtualRole: role,
content,
});
const renderMessages = (messages: unknown[]) =>
render(renderChat(createChatProps({ transcript, messages })), container);
const existing = [
message("user-1", "user", "Question"),
message("assistant-1", "assistant", "Existing answer"),
];
renderMessages(existing);
expect(container.querySelector(".chat-transcript-announcement")?.textContent).toBe("");
renderMessages([
message("older-user", "user", "Older question"),
message("older-assistant", "assistant", "Older answer"),
...existing,
]);
expect(container.querySelector(".chat-transcript-announcement")?.textContent).toBe("");
renderMessages([
message("older-user", "user", "Older question"),
message("older-assistant", "assistant", "Older answer"),
...existing,
message("user-2", "user", "New question"),
message("assistant-2", "assistant", "New answer"),
]);
expect(container.querySelector(".chat-transcript-announcement")?.textContent).toBe(
"New answer",
);
});
});

View File

@@ -1,6 +1,7 @@
// Chat-owned message thread presentation and thread-local interaction state.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { VirtualizerController } from "@tanstack/lit-virtual";
import { defaultRangeExtractor } from "@tanstack/virtual-core";
import {
html,
nothing,
@@ -155,9 +156,15 @@ type ChatTranscriptRow =
| { kind: "item"; key: string; item: ChatRenderItem }
| { kind: "content"; key: string; content: unknown };
type ChatTranscriptAnnouncement = {
key: string;
text: string;
};
const CHAT_TRANSCRIPT_ESTIMATED_ROW_PX = 120;
const CHAT_TRANSCRIPT_OVERSCAN = 6;
const CHAT_TRANSCRIPT_END_THRESHOLD_PX = 8;
const CHAT_TRANSCRIPT_ANNOUNCEMENT_MAX_CHARS = 500;
function initialTranscriptRect(host: ReactiveControllerHost) {
const width = host instanceof HTMLElement ? host.clientWidth : 0;
@@ -173,6 +180,11 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
private readonly virtualizerController: VirtualizerController<HTMLDivElement, HTMLElement>;
private scrollElement: HTMLDivElement | null = null;
private rowKeys: readonly string[] = [];
private rowIndexesByKey = new Map<string, number>();
private focusedRowKey: string | null = null;
private announcementInitialized = false;
private announcementKey: string | null = null;
private currentAnnouncementText = "";
constructor(private readonly host: ReactiveControllerHost) {
this.virtualizerController = new VirtualizerController(this, {
@@ -184,6 +196,20 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
initialOffset: Number.MAX_SAFE_INTEGER,
anchorTo: "end",
followOnAppend: false,
rangeExtractor: (range) => {
const indexes = defaultRangeExtractor(range);
const focused =
this.focusedRowKey === null ? undefined : this.rowIndexesByKey.get(this.focusedRowKey);
if (
focused === undefined ||
focused < 0 ||
focused >= range.count ||
indexes.includes(focused)
) {
return indexes;
}
return [...indexes, focused].sort((left, right) => left - right);
},
scrollEndThreshold: CHAT_TRANSCRIPT_END_THRESHOLD_PX,
overscan: CHAT_TRANSCRIPT_OVERSCAN,
});
@@ -193,6 +219,10 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
return this.host.updateComplete;
}
get liveAnnouncementText() {
return this.currentAnnouncementText;
}
requestUpdate = () => {
this.host.requestUpdate();
};
@@ -227,11 +257,14 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
render(
rows: readonly ChatTranscriptRow[],
renderRow: (row: ChatTranscriptRow) => unknown,
announcement: ChatTranscriptAnnouncement | null,
announce: boolean,
overlay: unknown = nothing,
): TemplateResult {
this.syncRows(rows);
this.syncAnnouncement(announcement, announce);
const virtualizer = this.virtualizerController.getVirtualizer();
const virtualRows = virtualizer.getVirtualItems();
const firstRow = virtualRows[0];
return html`
<div
class="chat-thread-inner chat-thread-inner--virtual"
@@ -244,37 +277,34 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
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>
${overlay}
${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"
: ""}"
style=${styleMap({
transform: `translateY(${virtualRow.start}px)`,
})}
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>
`;
@@ -284,6 +314,42 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
this.virtualizerController.getVirtualizer().scrollToEnd(options);
}
handleFocusIn(event: FocusEvent): void {
this.focusedRowKey = this.rowKeyFromEvent(event);
}
handleFocusOut(event: FocusEvent): void {
this.focusedRowKey = this.rowKeyFromEvent(event, event.relatedTarget);
}
private rowKeyFromEvent(event: FocusEvent, target: EventTarget | null = event.target) {
if (!(target instanceof Element) || !this.scrollElement?.contains(target)) {
return null;
}
const row = target.closest<HTMLElement>(".chat-virtual-row[data-virtual-row-key]");
if (!row || !this.scrollElement.contains(row)) {
return null;
}
return row.dataset.virtualRowKey || null;
}
private syncAnnouncement(
announcement: ChatTranscriptAnnouncement | null,
announce: boolean,
): void {
if (!this.announcementInitialized || !announce) {
this.announcementInitialized = true;
this.announcementKey = announcement?.key ?? null;
this.currentAnnouncementText = "";
return;
}
if (!announcement || announcement.key === this.announcementKey) {
return;
}
this.announcementKey = announcement.key;
this.currentAnnouncementText = announcement.text;
}
private syncRows(rows: readonly ChatTranscriptRow[]): void {
const nextKeys = rows.map((row) => row.key);
if (
@@ -293,6 +359,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
return;
}
this.rowKeys = Object.freeze(nextKeys);
this.rowIndexesByKey = new Map(this.rowKeys.map((key, index) => [key, index]));
const keys = this.rowKeys;
const virtualizer = this.virtualizerController.getVirtualizer();
virtualizer.setOptions({
@@ -332,6 +399,14 @@ export class ChatTranscriptController implements ReactiveController {
this.sessionVirtualizer?.scrollToEnd(options);
}
handleFocusIn(event: FocusEvent): void {
this.sessionVirtualizer?.handleFocusIn(event);
}
handleFocusOut(event: FocusEvent): void {
this.sessionVirtualizer?.handleFocusOut(event);
}
hostConnected(): void {
this.connected = true;
this.sessionVirtualizer?.connect();
@@ -752,6 +827,28 @@ function renderHistorySentinel(loading: boolean) {
`;
}
function latestTranscriptAnnouncement(
items: readonly ChatRenderItem[],
): ChatTranscriptAnnouncement | null {
for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) {
const item = items[itemIndex];
if (!item || item.kind !== "group" || item.role.toLowerCase() !== "assistant") {
continue;
}
for (let messageIndex = item.messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = item.messages[messageIndex]?.message;
const text = extractTextCached(message)?.trim();
if (text) {
return {
key: item.key,
text: truncateUtf16Safe(text, CHAT_TRANSCRIPT_ANNOUNCEMENT_MAX_CHARS),
};
}
}
}
return null;
}
function chatRenderItemGuardDependencies(item: ChatRenderItem): readonly unknown[] {
if (item.kind === "stream-run") {
return [item.key, ...item.parts];
@@ -946,17 +1043,11 @@ function renderChatThreadContents(
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 transcriptRows: ChatTranscriptRow[] = collapsedItems.map((item) => ({
kind: "item",
key: item.key,
item,
}));
const realtimeConversation = renderRealtimeTalkConversation(props);
if (realtimeConversation !== nothing) {
transcriptRows.push({
@@ -1004,12 +1095,38 @@ function renderChatThreadContents(
props.allowExternalEmbedUrls ?? false,
threadContextWindow,
]);
const transcriptContents =
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),
latestTranscriptAnnouncement(collapsedItems),
!state.searchOpen && !props.loading,
props.historyPagination
? renderHistorySentinel(props.historyPagination.loading)
: nothing,
);
return html`
<div
class="chat-thread ${isDirectThread ? "chat-thread--direct" : ""}"
role="log"
aria-live="polite"
aria-live="off"
aria-relevant="additions"
tabindex="0"
@focusin=${(event: FocusEvent) => transcript.handleFocusIn(event)}
@focusout=${(event: FocusEvent) => transcript.handleFocusOut(event)}
@scroll=${props.onChatScroll}
@wheel=${props.onHistoryIntent ? { handleEvent: props.onHistoryIntent, passive: true } : null}
@keydown=${props.onHistoryIntent}
@@ -1032,22 +1149,14 @@ function renderChatThreadContents(
@contextmenu=${(event: MouseEvent) => handleChatContextMenu(event, props)}
@pointerup=${(event: PointerEvent) => handleChatThreadSelectionPointerUp(event, props)}
>
${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,
)}
<span
class="chat-transcript-announcement agent-chat__sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>${transcript.liveAnnouncementText}</span
>
${transcriptContents}
</div>
`;
}

View File

@@ -165,14 +165,10 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
width: 100%;
}
.chat-virtual-range {
.chat-virtual-row {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.chat-virtual-row {
display: flow-root;
width: 100%;
}
@@ -188,6 +184,16 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
justify-content: center;
}
.chat-virtual-sizer > .chat-history-sentinel {
position: absolute;
z-index: 1;
top: 0;
right: 0;
left: 0;
min-height: 1px;
pointer-events: none;
}
.chat-history-loading {
display: inline-flex;
align-items: center;