fix(coach): suggested-prompt chips show user message before thinking placeholder

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:19:17 +02:00
1 parent 7c06b89717
commit 1895fd3cd8
4 files changed
+160 -3

No files matched your search

@@ -400,4 +400,62 @@ describe("<MessageThread>", () => {
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE</);
});
// v1.4.25 W5 — optimistic user bubble appears before the "Thinking…"
// placeholder so the visible order matches the user's mental model.
it("renders the optimistic user bubble before the streaming Thinking placeholder", () => {
const html = render(
<MessageThread
conversation={null}
optimisticUser={{
localId: "local-1",
content: "Wie ist mein Blutdruck letzte Woche?",
conversationId: null,
}}
streaming={{
content: "",
metricSource: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
/>,
);
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(
<MessageThread
conversation={baseConversation}
optimisticUser={{
localId: "local-2",
content: "Why was BP higher on Monday?",
conversationId: "conv-1",
}}
streaming={{
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: null,
}}
/>,
);
const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? [])
.length;
expect(userBubbles).toBe(1);
});
});
@@ -304,6 +304,7 @@ export function CoachDrawer({
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
/>
}
composer={
@@ -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 (
<div
data-slot="coach-message-thread"
@@ -184,6 +214,18 @@ export function MessageThread({
/>
);
})}
{/* 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 && (
<ChatBubble
key={optimisticUser.localId}
role="user"
content={optimisticUser.content}
/>
)}
{streamingActive && streaming && (
// role=log + aria-live=polite so screen-reader users hear the
// assistant prose announce as tokens land. aria-relevant=text
@@ -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<CoachStreamingMessage>(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<CoachOptimisticUserMessage | null>(null);
const abortRef = useRef<AbortController | null>(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,
};
}