diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 75cff0d1e..cb4c72274 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -144,6 +144,72 @@ describe("", () => { 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( + , + ); + // 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( + , + ); + 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(, "de"); expect(html).toContain( diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 6605da3cf..2eaa4738e 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -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.