diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 9df088a4780..d0b64e61a5e 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -36,8 +36,10 @@ import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from import { buildIssueChatMessages, formatDurationWords, + stabilizeThreadMessages, type IssueChatComment, type IssueChatLinkedRun, + type StableThreadMessageCacheEntry, type IssueChatTranscriptEntry, type SegmentTiming, } from "../lib/issue-chat-messages"; @@ -1925,8 +1927,7 @@ export function IssueChatThread({ }); const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun; const resolvedHasOutputForRun = hasOutputForRunOverride ?? hasOutputForRun; - - const messages = useMemo( + const rawMessages = useMemo( () => buildIssueChatMessages({ comments, @@ -1957,6 +1958,18 @@ export function IssueChatThread({ currentUserId, ], ); + const stableMessagesRef = useRef([]); + const stableMessageCacheRef = useRef>(new Map()); + const messages = useMemo(() => { + const stabilized = stabilizeThreadMessages( + rawMessages, + stableMessagesRef.current, + stableMessageCacheRef.current, + ); + stableMessagesRef.current = stabilized.messages; + stableMessageCacheRef.current = stabilized.cache; + return stabilized.messages; + }, [rawMessages]); const isRunning = displayLiveRuns.some((run) => run.status === "queued" || run.status === "running"); const feedbackVoteByTargetId = useMemo(() => { diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index 560fc3b14d8..c79759f4a7b 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -3,6 +3,7 @@ import type { Agent } from "@paperclipai/shared"; import { buildAssistantPartsFromTranscript, buildIssueChatMessages, + stabilizeThreadMessages, type IssueChatComment, type IssueChatLinkedRun, } from "./issue-chat-messages"; @@ -527,3 +528,69 @@ describe("buildIssueChatMessages", () => { }); }); }); + +describe("stabilizeThreadMessages", () => { + it("reuses unchanged message objects across rebuilds", () => { + const firstPass = buildIssueChatMessages({ + comments: [createComment()], + timelineEvents: [], + linkedRuns: [], + liveRuns: [], + currentUserId: "user-1", + }); + + const firstStable = stabilizeThreadMessages(firstPass, [], new Map()); + const secondPass = buildIssueChatMessages({ + comments: [ + createComment(), + createComment({ + id: "comment-2", + body: "New message", + createdAt: new Date("2026-04-06T12:01:00.000Z"), + updatedAt: new Date("2026-04-06T12:01:00.000Z"), + }), + ], + timelineEvents: [], + linkedRuns: [], + liveRuns: [], + currentUserId: "user-1", + }); + + const secondStable = stabilizeThreadMessages( + secondPass, + firstStable.messages, + firstStable.cache, + ); + + expect(secondStable.messages).toHaveLength(2); + expect(secondStable.messages[0]).toBe(firstStable.messages[0]); + expect(secondStable.messages[1]?.id).toBe("comment-2"); + }); + + it("reuses the previous array when nothing semantically changed", () => { + const firstPass = buildIssueChatMessages({ + comments: [createComment()], + timelineEvents: [], + linkedRuns: [], + liveRuns: [], + currentUserId: "user-1", + }); + + const firstStable = stabilizeThreadMessages(firstPass, [], new Map()); + const secondPass = buildIssueChatMessages({ + comments: [createComment()], + timelineEvents: [], + linkedRuns: [], + liveRuns: [], + currentUserId: "user-1", + }); + + const secondStable = stabilizeThreadMessages( + secondPass, + firstStable.messages, + firstStable.cache, + ); + + expect(secondStable.messages).toBe(firstStable.messages); + }); +}); diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index e9c9ecc6e79..8b6869fa456 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -81,6 +81,11 @@ type MessageWithOrder = { message: ThreadMessage; }; +export interface StableThreadMessageCacheEntry { + fingerprint: string; + message: ThreadMessage; +} + function toDate(value: Date | string | null | undefined) { return value instanceof Date ? value : new Date(value ?? Date.now()); } @@ -89,6 +94,41 @@ function toTimestamp(value: Date | string | null | undefined) { return toDate(value).getTime(); } +function fingerprintThreadMessage(message: ThreadMessage) { + return JSON.stringify(message); +} + +export function stabilizeThreadMessages( + messages: readonly ThreadMessage[], + previousMessages: readonly ThreadMessage[], + previousById: ReadonlyMap, +) { + const nextById = new Map(); + let sameSequence = previousMessages.length === messages.length; + + const stabilizedMessages = messages.map((message, index) => { + const fingerprint = fingerprintThreadMessage(message); + const cached = previousById.get(message.id); + const stableMessage = + cached && cached.fingerprint === fingerprint + ? cached.message + : message; + nextById.set(message.id, { + fingerprint, + message: stableMessage, + }); + if (sameSequence && previousMessages[index] !== stableMessage) { + sameSequence = false; + } + return stableMessage; + }); + + return { + messages: sameSequence ? previousMessages : stabilizedMessages, + cache: nextById, + }; +} + function sortByCreated(items: readonly T[]) { return [...items].sort((a, b) => { const diff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);