fix: stabilize chat transcript rows

This commit is contained in:
Shakker
2026-07-14 19:51:38 +01:00
committed by Shakker
parent fb7cda20f1
commit 8121639e8d
5 changed files with 194 additions and 9 deletions
+118
View File
@@ -257,6 +257,124 @@ describe("collapseCompletedTurnWork", () => {
expect(workGroups).toHaveLength(2);
expect(new Set(workGroups.map((item) => item.key)).size).toBe(2);
});
it("keeps a completed-work row keyed to its final reply as older work is prepended", () => {
resetChatThreadState();
const finalReply = {
__openclaw: { id: "final-reply", seq: 3 },
role: "assistant",
content: "Done.",
timestamp: 3_000,
};
const initial = collapsedItems({
messages: [toolResult("call-1", 2_000), finalReply],
});
const initialWork = requireWorkGroup(initial[0]);
const prepended = collapsedItems({
messages: [
{
__openclaw: { id: "older-commentary", seq: 1 },
role: "assistant",
content: "Checking.",
timestamp: 1_000,
},
toolResult("call-1", 2_000),
finalReply,
],
});
const prependedWork = requireWorkGroup(prepended[0]);
expect(prependedWork.key).toBe(initialWork.key);
expect(prependedWork.durationMs).toBeGreaterThan(initialWork.durationMs ?? 0);
});
});
describe("buildCachedChatItems row identity", () => {
it("preserves a same-role group key as messages are prepended and appended", () => {
resetChatThreadState();
const first = {
__openclaw: { id: "assistant-1", seq: 2 },
role: "assistant",
content: "First",
timestamp: 2,
};
const second = {
__openclaw: { id: "assistant-2", seq: 3 },
role: "assistant",
content: "Second",
timestamp: 3,
};
const initial = groupAt(messageGroups({ messages: [first, second] }), 0);
const prepended = groupAt(
messageGroups({
messages: [
{
__openclaw: { id: "assistant-0", seq: 1 },
role: "assistant",
content: "Earlier",
timestamp: 1,
},
first,
second,
],
}),
0,
);
const appended = groupAt(
messageGroups({
messages: [
...prepended.messages.map((entry) => entry.message),
{
__openclaw: { id: "assistant-3", seq: 4 },
role: "assistant",
content: "Later",
timestamp: 4,
},
],
}),
0,
);
expect(prepended.key).toBe(initial.key);
expect(appended.key).toBe(initial.key);
});
it("keeps a projected-sibling group stable after an unrelated prepend", () => {
resetChatThreadState();
const siblings = [
{
__openclaw: { id: "source-message", seq: 2 },
role: "assistant",
content: "First projection",
timestamp: 2,
},
{
__openclaw: { id: "source-message", seq: 2 },
role: "assistant",
content: "Second projection",
timestamp: 2,
},
];
const initial = groupAt(messageGroups({ messages: siblings }), 0);
const prepended = groupAt(
messageGroups({
messages: [
{
__openclaw: { id: "older-user", seq: 1 },
role: "user",
content: "Earlier",
timestamp: 1,
},
...siblings,
],
}),
1,
);
expect(new Set(initial.messages.map((entry) => entry.key)).size).toBe(2);
expect(prepended.key).toBe(initial.key);
});
});
describe("buildCachedChatItems working spark", () => {
+64 -3
View File
@@ -1405,7 +1405,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: `${messageKey(
liftedCanvasSource.message,
liftedCanvasSource.index + history.length,
)}:canvas`,
message: createCanvasAssistantMessage(liftedCanvasSource, timestamp),
});
continue;
@@ -1649,8 +1652,65 @@ function stabilizeChatItems(
if (previous.length === 0 || next.length === 0) {
return next;
}
// Same-role groups can grow at either edge. Preserve the existing row key
// when loaded-history arrays retain message objects across prepend or append.
const previousGroupByMessage = new WeakMap<object, MessageGroup>();
const previousGroupByMessageKey = new Map<string, MessageGroup>();
for (const item of previous) {
if (item.kind !== "group") {
continue;
}
for (const message of item.messages) {
if (message.message && typeof message.message === "object") {
previousGroupByMessage.set(message.message, item);
}
previousGroupByMessageKey.set(message.key, item);
}
}
const claimedGroupKeys = new Set<string>();
const reconciled = next.map((item) => {
if (item.kind !== "group") {
return item;
}
const candidates = new Map<MessageGroup, { overlap: number; lastMatchIndex: number }>();
for (const [index, message] of item.messages.entries()) {
const prior =
message.message && typeof message.message === "object"
? (previousGroupByMessage.get(message.message) ??
previousGroupByMessageKey.get(message.key))
: previousGroupByMessageKey.get(message.key);
if (
!prior ||
claimedGroupKeys.has(prior.key) ||
prior.role !== item.role ||
prior.senderLabel !== item.senderLabel
) {
continue;
}
const candidate = candidates.get(prior);
candidates.set(prior, {
overlap: (candidate?.overlap ?? 0) + 1,
lastMatchIndex: index,
});
}
let best: { group: MessageGroup; overlap: number; lastMatchIndex: number } | null = null;
for (const [group, candidate] of candidates) {
if (
!best ||
candidate.overlap > best.overlap ||
(candidate.overlap === best.overlap && candidate.lastMatchIndex > best.lastMatchIndex)
) {
best = { group, ...candidate };
}
}
if (!best) {
return item;
}
claimedGroupKeys.add(best.group.key);
return item.key === best.group.key ? item : { ...item, key: best.group.key };
});
const previousByKey = new Map(previous.map((item) => [`${item.kind}\u0000${item.key}`, item]));
const stabilized = next.map((item) => {
const stabilized = reconciled.map((item) => {
const prior = previousByKey.get(`${item.kind}\u0000${item.key}`);
return prior && sameChatItem(prior, item) ? prior : item;
});
@@ -1876,7 +1936,8 @@ export function collapseCompletedTurnWork(
result.push(...turn.slice(0, segmentStart));
result.push({
kind: "work-group",
key: `work:${firstGroup.key}`,
// The final reply survives older-history prepends; the first work row does not.
key: `work:${finalReply.key}`,
groups,
durationMs,
hasError: workGroupHasError(groups),
@@ -525,6 +525,18 @@ describe("grouped chat rendering", () => {
expect(userBubble.querySelector(".chat-bubble-actions")).toBeNull();
});
it("does not replay an arrival animation when a message row mounts", () => {
const container = document.createElement("div");
renderAssistantMessage(container, {
role: "assistant",
content: "Stable transcript row",
timestamp: 1000,
});
const bubble = expectElement(container, ".chat-bubble", HTMLElement);
expect(bubble.classList.contains("fade-in")).toBe(false);
});
it("uses the displayed answer for assistant message actions", () => {
const container = document.createElement("div");
const onOpenSidebar = vi.fn();
@@ -2131,7 +2131,6 @@ function renderGroupedMessage(
"chat-bubble",
isToolShell ? "chat-bubble--tool-shell" : "",
opts.isStreaming ? "streaming" : "",
"fade-in",
]
.filter(Boolean)
.join(" ");
-5
View File
@@ -518,11 +518,6 @@ img.chat-avatar {
box-shadow: none;
}
/* Fade-in animation for new messages */
.chat-bubble.fade-in {
animation: fade-in 200ms ease-out;
}
@keyframes fade-in {
from {
opacity: 0;