fix(ui): stabilize issue chat message identities

This commit is contained in:
Dotta committed 2026-04-14 11:45:22 -05:00
1 parent f585689b68
commit 7d1d365217
3 files changed
+122 -2

No files matched your search

+15 -2
View File
@@ -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<readonly import("@assistant-ui/react").ThreadMessage[]>([]);
const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(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(() => {
+67
View File
@@ -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);
});
});
+40
View File
@@ -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<string, StableThreadMessageCacheEntry>,
) {
const nextById = new Map<string, StableThreadMessageCacheEntry>();
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<T extends { createdAt: Date | string; id: string }>(items: readonly T[]) {
return [...items].sort((a, b) => {
const diff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);