mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 19:21:13 +00:00
fix: stabilize virtualized chat rows
This commit is contained in:
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user