From 1895fd3cd85448191f32075de1fc2eccb9282216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:18:41 +0200 Subject: [PATCH] fix(coach): suggested-prompt chips show user message before thinking placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The send hook now surfaces the user's message as an optimistic bubble the moment send fires, so the chronological render order matches the user's mental model: user message → "Thinking…" placeholder → assistant reply. The optimistic copy drops as soon as the persisted twin lands via the invalidate-refetch the SSE `done` frame triggers. --- .../__tests__/message-thread.test.tsx | 58 +++++++++++++++++++ .../insights/coach-panel/coach-drawer.tsx | 1 + .../insights/coach-panel/message-thread.tsx | 48 ++++++++++++++- .../insights/coach-panel/use-coach.ts | 56 ++++++++++++++++++ 4 files changed, 160 insertions(+), 3 deletions(-) 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 b1cf8ad3a..efa51bc33 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -400,4 +400,62 @@ describe("", () => { expect(html).toContain('data-slot="coach-bubble-user-avatar"'); expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE { + const html = render( + , + ); + expect(html).toContain("Wie ist mein Blutdruck letzte Woche?"); + expect(html).toContain("Thinking"); + // The user bubble appears before the assistant bubble in the DOM. + const userIdx = html.indexOf('data-slot="coach-bubble-user"'); + const assistantIdx = html.indexOf('data-slot="coach-bubble-assistant"'); + expect(userIdx).toBeGreaterThan(-1); + expect(assistantIdx).toBeGreaterThan(-1); + expect(userIdx).toBeLessThan(assistantIdx); + }); + + it("suppresses the optimistic user bubble once its persisted twin lands", () => { + // baseConversation already carries a user message "Why was BP + // higher on Monday?". Passing the same content as the optimistic + // bubble simulates the post-`done` invalidate-refetch landing the + // persisted user message — the optimistic copy must drop so the + // user never sees their bubble twice. + const html = render( + , + ); + const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? []) + .length; + expect(userBubbles).toBe(1); + }); }); diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 25cb76e65..7179e6209 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -304,6 +304,7 @@ export function CoachDrawer({ } composer={ diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 5752528b9..b73ade936 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -20,6 +20,7 @@ import { useCoachPrefs } from "@/hooks/use-coach-prefs"; import { SourceChips } from "./source-chips"; import type { CoachConversationDetailDTO, + CoachOptimisticUserMessage, CoachStreamingMessage, } from "./use-coach"; import type { CoachMessageDTO } from "@/lib/ai/coach/types"; @@ -45,6 +46,15 @@ export interface MessageThreadProps { conversation: CoachConversationDetailDTO | null; /** Optional in-flight bubble from `useSendCoachMessage()`. */ streaming?: CoachStreamingMessage; + /** + * v1.4.25 W5 — optimistic user message surfaced by the send hook so + * the user sees their own bubble before the "Thinking…" placeholder. + * Cleared by the hook once the SSE `done` frame fires (the persisted + * twin lands via the invalidate-refetch). When the persisted twin is + * already in `conversation.messages` we suppress the optimistic copy + * so the user never sees the same bubble twice. + */ + optimisticUser?: CoachOptimisticUserMessage | null; /** Empty-state copy when no conversation is loaded yet. */ emptyHint?: string; } @@ -56,6 +66,7 @@ function isPinnedToBottom(el: HTMLElement, slack = 64): boolean { export function MessageThread({ conversation, streaming, + optimisticUser, emptyHint, }: MessageThreadProps) { const { t } = useTranslations(); @@ -117,6 +128,23 @@ export function MessageThread({ !streamingPersisted && (!!streaming?.inProgress || !!streaming?.content || !!streaming?.errorCode); + // v1.4.25 W5 — render the optimistic user bubble only when the + // persisted twin hasn't landed yet. We match on (role=user, content + // equality, no later persisted user message). The server is the + // source of truth — once the persisted user message lands (via the + // invalidate-refetch the SSE `done` frame triggers), the optimistic + // copy is dropped so the user never sees their bubble twice. + const optimisticActive = (() => { + if (!optimisticUser) return false; + // Suppress if the persisted history already contains the same + // user content as the last user message — the twin has landed. + const lastUser = [...messages] + .reverse() + .find((m) => m.role === "user"); + if (lastUser && lastUser.content === optimisticUser.content) return false; + return true; + })(); + // Track scroll position so we don't yank the viewport when the user // is browsing earlier turns. useEffect(() => { @@ -130,16 +158,18 @@ export function MessageThread({ }, []); // Auto-scroll on new messages OR streaming-content growth, but only - // when the user was already at the bottom. + // when the user was already at the bottom. v1.4.25 W5 — the + // optimistic user bubble counts as a new message; scroll on its + // localId so the user sees their own bubble land at the bottom. useEffect(() => { const el = scrollerRef.current; if (!el) return; if (wasPinnedRef.current) { el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); } - }, [messages.length, streaming?.content]); + }, [messages.length, streaming?.content, optimisticUser?.localId]); - if (messages.length === 0 && !streamingActive) { + if (messages.length === 0 && !streamingActive && !optimisticActive) { return (
); })} + {/* v1.4.25 W5 — optimistic user bubble surfaces between the + persisted history and the streaming assistant placeholder so + the visible order matches the user's mental model. The + send-hook drops it as soon as the SSE `done` frame fires + + the invalidate-refetch lands the persisted twin. */} + {optimisticActive && optimisticUser && ( + + )} {streamingActive && streaming && ( // role=log + aria-live=polite so screen-reader users hear the // assistant prose announce as tokens land. aria-relevant=text diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 548595339..3b522fc10 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -202,6 +202,37 @@ export interface CoachStreamingMessage { errorCode: string | null; } +/** + * v1.4.25 W5 — optimistic user bubble surfaced by the send hook so the + * thread paints the user's message immediately, before the assistant's + * "Thinking…" placeholder. The server persists the user message before + * the stream starts (see `appendMessage` in + * `src/app/api/insights/chat/route.ts`), but the persisted twin only + * lands client-side after the SSE `done` frame triggers a + * `queryClient.invalidateQueries`. The earlier render order was + * "Thinking…" → user message → assistant reply, which Marc flagged in + * the W5 polish brief; surfacing this optimistic bubble flips the + * order to user → "Thinking…" → assistant reply. + * + * The bubble holds the message text + the (parent-supplied) + * conversationId so the thread renderer can match it against the + * persisted history and suppress the twin during the same 150ms grace + * window that already guards the assistant streaming bubble (see + * `message-thread.tsx`). + */ +export interface CoachOptimisticUserMessage { + /** Local-only id so React keys stay stable across renders. */ + localId: string; + /** The user's message text — exactly what was sent to the server. */ + content: string; + /** + * Conversation id at the time the send fired. Null when the user is + * creating a brand-new conversation; the server assigns the id and + * emits it on the `done` frame. + */ + conversationId: string | null; +} + const EMPTY_STREAMING: CoachStreamingMessage = { content: "", metricSource: null, @@ -248,6 +279,13 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { const queryClient = useQueryClient(); const [streaming, setStreaming] = useState(EMPTY_STREAMING); + // v1.4.25 W5 — optimistic user bubble. Mounts the user's message in + // the thread immediately so the chronological render order matches + // the user's mental model (send → my message → coach "Thinking…" → + // reply). Cleared once the persisted twin lands via the invalidate- + // refetch the SSE `done` frame triggers. + const [optimisticUser, setOptimisticUser] = + useState(null); const abortRef = useRef(null); // Stash `opts` in a ref so callers can pass an inline-literal object @@ -260,6 +298,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { const reset = useCallback(() => { setStreaming(EMPTY_STREAMING); + setOptimisticUser(null); }, []); const send = useCallback( @@ -269,6 +308,17 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { const controller = new AbortController(); abortRef.current = controller; + // v1.4.25 W5 — surface the user's message in the thread BEFORE + // the "Thinking…" placeholder paints. Without this the send hook + // only exposed the streaming assistant bubble, so the first + // thing the user saw was the placeholder followed by their own + // message landing on the next refetch — Marc flagged the + // order as confusing on the suggested-prompt chips. + setOptimisticUser({ + localId: `local-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + content: params.message, + conversationId: params.conversationId ?? null, + }); setStreaming({ content: "", metricSource: null, @@ -413,6 +463,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() }); optsRef.current.onDone?.(resolvedConversationId); } + // v1.4.25 W5 — drop the optimistic user bubble; the persisted + // twin is on its way via the invalidate-refetch above. Drop on + // error too so the user message isn't doubled when the next + // refetch sees the server-persisted row. + setOptimisticUser(null); }, [queryClient], ); @@ -427,6 +482,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { send, cancel, reset, + optimisticUser, }; }