feat(coach): drawer shell, message thread, source chips

Lays the visual scaffold for the AI Coach drawer (B2b):

- `<CoachDrawer>` wraps `<Sheet side="right">` and exposes the
  three-column layout from the artboard (history rail · message
  thread · sources rail) on `lg+`. Mobile (<lg) collapses to a
  single column with the message thread in focus and the rails
  hidden — they get reachable via tray menus in v1.4.21.
  History / sources / composer are slot props so the parent wires
  them in commit 3 without coupling them to the shell.
- `<MessageThread>` renders user / assistant bubbles, supports an
  in-flight streaming bubble fed by `useSendCoachMessage()`, and
  preserves a "user-pinned-to-bottom" auto-scroll heuristic so
  reading earlier turns doesn't yank the viewport when a new token
  lands.
- `<SourceChips>` renders the labels-only provenance envelope —
  metric + window + n-count, no raw values — matching the wire
  contract from B2a.
- i18n keys land under `insights.coach.*` for tagline, new chat,
  composer placeholder, history rail, sources rail, metric labels,
  and window labels. Both locales updated.

Drawer state (open / prefill) is consumed via a controlled prop +
lazy `useState` initialiser; the close path resets thread + composer
in the `onOpenChange` handler so we don't trip
`react-hooks/set-state-in-effect`.

Tests cover: source-chip render permutations (8), message-thread
empty / persisted / streaming / error states (8), provenance
locale-aware labels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 16:25:34 +02:00
1 parent c42c0a50cb
commit 195448ae19
7 files changed
+911 -2

No files matched your search

