mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
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.
|
||||
|
||||
Reference in new issue
Block a user