feat(coach): stream replies incrementally, collapsible history and provenance, single disclaimer

Token-by-token streaming was buffering on the server. The chat route
enqueued every tokenised SSE frame synchronously inside the stream's
start() callback, so the runtime coalesced them into a single network
read and the client painted the full answer at once despite the
per-token render path already wired in use-coach. Make the emit callback
async and yield to the event loop between token frames so each one
flushes as its own chunk; the visible cadence now reads ChatGPT/Claude-
style. Refusal and error paths stay single-frame.

The conversation history was a permanent left column on lg+, eating chat
width on laptops and reading as a takeover behind the narrowed drawer
cap. It now lives behind an always-visible "Conversations" toggle on
every viewport and opens as the left-edge tray on demand; the thread
keeps the full width by default and never loses it on mobile.

The provenance block (source chips plus raw key-values) rendered fully
expanded under every answer and was often taller than the reply. Fold
the chips into the existing collapsed disclosure alongside the key-
values so the whole grounding is one click-to-expand surface, collapsed
by default, with the disclosure's aria-expanded/aria-controls intact.

The clinical-decisions disclaimer rendered both at the bottom of the
thread and in the sources-rail footer. Consolidate to a single line
directly above the composer, always visible on every viewport.

The auto-growing composer (single line growing to a capped multi-line
with internal scroll, Enter-to-send, Shift+Enter-newline) and the
aria-live streaming region are preserved. Plain-text React children
throughout; no new dependencies.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 22:17:55 +02:00
1 parent 7ab2cbaa56
commit 9e6865841d
11 files changed
+265 -140

No files matched your search

+26 -1
View File
@@ -100,6 +100,20 @@ function tokeniseForStreaming(content: string): string[] {
return matches ?? [content];
}
/**
* v1.12.0 — yield control back to the event loop for one tick so the
* stream controller flushes the just-enqueued frame before the next one
* is produced. `setTimeout(0)` (rather than a bare `Promise.resolve()`
* microtask) hands the turn back to the platform's stream pump so each
* SSE frame lands in its own network chunk; a microtask would drain
* before the runtime gets a chance to flush. The delay is intentionally
* zero — we want incremental delivery, not an artificial typewriter
* pause.
*/
function flushTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
interface CoachTurn {
role: "user" | "assistant";
content: string;
@@ -485,9 +499,20 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
});
// ── Stream the body to the client ────────────────────────────
const stream = createSseStream((controller) => {
// v1.12.0 — yield to the event loop between token frames so each one
// flushes as its own network chunk. The provider clients return the
// full reply in one shot; without the yield the whole tokenised body
// was enqueued synchronously inside `start()` and the runtime
// coalesced every frame into a single read, so the client painted the
// answer all at once despite the per-token render path in `use-coach`.
// A zero-delay yield is enough to land each frame on its own tick —
// the visible cadence reads ChatGPT/Claude-style without a contrived
// sleep. The refusal + error paths stay single-frame (nothing to
// pace).
const stream = createSseStream(async (controller) => {
for (const tok of tokeniseForStreaming(replyText)) {
controller.enqueue(encodeFrame({ type: "token", token: tok }));
await flushTick();
}
controller.enqueue(
encodeFrame({