diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 429218bfb1a7..32e1246d75a8 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -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", () => { diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index 8640fce2ce61..a2778bbee35f 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -1405,7 +1405,10 @@ function buildChatItems(props: BuildChatItemsProps): Array(); + const previousGroupByMessageKey = new Map(); + 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(); + const reconciled = next.map((item) => { + if (item.kind !== "group") { + return item; + } + const candidates = new Map(); + 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), diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 7345899710dd..510ffcfd5476 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -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(); diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index 638d7296ce27..1a265067b708 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -2131,7 +2131,6 @@ function renderGroupedMessage( "chat-bubble", isToolShell ? "chat-bubble--tool-shell" : "", opts.isStreaming ? "streaming" : "", - "fade-in", ] .filter(Boolean) .join(" "); diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 89f2507a9ee8..5d1df28cf969 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -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;