diff --git a/messages/de.json b/messages/de.json index 2742e80a0..3062b603a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index bfd89efc9..6c10f55bf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx new file mode 100644 index 000000000..1b45bb9cf --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -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( + {node}, + ); +} + +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("", () => { + it("shows the empty hint when no conversation and no streaming", () => { + const html = render(); + 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(); + 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(); + 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( + , + ); + // 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( + , + ); + 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( + , + ); + expect(html).toContain("Thinking"); + }); + + it("surfaces the provider error copy when an error frame fired", () => { + const html = render( + , + ); + expect(html).toContain("could not reach an AI provider"); + }); + + it("uses German empty hint when locale is 'de'", () => { + const html = render(, "de"); + expect(html).toContain( + "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten", + ); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/source-chips.test.tsx b/src/components/insights/coach-panel/__tests__/source-chips.test.tsx new file mode 100644 index 000000000..de152a643 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/source-chips.test.tsx @@ -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( + {node}, + ); +} + +describe("", () => { + it("renders nothing when provenance is null", () => { + const html = render(); + expect(html).not.toContain('data-slot="coach-source-chips"'); + }); + + it("renders nothing when both windows + metrics are empty", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="coach-source-chips"'); + }); + + it("renders one chip per metric, paired with the primary window", () => { + const html = render( + , + ); + 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( + , + ); + expect(html).toContain("n=7"); + }); + + it("does not render n=0 chips for zero counts", () => { + const html = render( + , + ); + expect(html).not.toContain("n=0"); + }); + + it("uses German labels when locale is 'de'", () => { + const html = render( + , + "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( + , + ); + expect(html).toContain('data-metric="general"'); + expect(html).toContain("General"); + expect(html).not.toContain("last 30 days"); + }); +}); diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx new file mode 100644 index 000000000..0e41e91aa --- /dev/null +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -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 `` overlay). + * + * Mounts above `/insights` so the user keeps the dashboard context + * behind it. Single-column on ` 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 `` 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(() => 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 ( + + + {/* Header (full width). Title + new-chat button + settings. */} + + +
+ + {drawerTitle} + + + {t("insights.coach.tagline")} + +
+ + + + + + + + {t("insights.coach.settingsTooltip")} + + + +
+ + {/* Body — three columns on lg+, single column on smaller. */} +
+ {/* History rail — desktop only. Mobile collapses; v1.4.21 will + ship a tray for it. */} + + + {/* Centre — message thread. */} +
+
+ +
+ {/* Composer pinned to the bottom. */} +
+ {composer ?? ( + handleSubmit(inputValue)} + disabled={send.isStreaming} + /> + )} +
+
+ + {/* Sources rail — desktop only. */} + +
+
+
+ ); +} + +/** + * Minimal composer used until commit 3 wires the full ``. + * 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 ( +
{ + e.preventDefault(); + onSubmit(); + }} + className="flex gap-2" + > + 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", + )} + /> + +
+ ); +} diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx new file mode 100644 index 000000000..3a3805573 --- /dev/null +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -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(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 ( +
+ +

+ {emptyHint ?? t("insights.coach.threadEmpty")} +

+
+ ); + } + + return ( +
+ {messages.map((m) => ( + + ))} + {streamingActive && streaming && ( + + )} +
+ ); +} + +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 ( +
+
+ {content} +
+ +
+ ); + } + + 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 ( +
+ +
+
+ {content || (inProgress ? t("insights.coach.thinking") : "")} +
+ {safeError && ( +

{safeError}

+ )} + {metricSource && } +
+
+ ); +} diff --git a/src/components/insights/coach-panel/source-chips.tsx b/src/components/insights/coach-panel/source-chips.tsx new file mode 100644 index 000000000..516e1dd66 --- /dev/null +++ b/src/components/insights/coach-panel/source-chips.tsx @@ -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 = { + 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 = { + 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 ( +
+ {chips.map((chip) => ( + + + ))} +
+ ); +}