From d1c8bd48af083db5936d8fd73cffbd9f88499f00 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 15 Jul 2026 00:18:45 +0100 Subject: [PATCH] fix: stabilize virtualized chat rows --- pnpm-lock.yaml | 21 +- ui/package.json | 3 +- ui/src/pages/chat/chat-thread.test.ts | 81 ++++++- ui/src/pages/chat/chat-thread.ts | 88 +++++++- ui/src/pages/chat/chat-view.test.ts | 77 ++++++- ui/src/pages/chat/components/chat-thread.ts | 229 +++++++++++++++----- ui/src/styles/chat/layout.css | 16 +- 7 files changed, 421 insertions(+), 94 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab0c7bfd2459..29ed260ee6ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/ui/package.json b/ui/package.json index fd46271504d1..9d9f229de596 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 42ff4505d024..ff9424c1facb 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -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), + ); }); }); diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index 224ef7766d56..eaf59f2f1150 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -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(); + +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(); + for (const sourceKey of sourceKeys) { + if (sourceKey) { + sourceCounts.set(sourceKey, (sourceCounts.get(sourceKey) ?? 0) + 1); + } + } + const projectionOccurrences = new Map(); + 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 !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 !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(); @@ -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}`; diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 2afe0c1a956a..7f1749c61d47 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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", + ); }); }); diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index b4a545046db9..74f32572c0de 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -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; private scrollElement: HTMLDivElement | null = null; private rowKeys: readonly string[] = []; + private rowIndexesByKey = new Map(); + 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`
-
- ${repeat( - virtualRows, - (virtualRow) => virtualRow.key, - (virtualRow) => { - const row = rows[virtualRow.index]; - if (!row) { - return nothing; - } - return html` -
- virtualizer.measureElement(element instanceof HTMLElement ? element : null), - )} - > - ${renderRow(row)} -
- `; - }, - )} -
+ ${overlay} + ${repeat( + virtualRows, + (virtualRow) => virtualRow.key, + (virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) { + return nothing; + } + return html` +
+ virtualizer.measureElement(element instanceof HTMLElement ? element : null), + )} + > + ${renderRow(row)} +
+ `; + }, + )}
`; @@ -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(".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` +
+ ${props.historyPagination + ? renderHistorySentinel(props.historyPagination.loading) + : nothing} + ${showLoadingSkeleton ? renderLoadingSkeleton() : nothing} + ${isEmpty && !state.searchOpen ? renderWelcomeState(props) : nothing} + ${isEmpty && state.searchOpen + ? html`
${t("chat.thread.noMatches")}
` + : nothing} +
+ ` + : 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`
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` -
- ${props.historyPagination - ? renderHistorySentinel(props.historyPagination.loading) - : nothing} - ${showLoadingSkeleton ? renderLoadingSkeleton() : nothing} - ${isEmpty && !state.searchOpen ? renderWelcomeState(props) : nothing} - ${isEmpty && state.searchOpen - ? html`
${t("chat.thread.noMatches")}
` - : nothing} -
- ` - : transcript.render(transcriptRows, (row) => - row.kind === "item" ? renderItem(row.item) : row.content, - )} + ${transcript.liveAnnouncementText} + ${transcriptContents}
`; } diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 7340559dc71d..03e5e4363e64 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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;