fix(chat): stop the just-sent user message from flickering
The server echo of an optimistic user message carries different part ids, so the reducer dropped the optimistic part and appended the server one at the end, and MessageBody keyed user parts by part id. The key change remounted the text subtree (blank frame, markdown re-parse, truncation state reset) and the append reordered text against file parts. Replace the optimistic part in place and key user parts positionally.
This commit is contained in:
@@ -726,10 +726,13 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
)}
|
||||
style={useStickyScrollableUserContent ? { maxHeight: 'calc(var(--chat-scroll-height, 100dvh) * 0.4)' } : undefined}
|
||||
>
|
||||
{/* Positional keys, not part ids: the server echo of a just-sent
|
||||
message swaps the optimistic part id, and id-based keys would
|
||||
remount the text subtree (blank frame + height jump). */}
|
||||
{userContentParts.map((part, index) => {
|
||||
if (isSubtaskPart(part)) {
|
||||
return (
|
||||
<React.Fragment key={part.id ?? `user-subtask-${index}`}>
|
||||
<React.Fragment key={`user-subtask-${index}`}>
|
||||
<UserSubtaskPart part={part} />
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -737,7 +740,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
|
||||
if (isShellActionPart(part)) {
|
||||
return (
|
||||
<React.Fragment key={part.id ?? `user-shell-${index}`}>
|
||||
<React.Fragment key={`user-shell-${index}`}>
|
||||
<UserShellActionPart part={part} />
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -752,7 +755,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
}
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={part.id ?? `user-text-${index}`}>
|
||||
<React.Fragment key={`user-text-${index}`}>
|
||||
<UserTextPart
|
||||
part={part}
|
||||
messageId={messageId}
|
||||
|
||||
@@ -114,6 +114,22 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(draft.part.msg_1).toEqual([legacyPart, currentPart])
|
||||
})
|
||||
|
||||
test("replaces an optimistic user part in place instead of appending it", () => {
|
||||
const optimisticText = { id: "prt_optimistic_text", messageID: "msg_1", type: "text", text: "hi" } as Part
|
||||
const optimisticFile = { id: "prt_optimistic_file", messageID: "msg_1", type: "file", filename: "a.png" } as Part
|
||||
const serverText = { id: "prt_server_text", messageID: "msg_1", sessionID: "ses_1", type: "text", text: "hi" } as Part
|
||||
const draft = state({
|
||||
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "user", time: { created: 1 } } as Message] },
|
||||
part: { msg_1: [optimisticText, optimisticFile] },
|
||||
})
|
||||
|
||||
expect(applyDirectoryEvent(draft, {
|
||||
type: "message.part.updated",
|
||||
properties: { part: serverText },
|
||||
} as Event)).toBe(true)
|
||||
expect(draft.part.msg_1).toEqual([serverText, optimisticFile])
|
||||
})
|
||||
|
||||
test("returns typed materialization when delta arrives before parts", () => {
|
||||
const result = applyDirectoryEvent(state(), deltaEvent())
|
||||
|
||||
|
||||
@@ -441,9 +441,12 @@ export function applyDirectoryEvent(
|
||||
? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID)
|
||||
: -1
|
||||
if (optimisticIndex >= 0) {
|
||||
next.splice(optimisticIndex, 1)
|
||||
// Replace in place: pushing to the end reorders text/file parts of a
|
||||
// just-sent message and remounts its rendered subtree.
|
||||
next[optimisticIndex] = part
|
||||
} else {
|
||||
next.push(part)
|
||||
}
|
||||
next.push(part)
|
||||
}
|
||||
draft.part[messageID] = next
|
||||
return missingOwningMessage
|
||||
|
||||
Reference in New Issue
Block a user