+35 -1
View File
@@ -780,7 +780,41 @@
"refusalOutOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.",
"refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.",
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
"errorProvider": "Der Coach konnte gerade keinen KI-Anbieter erreichen. Bitte versuche es in einem Moment erneut."
"errorProvider": "Der Coach konnte gerade keinen KI-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
"newChat": "Neuer Chat",
"settings": "Coach-Einstellungen",
"settingsTooltip": "Coach-Einstellungen kommen mit v1.4.21.",
"send": "Senden",
"thinking": "Denke nach…",
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
"composerHint": "Enter zum Senden, Umschalt+Enter für eine neue Zeile.",
"composerDisclaimer": "Coach-Antworten sind generiert. Klinische Entscheidungen gehören in die Hand deines Arztes.",
"voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.",
"threadEmpty": "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten, um zu starten.",
"historyTitle": "Unterhaltungen",
"historySearchPlaceholder": "Unterhaltungen durchsuchen…",
"historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.",
"historyDeleteAria": "Unterhaltung löschen",
"historyDeleteConfirm": "Diese Unterhaltung löschen? Das lässt sich nicht rückgängig machen.",
"sourcesTitle": "Worauf ich zugreife",
"sourcesFresh": "Aktuell",
"sourcesStale": "Veraltet",
"sourcesFooter": "Der Coach liest nur Daten, die du verbunden hast.",
"metric": {
"bp": "Blutdruck",
"weight": "Gewicht",
"pulse": "Puls",
"mood": "Stimmung",
"compliance": "Einnahmetreue",
"general": "Allgemein"
},
"window": {
"last7days": "letzte 7 Tage",
"last30days": "letzte 30 Tage",
"last90days": "letzte 90 Tage",
"allTime": "gesamter Zeitraum"
}
},
"viewAll": "Alle ansehen",
"previewEmpty": "Noch keine Empfehlungen.",
+35 -1
View File
@@ -780,7 +780,41 @@
"refusalOutOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.",
"refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.",
"errorProvider": "The Coach could not reach an AI provider just now. Please try again in a moment."
"errorProvider": "The Coach could not reach an AI provider just now. Please try again in a moment.",
"tagline": "Personal health coach, grounded in your data.",
"newChat": "New chat",
"settings": "Coach settings",
"settingsTooltip": "Coach settings arrive in v1.4.21.",
"send": "Send",
"thinking": "Thinking…",
"composerPlaceholder": "Ask anything about your data…",
"composerHint": "Press Enter to send, Shift+Enter for a new line.",
"composerDisclaimer": "Coach replies are generated. Clinical decisions belong with your doctor.",
"voiceComingSoon": "Voice input arrives with the iOS app in v1.5.",
"threadEmpty": "Ask anything about your trends, medications, or readings to start a conversation.",
"historyTitle": "Conversations",
"historySearchPlaceholder": "Search conversations…",
"historyEmpty": "Your conversations will appear here. Ask anything to start.",
"historyDeleteAria": "Delete conversation",
"historyDeleteConfirm": "Delete this conversation? This cannot be undone.",
"sourcesTitle": "What I can see",
"sourcesFresh": "Fresh",
"sourcesStale": "Stale",
"sourcesFooter": "The Coach reads only the data you've connected.",
"metric": {
"bp": "Blood pressure",
"weight": "Weight",
"pulse": "Pulse",
"mood": "Mood",
"compliance": "Compliance",
"general": "General"
},
"window": {
"last7days": "last 7 days",
"last30days": "last 30 days",
"last90days": "last 90 days",
"allTime": "all time"
}
},
"viewAll": "View all",
"previewEmpty": "No recommendations yet.",
@@ -0,0 +1,153 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { MessageThread } from "../message-thread";
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const baseConversation: CoachConversationDetailDTO = {
id: "conv-1",
title: "Why was BP higher on Monday?",
createdAt: "2026-05-10T09:00:00.000Z",
updatedAt: "2026-05-10T09:01:00.000Z",
messageCount: 2,
messages: [
{
id: "m1",
role: "user",
content: "Why was BP higher on Monday?",
createdAt: "2026-05-10T09:00:00.000Z",
metricSource: null,
providerType: null,
promptVersion: null,
},
{
id: "m2",
role: "assistant",
content: "Looking at your last 6 Mondays, the morning systolic averaged …",
createdAt: "2026-05-10T09:00:30.000Z",
metricSource: {
windows: ["last30days"],
metrics: ["bp"],
counts: { bp: 26 },
},
providerType: "admin-openai",
promptVersion: "4.20.0",
},
],
};
describe("<MessageThread>", () => {
it("shows the empty hint when no conversation and no streaming", () => {
const html = render(<MessageThread conversation={null} />);
expect(html).toContain('data-slot="coach-message-thread"');
expect(html).toContain(
"Ask anything about your trends, medications, or readings",
);
});
it("renders user + assistant bubbles for a persisted conversation", () => {
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-bubble-user"');
expect(html).toContain('data-slot="coach-bubble-assistant"');
expect(html).toContain("Why was BP higher on Monday?");
expect(html).toContain(
"Looking at your last 6 Mondays, the morning systolic averaged",
);
});
it("renders source chips below an assistant bubble that carries provenance", () => {
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-source-chips"');
expect(html).toContain('data-metric="bp"');
expect(html).toContain("last 30 days");
expect(html).toContain("n=26");
});
it("renders an in-flight streaming bubble alongside persisted history", () => {
const html = render(
<MessageThread
conversation={baseConversation}
streaming={{
content: "Looking at your data ",
metricSource: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
/>,
);
// 1 persisted user + 1 persisted assistant + 1 streaming assistant.
const userBubbles = (
html.match(/data-slot="coach-bubble-user"/g) ?? []
).length;
const assistantBubbles = (
html.match(/data-slot="coach-bubble-assistant"/g) ?? []
).length;
expect(userBubbles).toBe(1);
expect(assistantBubbles).toBe(2);
expect(html).toContain("Looking at your data");
});
it("shows the streaming-only bubble when no conversation is loaded yet", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
content: "Drafting…",
metricSource: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
/>,
);
expect(html).toContain("Drafting…");
expect(html).toContain('data-slot="coach-bubble-assistant"');
});
it("renders the 'thinking' placeholder when content is empty but inProgress", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
content: "",
metricSource: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
/>,
);
expect(html).toContain("Thinking");
});
it("surfaces the provider error copy when an error frame fired", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: "errorProvider",
}}
/>,
);
expect(html).toContain("could not reach an AI provider");
});
it("uses German empty hint when locale is 'de'", () => {
const html = render(<MessageThread conversation={null} />, "de");
expect(html).toContain(
"Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten",
);
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SourceChips } from "../source-chips";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<SourceChips>", () => {
it("renders nothing when provenance is null", () => {
const html = render(<SourceChips provenance={null} />);
expect(html).not.toContain('data-slot="coach-source-chips"');
});
it("renders nothing when both windows + metrics are empty", () => {
const html = render(
<SourceChips provenance={{ windows: [], metrics: [] }} />,
);
expect(html).not.toContain('data-slot="coach-source-chips"');
});
it("renders one chip per metric, paired with the primary window", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last30days"],
metrics: ["bp", "pulse"],
counts: { bp: 12, pulse: 14 },
}}
/>,
);
expect(html).toContain('data-slot="coach-source-chips"');
const matches = html.match(/data-slot="coach-source-chip"/g) ?? [];
expect(matches.length).toBe(2);
expect(html).toContain('data-metric="bp"');
expect(html).toContain('data-metric="pulse"');
expect(html).toContain("Blood pressure");
expect(html).toContain("Pulse");
expect(html).toContain("last 30 days");
});
it("renders the sample-count suffix when counts are supplied", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last7days"],
metrics: ["bp"],
counts: { bp: 7 },
}}
/>,
);
expect(html).toContain("n=7");
});
it("does not render n=0 chips for zero counts", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last7days"],
metrics: ["bp"],
counts: { bp: 0 },
}}
/>,
);
expect(html).not.toContain("n=0");
});
it("uses German labels when locale is 'de'", () => {
const html = render(
<SourceChips
provenance={{ windows: ["last30days"], metrics: ["bp"] }}
/>,
"de",
);
expect(html).toContain("Blutdruck");
expect(html).toContain("letzte 30 Tage");
});
it("renders a metric chip without window when windows are empty", () => {
const html = render(
<SourceChips
provenance={{ windows: [], metrics: ["general"] }}
/>,
);
expect(html).toContain('data-metric="general"');
expect(html).toContain("General");
expect(html).not.toContain("last 30 days");
});
});
@@ -0,0 +1,281 @@
"use client";
import { useCallback, useState } from "react";
import { Plus, Settings2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { MessageThread } from "./message-thread";
import {
useCoachConversation,
useSendCoachMessage,
} from "./use-coach";
/**
* v1.4.20 phase B2b — AI Coach drawer (right-side `<Sheet>` overlay).
*
* Mounts above `/insights` so the user keeps the dashboard context
* behind it. Single-column on `<lg`; on desktop the history rail and
* sources rail mount alongside the message thread (delivered in
* commit 3, this file lays out the slots).
*
* The drawer is fully-controlled by the parent (`open` /
* `onOpenChange`). The `prefill` prop lets the hero strip's
* suggested-prompt chips pre-populate the input on open.
*
* History rail + sources rail import-load asynchronously to keep the
* drawer's first-paint cheap. Until commit 3 lands, those slots
* render as visual placeholders that match the column widths.
*/
export interface CoachDrawerProps {
open: boolean;
onOpenChange: (next: boolean) => void;
/** Optional pre-fill for the input box (suggested-prompt chip click). */
prefill?: string | null;
/**
* Optional rail slots — passed by the parent so commit-3 can mount
* the history + sources rails without coupling them to the drawer
* shell. Falls through to a no-op when omitted.
*/
historyRail?: React.ReactNode;
sourcesRail?: React.ReactNode;
/**
* Composer slot — the parent wires `<CoachInput>` here in commit 3
* so this shell stays presentational. The shell renders its own
* minimal input fallback when no slot is supplied (used during the
* commit-2 boundary to keep the drawer functional in isolation).
*/
composer?: React.ReactNode;
}
export function CoachDrawer({
open,
onOpenChange,
prefill,
historyRail,
sourcesRail,
composer,
}: CoachDrawerProps) {
const { t } = useTranslations();
const [currentConversationId, setCurrentConversationId] = useState<
string | null
>(null);
// The composer's input value is seeded from `prefill` whenever the
// parent toggles `open` — we mount the drawer with a key derived from
// (open, prefill) so the lazy initialiser fires fresh on each
// open/prefill transition. That sidesteps `setState`-in-`useEffect`
// entirely (banned by `react-hooks/set-state-in-effect`).
const [inputValue, setInputValue] = useState<string>(() => prefill ?? "");
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
// Reset thread + composer on close so the next open starts on
// the rail's empty hint instead of re-rendering the previous
// conversation by accident.
setCurrentConversationId(null);
setInputValue("");
}
onOpenChange(next);
},
[onOpenChange],
);
const { data: conversation } = useCoachConversation(currentConversationId);
const send = useSendCoachMessage({
onDone: (resolvedId) => {
setCurrentConversationId(resolvedId);
},
});
async function handleSubmit(value: string) {
const trimmed = value.trim();
if (!trimmed || send.isStreaming) return;
setInputValue("");
await send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
});
}
function handleNewChat() {
setCurrentConversationId(null);
setInputValue("");
send.reset();
}
const drawerTitle = conversation?.title ?? t("insights.coach.newChat");
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
side="right"
showCloseButton
data-slot="coach-drawer"
className={cn(
"w-full p-0 sm:max-w-[720px] lg:max-w-[1080px]",
"flex h-[100dvh] flex-col gap-0",
)}
>
{/* Header (full width). Title + new-chat button + settings. */}
<SheetHeader
data-slot="coach-drawer-header"
className="border-border/70 flex-row items-center gap-3 border-b p-3 sm:p-4"
>
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-4" />
</div>
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-sm font-semibold">
{drawerTitle}
</SheetTitle>
<SheetDescription className="text-muted-foreground truncate text-[11px]">
{t("insights.coach.tagline")}
</SheetDescription>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleNewChat}
data-slot="coach-drawer-new-chat"
className="gap-1.5"
>
<Plus className="size-3.5" aria-hidden="true" />
<span className="hidden sm:inline">
{t("insights.coach.newChat")}
</span>
</Button>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled
aria-label={t("insights.coach.settings")}
data-slot="coach-drawer-settings"
className="size-8"
>
<Settings2 className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("insights.coach.settingsTooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SheetHeader>
{/* Body — three columns on lg+, single column on smaller. */}
<div
data-slot="coach-drawer-body"
className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[260px_1fr_280px]"
>
{/* History rail — desktop only. Mobile collapses; v1.4.21 will
ship a tray for it. */}
<aside
data-slot="coach-drawer-history"
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
>
{historyRail ?? <div className="p-3" />}
</aside>
{/* Centre — message thread. */}
<main
data-slot="coach-drawer-thread"
className="flex h-full min-h-0 flex-col"
>
<div className="min-h-0 flex-1">
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
/>
</div>
{/* Composer pinned to the bottom. */}
<div
data-slot="coach-drawer-composer"
className="border-border/70 border-t p-3 sm:p-4"
>
{composer ?? (
<FallbackComposer
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
disabled={send.isStreaming}
/>
)}
</div>
</main>
{/* Sources rail — desktop only. */}
<aside
data-slot="coach-drawer-sources"
className="border-border/70 hidden h-full min-h-0 border-l lg:flex lg:flex-col"
>
{sourcesRail ?? <div className="p-3" />}
</aside>
</div>
</SheetContent>
</Sheet>
);
}
/**
* Minimal composer used until commit 3 wires the full `<CoachInput>`.
* Kept here so the drawer is functional in isolation (storybook /
* narrow-scope tests) without depending on the richer composer.
*/
function FallbackComposer({
value,
onChange,
onSubmit,
disabled,
}: {
value: string;
onChange: (next: string) => void;
onSubmit: () => void;
disabled?: boolean;
}) {
const { t } = useTranslations();
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="flex gap-2"
>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t("insights.coach.composerPlaceholder")}
disabled={disabled}
className={cn(
"border-border/60 bg-muted/40 text-foreground flex-1 rounded-md border",
"px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-dracula-purple/50 disabled:opacity-60",
)}
/>
<Button type="submit" disabled={disabled || !value.trim()} size="sm">
{t("insights.coach.send")}
</Button>
</form>
);
}
@@ -0,0 +1,218 @@
"use client";
import { useEffect, useRef } from "react";
import { Bot, Sparkles, User } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { SourceChips } from "./source-chips";
import type {
CoachConversationDetailDTO,
CoachStreamingMessage,
} from "./use-coach";
import type { CoachMessageDTO } from "@/lib/ai/coach/types";
/**
* v1.4.20 phase B2b — message-thread renderer.
*
* Mounted inside the centre column of the Coach drawer. Renders the
* persisted message history (decrypted server-side and delivered as
* `CoachMessageDTO[]`) + an optional in-flight assistant bubble fed
* by the streaming hook.
*
* Auto-scroll behaviour: scrolls to the bottom whenever the message
* list grows OR the streaming-content length changes. The user can
* scroll up to read history; we suppress auto-scroll until the next
* "new message" tick if they're not already pinned to the bottom.
*
* Visual identity: user bubbles right-aligned, Dracula purple accent;
* assistant bubbles left-aligned with the gradient sparkle avatar
* from the artboard.
*/
export interface MessageThreadProps {
conversation: CoachConversationDetailDTO | null;
/** Optional in-flight bubble from `useSendCoachMessage()`. */
streaming?: CoachStreamingMessage;
/** Empty-state copy when no conversation is loaded yet. */
emptyHint?: string;
}
function isPinnedToBottom(el: HTMLElement, slack = 64): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= slack;
}
export function MessageThread({
conversation,
streaming,
emptyHint,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef<HTMLDivElement | null>(null);
const wasPinnedRef = useRef(true);
const messages: CoachMessageDTO[] = conversation?.messages ?? [];
const streamingActive =
!!streaming?.inProgress || !!streaming?.content || !!streaming?.errorCode;
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const onScroll = () => {
wasPinnedRef.current = isPinnedToBottom(el);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
// Auto-scroll on new messages OR streaming-content growth, but only
// when the user was already 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]);
if (messages.length === 0 && !streamingActive) {
return (
<div
data-slot="coach-message-thread"
className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 px-6 text-center"
>
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink flex size-12 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-5" />
</div>
<p className="max-w-sm text-sm leading-relaxed">
{emptyHint ?? t("insights.coach.threadEmpty")}
</p>
</div>
);
}
return (
<div
ref={scrollerRef}
data-slot="coach-message-thread"
className={cn(
"flex h-full flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6",
"scroll-smooth",
)}
>
{messages.map((m) => (
<ChatBubble
key={m.id}
role={m.role}
content={m.content}
metricSource={m.metricSource}
providerType={m.providerType}
/>
))}
{streamingActive && streaming && (
<ChatBubble
role="assistant"
content={streaming.content}
metricSource={streaming.metricSource}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
/>
)}
</div>
);
}
interface ChatBubbleProps {
role: "user" | "assistant";
content: string;
metricSource?: import("@/lib/ai/coach/types").CoachProvenance | null;
providerType?: string | null;
inProgress?: boolean;
errorCode?: string | null;
}
function ChatBubble({
role,
content,
metricSource,
providerType,
inProgress,
errorCode,
}: ChatBubbleProps) {
const { t } = useTranslations();
if (role === "user") {
return (
<div
data-slot="coach-bubble-user"
className="flex items-start justify-end gap-2"
>
<div
className={cn(
"border-dracula-purple/30 bg-dracula-purple/12 text-foreground",
"max-w-[80%] rounded-xl rounded-tr-sm border px-3.5 py-2.5",
"text-sm leading-relaxed",
)}
>
{content}
</div>
<div
aria-hidden="true"
className="text-muted-foreground bg-muted/60 mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full"
>
<User className="size-3.5" />
</div>
</div>
);
}
const errorMessage = errorCode ? t(`insights.coach.${errorCode}`, {}) : null;
// When a translated message comes back unchanged (i.e. key missing) we
// fall back to a generic provider error string so the bubble doesn't
// surface raw `coach.http.503` text to the user.
const safeError =
errorMessage && errorMessage !== `insights.coach.${errorCode}`
? errorMessage
: errorCode
? t("insights.coach.errorProvider")
: null;
return (
<div
data-slot="coach-bubble-assistant"
className="flex items-start gap-2.5"
>
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
{providerType === "refusal" ? (
<Bot className="text-background size-3.5" />
) : (
<Sparkles className="text-background size-3.5" />
)}
</div>
<div className="flex max-w-[80%] flex-col gap-2">
<div
className={cn(
"border-border/60 bg-muted/40 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed whitespace-pre-wrap",
inProgress && "animate-pulse motion-reduce:animate-none",
)}
>
{content || (inProgress ? t("insights.coach.thinking") : "")}
</div>
{safeError && (
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
)}
{metricSource && <SourceChips provenance={metricSource} />}
</div>
</div>
);
}
@@ -0,0 +1,96 @@
"use client";
import { Link2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { CoachProvenance } from "@/lib/ai/coach/types";
/**
* v1.4.20 phase B2b — provenance chip row.
*
* Renders the labels-only `metricSourceJson` envelope that the backend
* attaches to every assistant message. We never render raw values here
* — only metric + window labels + sample counts so that the row
* remains queryable / analytics-safe and keeps PII out of any future
* server-side log capture.
*
* The chip click is a no-op for v1.4.20; a chart deeplink will land in
* B3 once the correlation row knows which window to focus.
*/
export interface SourceChipsProps {
provenance: CoachProvenance | null;
className?: string;
}
const METRIC_KEYS: Record<CoachProvenance["metrics"][number], string> = {
bp: "insights.coach.metric.bp",
weight: "insights.coach.metric.weight",
pulse: "insights.coach.metric.pulse",
mood: "insights.coach.metric.mood",
compliance: "insights.coach.metric.compliance",
general: "insights.coach.metric.general",
};
const WINDOW_KEYS: Record<CoachProvenance["windows"][number], string> = {
last7days: "insights.coach.window.last7days",
last30days: "insights.coach.window.last30days",
last90days: "insights.coach.window.last90days",
allTime: "insights.coach.window.allTime",
};
export function SourceChips({ provenance, className }: SourceChipsProps) {
const { t } = useTranslations();
if (!provenance) return null;
const { metrics, windows, counts } = provenance;
if (metrics.length === 0 && windows.length === 0) return null;
// We pair each metric with its first window (the assistant only ever
// surfaces a single window-per-metric in v1.4.20). When the provenance
// envelope has no windows (e.g. refusal path with `windows: []`), we
// still render the metric as a standalone chip so the user gets the
// "general" tag visibly.
const primaryWindowKey = windows[0] ?? null;
const chips = metrics.map((metric) => {
const metricLabel = t(METRIC_KEYS[metric]);
const windowLabel = primaryWindowKey ? t(WINDOW_KEYS[primaryWindowKey]) : null;
const count = counts && metric in counts ? counts[metric as keyof typeof counts] : undefined;
return {
key: `${metric}-${primaryWindowKey ?? "none"}`,
metric,
metricLabel,
windowLabel,
count,
};
});
return (
<div
data-slot="coach-source-chips"
className={cn("flex flex-wrap gap-1.5", className)}
>
{chips.map((chip) => (
<span
key={chip.key}
data-slot="coach-source-chip"
data-metric={chip.metric}
className={cn(
"border-dracula-cyan/25 text-dracula-cyan/90",
"inline-flex items-center gap-1 rounded-full border bg-transparent",
"px-2 py-0.5 text-[11px] leading-none",
)}
>
<Link2 className="h-2.5 w-2.5" aria-hidden="true" />
<span className="font-medium">{chip.metricLabel}</span>
{chip.windowLabel && (
<span className="opacity-75">· {chip.windowLabel}</span>
)}
{typeof chip.count === "number" && chip.count > 0 && (
<span className="opacity-60">· n={chip.count}</span>
)}
</span>
))}
</div>
);
}