fix(coach): clear streaming bubble after persisted twin lands

After an SSE `done` frame the streaming hook keeps `streaming.content`
populated to support the in-flight render path, then fires a TanStack
invalidate that pulls the persisted assistant message into
`conversation.messages`. Until the next `send` reset the message
thread therefore rendered the assistant reply twice — once from the
streaming bubble and once from the persisted message — until the
drawer was closed and reopened (which is when Marc noticed the bug
went away).

Suppress the streaming bubble at render time as soon as the persisted
twin lands. The match keys on `streaming.messageId`, which the SSE
`done` frame fills in once the row is on disk; mid-stream the field
is null so the in-flight bubble stays visible.

Add two regression tests: one that asserts dedup-on-id works and one
that pins the still-streaming render path so the fix doesn't trade a
duplicate-bubble bug for a no-bubble bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 19:23:26 +02:00
1 parent 2143377844
commit f07e35bb9a
2 files changed
+83 -1

No files matched your search

@@ -144,6 +144,72 @@ describe("<MessageThread>", () => {
expect(html).toContain("could not reach an AI provider");
});
it("hides the streaming bubble once its persisted twin lands by id", () => {
// After the SSE `done` frame the hook keeps `streaming.content`
// populated AND fires a TanStack invalidate. When the invalidated
// refetch resolves, the persisted message lands inside
// `conversation.messages` while `streaming.messageId` matches
// exactly. Without de-dup the user sees the assistant reply twice.
const conv: CoachConversationDetailDTO = {
...baseConversation,
messages: [
...baseConversation.messages,
{
id: "m3-streaming",
role: "assistant",
content: "Looking at your data fresh persisted",
createdAt: "2026-05-10T09:01:30.000Z",
metricSource: null,
providerType: "admin-openai",
promptVersion: "4.20.0",
},
],
};
const html = render(
<MessageThread
conversation={conv}
streaming={{
content: "Looking at your data ",
metricSource: null,
inProgress: false,
messageId: "m3-streaming",
errorCode: null,
}}
/>,
);
// 1 persisted user + 2 persisted assistant + 0 streaming → 2.
const assistantBubbles = (
html.match(/data-slot="coach-bubble-assistant"/g) ?? []
).length;
expect(assistantBubbles).toBe(2);
// The persisted text shows; the older streaming sketch does not
// appear as a separate bubble.
expect(html).toContain("fresh persisted");
});
it("still renders the streaming bubble while inProgress and id is null", () => {
// Mid-stream — `messageId` is null, persisted twin can't exist
// yet. Confirm the streaming branch survives the v1.4.20.1
// de-dup logic.
const html = render(
<MessageThread
conversation={baseConversation}
streaming={{
content: "Looking at your data ",
metricSource: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
/>,
);
const assistantBubbles = (
html.match(/data-slot="coach-bubble-assistant"/g) ?? []
).length;
expect(assistantBubbles).toBe(2);
});
it("uses German empty hint when locale is 'de'", () => {
const html = render(<MessageThread conversation={null} />, "de");
expect(html).toContain(
@@ -52,8 +52,24 @@ export function MessageThread({
const wasPinnedRef = useRef(true);
const messages: CoachMessageDTO[] = conversation?.messages ?? [];
// v1.4.20.1 — once the SSE stream emits `done`, the route's
// invalidate-then-refetch pulls the persisted assistant message into
// `conversation.messages`. The streaming bubble was still rendering
// because the hook keeps `streaming.content` populated to support
// the in-flight render path; the result was two assistant bubbles
// side by side until the next `send` reset the streaming state.
// Suppress the streaming bubble at render time as soon as the
// persisted twin lands — comparing on `messageId` keeps the
// transition seamless while never accidentally hiding an in-flight
// bubble (during streaming `messageId` is null).
const streamingPersisted =
streaming?.messageId != null &&
messages.some((m) => m.id === streaming.messageId);
const streamingActive =
!!streaming?.inProgress || !!streaming?.content || !!streaming?.errorCode;
!streamingPersisted &&
(!!streaming?.inProgress ||
!!streaming?.content ||
!!streaming?.errorCode);
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.