mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
fix: stabilize chat transcript rows
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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(" ");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user