mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
7ab2cbaa56
commit
9e6865841d
11 files changed
+265
-140
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user