mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
c42c0a50cb
commit
195448ae19
7 files changed
+911
-2
No files matched your search
+35
-1
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user