mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
fix(ui): stabilize issue chat message identities
This commit is contained in:
1 parent
f585689b68
commit
7d1d365217
3 files changed
+122
-2
No files matched your search
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user