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

+39
View File
@@ -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 });
});
});
+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({
@@ -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 `<lg`. B4 adds two
* chevron-button triggers along the edges of the message thread that
* open side-sheets surfacing the same rails.
* Two chevron-button triggers along the thread sub-header open
* side-sheets surfacing the history + sources rails.
*
* Phase D reconcile narrowed the lg drawer cap (was 1080px → now
* min(960px,75vw)) so the inline sources rail no longer fits below
* xl. The history rail surfaces inline on lg; the sources rail
* surfaces inline on xl. Mobile chevron triggers cover the missing
* rails: history hidden on lg+, sources hidden on xl+.
* v1.12.0 — the conversation history is no longer an inline column on
* any viewport. The "Conversations" trigger is the only entry to the
* history rail and is therefore visible at every breakpoint; the
* sources rail stays inline at xl+ so its trigger keeps `xl:hidden`.
* The clinical-decisions disclaimer renders once, directly above the
* composer.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -25,10 +25,10 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
const baseProps = {
historyRail: <span>history</span>,
sourcesRail: <span>sources</span>,
thread: <span>thread</span>,
composer: <span>composer</span>,
disclaimer: <span>disclaimer</span>,
onOpenHistoryTray: () => undefined,
onOpenSourcesTray: () => undefined,
};
@@ -40,42 +40,55 @@ describe("<CoachDrawerBody> — mobile rail trays", () => {
expect(html).toMatch(/data-slot="coach-drawer-sources-tray-trigger"/);
});
it("hides the history trigger on >=lg and the sources trigger on >=xl", () => {
// v1.4.27 R3d MB2 — the strip wrapper carries `xl:hidden` so the
// sources trigger inherits the breakpoint; the history trigger
// keeps its own `lg:hidden` because the history rail mounts
// inline already on lg+ while the sources rail still needs the
// chevron until xl.
it("keeps the history trigger on every breakpoint and hides the sources trigger on >=xl", () => {
// v1.12.0 — the history rail is tray-only now, so the
// "Conversations" toggle is always visible (no `lg:hidden`) and
// the strip wrapper no longer carries `xl:hidden`. The sources
// trigger keeps `xl:hidden` because the sources rail is inline at
// xl+.
const html = render(<CoachDrawerBody {...baseProps} />);
const strip = html.match(
/<div[^>]*data-slot="coach-drawer-rail-tray-strip"[^>]*>/,
);
expect(strip?.[0]).toContain("xl:hidden");
expect(strip?.[0]).not.toContain("xl:hidden");
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(historyTrigger).not.toBeNull();
expect(historyTrigger?.[0]).toContain("lg:hidden");
expect(historyTrigger?.[0]).not.toContain("lg:hidden");
const sourcesTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-sources-tray-trigger"[^>]*>/,
);
expect(sourcesTrigger?.[0]).toContain("xl:hidden");
});
it("renders the desktop history rail (lg+) and sources rail (xl+)", () => {
it("no longer mounts an inline history column; sources rail stays inline at xl+", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(
/<aside[^>]*data-slot="coach-drawer-history"[^>]*lg:flex/,
);
expect(html).not.toContain('data-slot="coach-drawer-history"');
expect(html).toMatch(
/<aside[^>]*data-slot="coach-drawer-sources"[^>]*xl:flex/,
);
});
it("renders the message thread + composer slots", () => {
it("renders the message thread + composer slots and the disclaimer above the composer", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(/data-slot="coach-drawer-thread"/);
expect(html).toMatch(/data-slot="coach-drawer-composer"/);
// Slot content forwarded verbatim.
expect(html).toContain("thread");
expect(html).toContain("composer");
// The disclaimer slot renders inside the composer container, before
// the composer itself.
expect(html).toContain("disclaimer");
const composerBlock = html.match(
/data-slot="coach-drawer-composer"[^>]*>([\s\S]*?)<\/div>/,
);
expect(composerBlock?.[1]).toContain("disclaimer");
const disclaimerIdx = html.indexOf("disclaimer");
const composerIdx = html.lastIndexOf("composer");
expect(disclaimerIdx).toBeLessThan(composerIdx);
});
it("uses chevron-style trigger labels — History on the left, Sources on the right", () => {
@@ -381,13 +381,40 @@ describe("<MessageThread>", () => {
expect(html).toMatch(/<strong[^>]*>\s*96\s*<\/strong>/);
});
it("hides the disclosure entirely when keyValues is empty or absent", () => {
// baseConversation.messages[1].metricSource has no `keyValues`
// field — the source chips still render but the disclosure does
// not appear at all.
it("folds the source chips into the collapsed disclosure (no key-values)", () => {
// v1.12.0 — the provenance block is now a single collapsed
// disclosure. baseConversation.messages[1].metricSource carries
// metrics + windows but no `keyValues`, so the disclosure renders
// (collapsed, with the chips inside) but the key-value list does
// not. The chips no longer paint outside / above the disclosure.
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-evidence"');
expect(html).toContain('data-slot="coach-source-chips"');
expect(html).not.toContain('data-slot="coach-evidence-list"');
// Collapsed by default.
expect(html).not.toMatch(/<details[^>]*\bopen\b/);
// The chips render inside the `<details>` shell, not as a sibling
// above it.
const detailsIdx = html.indexOf('data-slot="coach-evidence"');
const chipsIdx = html.indexOf('data-slot="coach-source-chips"');
expect(detailsIdx).toBeGreaterThanOrEqual(0);
expect(chipsIdx).toBeGreaterThan(detailsIdx);
});
it("renders no disclosure when there is no provenance at all", () => {
const noProvenance: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
{
...baseConversation.messages[1],
metricSource: { windows: [], metrics: [] },
},
],
};
const html = render(<MessageThread conversation={noProvenance} />);
expect(html).not.toContain('data-slot="coach-evidence"');
expect(html).not.toContain("What I&#x27;m looking at");
expect(html).not.toContain('data-slot="coach-source-chips"');
});
it("renders the user bubble with the self-hosted avatar when one is set (v1.5.5)", () => {
@@ -68,14 +68,16 @@ describe("<MobileRailTray>", () => {
expect(html).toContain("sources-content");
});
it("hides the history tray on lg+ and the sources tray on xl+", () => {
// Matches the breakpoints documented inline: the history rail
// mounts inline at lg+, so the tray is `lg:hidden`. The sources
// rail mounts inline at xl+ only, so the tray is `xl:hidden`.
it("keeps the history tray on every breakpoint and hides the sources tray on xl+", () => {
// v1.12.0 — the history rail is tray-only now (no inline column on
// any viewport), so the history tray drops its `lg:hidden` cap and
// is available at every breakpoint. The sources rail still mounts
// inline at xl+, so its tray keeps `xl:hidden`.
const html = render(<MobileRailTray {...baseProps} />);
expect(html).toMatch(
/data-slot="coach-drawer-history-tray"[^>]*class="[^"]*lg:hidden/,
const historyTray = html.match(
/data-slot="coach-drawer-history-tray"[^>]*class="([^"]*)"/,
);
expect(historyTray?.[1]).not.toContain("lg:hidden");
expect(html).toMatch(
/data-slot="coach-drawer-sources-tray"[^>]*class="[^"]*xl:hidden/,
);
@@ -89,10 +89,12 @@ describe("<SourcesRail>", () => {
expect(html).toContain('data-source="medication"');
});
it("renders the medical disclaimer in the footer", () => {
it("no longer carries its own medical disclaimer footer", () => {
// v1.12.0 — the clinical-decisions disclaimer is consolidated to a
// single line above the composer; the rail used to duplicate it in
// its footer.
const html = render(<SourcesRail />);
expect(html).toContain('data-slot="coach-sources-disclaimer"');
expect(html).toContain("Clinical decisions belong with your doctor");
expect(html).not.toContain('data-slot="coach-sources-disclaimer"');
});
it("renders a toggle per cluster + the window selector trigger", () => {
@@ -1,6 +1,6 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronLeft, MessagesSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
@@ -19,21 +19,28 @@ import { useTranslations } from "@/lib/i18n/context";
* `sourcesTrayOpen` and passes the open-toggles + slot content. This
* keeps the trigger logic + the desktop rail layout in one place
* while delegating the actual side-sheet to the parent.
*
* v1.12.0 — the history rail is no longer an inline column on any
* viewport; it lives behind the always-visible "Conversations" toggle
* and opens as the left-edge tray. The body therefore no longer takes
* a `historyRail` slot — the parent wires that rail straight into the
* `<MobileRailTray>`.
*/
export interface CoachDrawerBodyProps {
historyRail: React.ReactNode;
sourcesRail: React.ReactNode;
thread: React.ReactNode;
composer: React.ReactNode;
/** Clinical-decisions disclaimer line, pinned above the composer. */
disclaimer: React.ReactNode;
onOpenHistoryTray: () => void;
onOpenSourcesTray: () => void;
}
export function CoachDrawerBody({
historyRail,
sourcesRail,
thread,
composer,
disclaimer,
onOpenHistoryTray,
onOpenSourcesTray,
}: CoachDrawerBodyProps) {
@@ -41,21 +48,17 @@ export function CoachDrawerBody({
return (
<div
data-slot="coach-drawer-body"
// v1.4.20 phase D reconcile — narrower drawer cap at lg means the
// sources rail no longer fits inline below xl. lg gets the
// history rail + thread (sources surface via the chevron tray);
// xl+ restores the full three-column layout.
className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[260px_1fr] xl:grid-cols-[260px_1fr_280px]"
// v1.12.0 — the conversation history is no longer a permanent
// left column. It used to mount inline on lg+ (a fixed 260px
// gutter) which ate the chat width on laptops and read as a
// takeover on the narrowed drawer cap. The rail now lives behind
// the "Conversations" toggle on EVERY viewport (opens as the
// left-edge tray), so the thread keeps the full width by default
// and the user summons history on demand. The sources rail stays
// inline at xl+ where there is room; below xl it stays on the
// chevron tray.
className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[1fr_280px]"
>
{/* History rail — desktop only. Mobile users summon it via the
tray trigger inside the thread column. */}
<aside
data-slot="coach-drawer-history"
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
>
{historyRail}
</aside>
{/* Centre — message thread. */}
<main
data-slot="coach-drawer-thread"
@@ -64,14 +67,17 @@ export function CoachDrawerBody({
{/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the
absolute overlay into a sub-header strip so the chevrons
sit at a 44 px tap target and never overlay the first
message bubble. Hidden on `>=xl` because both rails are
inline at that breakpoint. The history trigger remains
visible up to lg-1 (rail is inline at lg+); the sources
trigger remains visible up to xl-1 (rail is inline at
xl+). */}
message bubble.
v1.12.0 — the strip is now ALWAYS rendered because the
"Conversations" toggle is the only way to reach the history
rail on every viewport (the rail is no longer an inline
column). The history trigger stays visible at all
breakpoints; the sources trigger keeps `xl:hidden` since the
sources rail is inline at xl+. */}
<div
data-slot="coach-drawer-rail-tray-strip"
className="border-border/70 flex items-center justify-between gap-2 border-b px-3 py-2 xl:hidden"
className="border-border/70 flex items-center justify-between gap-2 border-b px-3 py-2"
>
<Button
type="button"
@@ -79,23 +85,20 @@ export function CoachDrawerBody({
onClick={onOpenHistoryTray}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="min-h-11 gap-1.5 text-xs lg:hidden"
className="min-h-11 gap-1.5 text-xs"
>
<ChevronRight className="size-4" aria-hidden="true" />
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
{/* Sources tray trigger stays visible up to xl since the
inline sources rail is xl+ only. The empty `<span>` to
the left keeps the right-aligned button placement when
the history trigger hides at lg+. */}
<span aria-hidden="true" className="hidden lg:block" />
inline sources rail is xl+ only. */}
<Button
type="button"
variant="ghost"
onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")}
className="min-h-11 gap-1.5 text-xs"
className="min-h-11 gap-1.5 text-xs xl:hidden"
>
{t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-4" aria-hidden="true" />
@@ -103,11 +106,15 @@ export function CoachDrawerBody({
</div>
<div className="min-h-0 flex-1">{thread}</div>
{/* Composer pinned to the bottom. */}
{/* Composer pinned to the bottom. v1.12.0 — the clinical-
decisions disclaimer renders directly above the composer
(the single, always-visible home; the thread-bottom and
sources-rail copies were removed). */}
<div
data-slot="coach-drawer-composer"
className="border-border/70 border-t p-3 sm:p-4"
className="border-border/70 flex flex-col gap-2 border-t p-3 sm:p-4"
>
{disclaimer}
{composer}
</div>
</main>
@@ -333,14 +333,6 @@ export function CoachDrawer({
harness can pin the mobile rail-tray triggers without
rendering the Sheet portal. */}
<CoachDrawerBody
historyRail={
historyRail ?? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => setCurrentConversationId(id)}
/>
)
}
sourcesRail={sourcesRail ?? <SourcesRail />}
thread={
<MessageThread
@@ -349,6 +341,14 @@ export function CoachDrawer({
optimisticUser={send.optimisticUser}
/>
}
disclaimer={
<p
data-slot="coach-composer-disclaimer"
className="text-muted-foreground text-xs leading-relaxed"
>
{t("insights.coach.composerDisclaimer")}
</p>
}
composer={
composer ?? (
<CoachInput
@@ -306,21 +306,6 @@ export function MessageThread({
/>
</div>
)}
{/* v1.4.22 W5 reconcile (Design-H3) — medical-disclaimer
reach regression. v1.4.22 B4 moved the disclaimer to the
sources-rail footer, but the rail is xl-only; on mobile
and laptop viewports the disclaimer was only reachable
via the chevron tray. Pin a small persistent line at the
bottom of the message thread so every Coach session
carries the disclaimer regardless of viewport. The rail
footer stays for desktop users — the redundancy is
intentional for clinical-adjacent UI. */}
<p
data-slot="coach-thread-disclaimer"
className="text-muted-foreground mt-auto pt-2 text-xs leading-relaxed"
>
{t("insights.coach.composerDisclaimer")}
</p>
</div>
);
}
@@ -427,6 +412,16 @@ function ChatBubble({
: null;
const keyValues = metricSource?.keyValues ?? [];
// v1.12.0 — the provenance disclosure surfaces whenever there is any
// grounding to show: the source chips (metrics/windows) and/or the
// raw key-values. `SourceChips` itself returns null when the envelope
// carries neither metric nor window, so we mirror that condition here
// to decide whether the `<details>` shell renders at all.
const hasChips =
!!metricSource &&
((metricSource.metrics?.length ?? 0) > 0 ||
(metricSource.windows?.length ?? 0) > 0);
const hasProvenance = hasChips || keyValues.length > 0;
return (
<div
@@ -467,8 +462,17 @@ function ChatBubble({
{safeError && (
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
)}
{metricSource && <SourceChips provenance={metricSource} />}
{keyValues.length > 0 && (
{/* v1.12.0 — collapse the whole provenance block ("what was
included") behind one disclosure, collapsed by default. The
source chips used to render fully expanded above the
evidence `<details>`, so the grounding context was often
taller than the answer itself. Folding the chips + the raw
key-values into a single closed disclosure keeps the reply
the focus and lets the user expand the grounding on demand —
and removes the always-on chip row that duplicated what the
disclosure already names. The disclosure surfaces whenever
there is any provenance to show (chips and/or key-values). */}
{hasProvenance && (
<details
data-slot="coach-evidence"
open={evidenceOpen}
@@ -507,36 +511,50 @@ function ChatBubble({
/>
<span>{t("insights.coach.evidenceLabel")}</span>
</summary>
<ul
<div
id={evidencePanelId}
data-slot="coach-evidence-list"
className="text-foreground mt-2 flex flex-col gap-1"
data-slot="coach-evidence-panel"
className="mt-2 flex flex-col gap-2"
>
{keyValues.map((kv, idx) => (
<li
key={`${kv.label}-${idx}`}
data-slot="coach-evidence-row"
className="leading-relaxed"
{/* v1.12.0 — the source chips now live inside the
disclosure so they expand with the rest of the
grounding instead of always painting above the
answer. */}
{hasChips && metricSource && (
<SourceChips provenance={metricSource} />
)}
{keyValues.length > 0 && (
<ul
data-slot="coach-evidence-list"
className="text-foreground flex flex-col gap-1"
>
{/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic")
was rendered prefixed to every row, repeating
framing the disclosure heading already gives.
Drop the label and lead with the value; the
window stays as a parenthetical tail so the row
still answers "over what timeframe?". */}
<strong className="font-semibold">
{kv.value}
{kv.unit ? ` ${kv.unit}` : ""}
</strong>
{kv.window && (
<span className="text-muted-foreground">
{" "}
({kv.window})
</span>
)}
</li>
))}
</ul>
{keyValues.map((kv, idx) => (
<li
key={`${kv.label}-${idx}`}
data-slot="coach-evidence-row"
className="leading-relaxed"
>
{/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic")
was rendered prefixed to every row, repeating
framing the disclosure heading already gives.
Drop the label and lead with the value; the
window stays as a parenthetical tail so the row
still answers "over what timeframe?". */}
<strong className="font-semibold">
{kv.value}
{kv.unit ? ` ${kv.unit}` : ""}
</strong>
{kv.window && (
<span className="text-muted-foreground">
{" "}
({kv.window})
</span>
)}
</li>
))}
</ul>
)}
</div>
</details>
)}
{/* v1.4.23 H7 — per-message thumbs feedback. Only persisted
@@ -50,13 +50,15 @@ export function MobileRailTray({
const { t } = useTranslations();
return (
<>
{/* History tray — slides in from the left edge. `lg:hidden`
because the history rail is inline on lg+. */}
{/* History tray — slides in from the left edge. v1.12.0 — the
history rail is no longer inline on any viewport (it used to
mount inline on lg+), so the tray is available at every
breakpoint and the `lg:hidden` cap is gone. */}
<Sheet open={historyOpen} onOpenChange={onHistoryOpenChange}>
<SheetContent
side="left"
data-slot="coach-drawer-history-tray"
className="w-[88vw] max-w-[320px] p-0 lg:hidden"
className="w-[88vw] max-w-[320px] p-0"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Activity, Eye, Loader2 } from "lucide-react";
import { Eye, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import {
@@ -195,21 +195,11 @@ export function SourcesRail({ className }: SourcesRailProps) {
);
})}
</ul>
{/* Medical disclaimer footer (relocated from the composer in
v1.4.22). */}
<div
data-slot="coach-sources-disclaimer"
className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3"
>
<Activity
aria-hidden="true"
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
/>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.composerDisclaimer")}
</p>
</div>
{/* v1.12.0 — the clinical-decisions disclaimer used to render
here AND at the bottom of the message thread. Both are now
consolidated into a single line directly above the composer
(always visible, every viewport), so the rail no longer
carries its own copy. */}
</div>
);
}