From 9e6865841dc74edb62cc9613aefe78d81b87c3bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 22:04:47 +0200 Subject: [PATCH] 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. --- e2e/v1427-coach-launch.spec.ts | 39 +++++++ src/app/api/insights/chat/route.ts | 27 ++++- .../coach-drawer-mobile-trays.test.tsx | 57 ++++++---- .../__tests__/message-thread.test.tsx | 37 +++++- .../__tests__/mobile-rail-tray.test.tsx | 14 ++- .../__tests__/sources-rail.test.tsx | 8 +- .../coach-panel/coach-drawer-body.tsx | 71 ++++++------ .../insights/coach-panel/coach-drawer.tsx | 16 +-- .../insights/coach-panel/message-thread.tsx | 106 ++++++++++-------- .../insights/coach-panel/mobile-rail-tray.tsx | 8 +- .../insights/coach-panel/sources-rail.tsx | 22 +--- 11 files changed, 265 insertions(+), 140 deletions(-) diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 0cacdaaa8..3ca8c9604 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -181,4 +181,43 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => { await expect(drawer).toBeVisible({ timeout: 10_000 }); await expect(drawer).toHaveAttribute("data-variant", "side-sheet"); }); + + test("conversation history is collapsed behind a toggle that opens the tray on Desktop Chrome", async ({ + page, + }, testInfo) => { + // v1.12.0 — the history rail is no longer an always-on left column; + // it lives behind the "Conversations" toggle on every viewport and + // opens as a tray on demand so the thread keeps the full width. + test.skip( + testInfo.project.name !== "chromium-desktop", + "exercises the desktop drawer layout", + ); + + await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); + + const icon = page.locator('[data-slot="coach-launch-icon"]').first(); + await expect(icon).toBeVisible({ timeout: 10_000 }); + await icon.click(); + + const drawer = page.locator('[data-slot="coach-drawer"]'); + await expect(drawer).toBeVisible({ timeout: 10_000 }); + + // No inline history column — only the toggle. + await expect( + page.locator('[data-slot="coach-drawer-history"]'), + ).toHaveCount(0); + const historyToggle = page.locator( + '[data-slot="coach-drawer-history-tray-trigger"]', + ); + await expect(historyToggle).toBeVisible({ timeout: 10_000 }); + + // The history rail surfaces only after the toggle is pressed. + await expect( + page.locator('[data-slot="coach-drawer-history-tray"]'), + ).toHaveCount(0); + await historyToggle.click(); + await expect( + page.locator('[data-slot="coach-drawer-history-tray"]'), + ).toBeVisible({ timeout: 10_000 }); + }); }); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index bd612c595..4a944a0d7 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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 { + 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({ diff --git a/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx b/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx index 186a12705..63831a031 100644 --- a/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx @@ -7,15 +7,15 @@ import { CoachDrawerBody } from "../coach-drawer-body"; /** * v1.4.20 phase B4 — Coach drawer mobile rail trays. * - * The drawer hides the history + sources rails on `