diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx new file mode 100644 index 000000000..d6d05d8b8 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { CoachInput } from "../coach-input"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("mounts the textarea, mic, send button, and disclaimer slots", () => { + const html = render( + {}} onSubmit={() => {}} />, + ); + expect(html).toContain('data-slot="coach-input"'); + expect(html).toContain('data-slot="coach-input-textarea"'); + expect(html).toContain('data-slot="coach-input-mic"'); + expect(html).toContain('data-slot="coach-input-send"'); + expect(html).toContain('data-slot="coach-input-disclaimer"'); + expect(html).toContain("Coach replies are generated"); + }); + + it("renders the localised placeholder + hint", () => { + const html = render( + {}} onSubmit={() => {}} />, + ); + expect(html).toContain("Ask anything about your data"); + expect(html).toContain("Press Enter to send"); + }); + + it("renders the German placeholder + disclaimer when locale is 'de'", () => { + const html = render( + {}} onSubmit={() => {}} />, + "de", + ); + expect(html).toContain("Frag mich etwas zu deinen Daten"); + expect(html).toContain("Klinische Entscheidungen gehören in die Hand"); + }); + + it("disables the mic button with the v1.5 tooltip text", () => { + const html = render( + {}} onSubmit={() => {}} />, + ); + // The button carries both the disabled attribute and the + // localised v1.5 voice tooltip on aria-label. Attribute order is + // not guaranteed across React versions, so we check for both + // independently inside the same tag. + const micTag = html.match( + /]*data-slot="coach-input-mic"[^>]*>/, + ); + expect(micTag).not.toBeNull(); + expect(micTag?.[0]).toContain("disabled"); + expect(micTag?.[0]).toContain( + 'aria-label="Voice input arrives with the iOS app in v1.5."', + ); + }); + + it("disables the send button when the value is empty", () => { + const html = render( + {}} onSubmit={() => {}} />, + ); + const sendTag = html.match( + /]*data-slot="coach-input-send"[^>]*>/, + ); + expect(sendTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/); + }); + + it("disables the send button when value is whitespace-only", () => { + const html = render( + {}} onSubmit={() => {}} />, + ); + const sendTag = html.match( + /]*data-slot="coach-input-send"[^>]*>/, + ); + expect(sendTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/); + }); + + it("enables the send button when there is non-empty content", () => { + const html = render( + {}} + onSubmit={() => {}} + />, + ); + // Locate the send button tag and assert no boolean `disabled=""` + // attribute (Tailwind class names contain the word `disabled` in + // utilities like `disabled:opacity-50`, so we match the actual + // attribute form `disabled=""` that React emits for boolean + // attributes). + const sendTag = html.match( + /]*data-slot="coach-input-send"[^>]*>/, + ); + expect(sendTag).not.toBeNull(); + expect(sendTag?.[0]).not.toMatch(/\sdisabled(=""|\s|>)/); + }); + + it("disables the send button while streaming", () => { + const html = render( + {}} + onSubmit={() => {}} + disabled + isStreaming + />, + ); + const sendTag = html.match( + /]*data-slot="coach-input-send"[^>]*>/, + ); + expect(sendTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/); + // Spinner replaces the send icon in the streaming state. + expect(html).toContain("animate-spin"); + }); + + it("invokes onChange when the parent passes a controlled handler", () => { + // SSR can't fire DOM events; smoke-check the contract by calling + // the supplied handler directly. + const handler = vi.fn(); + render( + {}} />, + ); + handler("typed"); + expect(handler).toHaveBeenCalledWith("typed"); + }); + + it("renders the textarea with rows=2 to match the artboard", () => { + const html = render( + {}} onSubmit={() => {}} />, + ); + expect(html).toMatch( + /data-slot="coach-input-textarea"[^>]*rows="2"/, + ); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/history-rail.test.tsx b/src/components/insights/coach-panel/__tests__/history-rail.test.tsx new file mode 100644 index 000000000..3c099534c --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/history-rail.test.tsx @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { HistoryRail } from "../history-rail"; +import type { CoachConversationsPage } from "@/lib/ai/coach/types"; + +/** + * v1.4.20 phase B2b — history-rail render snapshot. + * + * The rail mounts a TanStack Query subscription so we have to wrap it + * in a ``. We seed the cache with a synthetic + * conversations page so the rail renders the populated state in one + * pass — server-side rendering doesn't drive a real network call. + */ + +function makeClientWithConversations(page: CoachConversationsPage) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(["coachConversations"], page); + return client; +} + +function render(node: React.ReactNode, client: QueryClient, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + {node} + , + ); +} + +const samplePage: CoachConversationsPage = { + conversations: [ + { + id: "c1", + title: "Why was BP higher on Monday?", + createdAt: "2026-05-10T08:00:00.000Z", + updatedAt: "2026-05-10T08:01:00.000Z", + messageCount: 2, + }, + { + id: "c2", + title: "Compare this week to last month", + createdAt: "2026-05-09T14:00:00.000Z", + updatedAt: "2026-05-09T14:05:00.000Z", + messageCount: 4, + }, + ], + nextCursor: null, +}; + +describe("", () => { + it("renders the rail wrapper + label", () => { + const client = makeClientWithConversations(samplePage); + const html = render( + {}} />, + client, + ); + expect(html).toContain('data-slot="coach-history-rail"'); + expect(html).toContain("Conversations"); + }); + + it("renders one row per conversation", () => { + const client = makeClientWithConversations(samplePage); + const html = render( + {}} />, + client, + ); + const rows = html.match(/data-slot="coach-history-item"/g) ?? []; + expect(rows.length).toBe(2); + expect(html).toContain("Why was BP higher on Monday?"); + expect(html).toContain("Compare this week to last month"); + }); + + it("flags the active row via data-active", () => { + const client = makeClientWithConversations(samplePage); + const html = render( + {}} />, + client, + ); + // c2 is active and gets the data-active flag. + expect(html).toMatch( + /data-slot="coach-history-item" data-active="true"/, + ); + }); + + it("renders the empty-state copy when no conversations are cached", () => { + const client = makeClientWithConversations({ + conversations: [], + nextCursor: null, + }); + const html = render( + {}} />, + client, + ); + expect(html).toContain('data-slot="coach-history-empty"'); + expect(html).toContain("Your conversations will appear here."); + }); + + it("mounts the search input with the localised placeholder", () => { + const client = makeClientWithConversations(samplePage); + const html = render( + {}} />, + client, + ); + expect(html).toMatch( + /data-slot="coach-history-search"[^>]*placeholder="Search conversations…"/, + ); + }); + + it("uses German strings when locale is 'de'", () => { + const client = makeClientWithConversations({ + conversations: [], + nextCursor: null, + }); + const html = render( + {}} />, + client, + "de", + ); + expect(html).toContain("Unterhaltungen"); + expect(html).toContain("Deine Unterhaltungen erscheinen hier"); + }); + + it("renders the delete button for each conversation row", () => { + const client = makeClientWithConversations(samplePage); + const html = render( + {}} />, + client, + ); + const buttons = html.match(/data-slot="coach-history-delete"/g) ?? []; + expect(buttons.length).toBe(2); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx b/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx new file mode 100644 index 000000000..522282074 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { SourcesRail } from "../sources-rail"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders the rail wrapper + label", () => { + const html = render(); + expect(html).toContain('data-slot="coach-sources-rail"'); + expect(html).toContain("What I can see"); + }); + + it("lists exactly five rows (BP, weight, pulse, mood, compliance)", () => { + const html = render(); + const rows = html.match(/data-slot="coach-sources-row"/g) ?? []; + expect(rows.length).toBe(5); + expect(html).toContain('data-source="bp"'); + expect(html).toContain('data-source="weight"'); + expect(html).toContain('data-source="pulse"'); + expect(html).toContain('data-source="mood"'); + expect(html).toContain('data-source="compliance"'); + }); + + it("renders the localised metric labels in English", () => { + const html = render(); + expect(html).toContain("Blood pressure"); + expect(html).toContain("Weight"); + expect(html).toContain("Pulse"); + expect(html).toContain("Mood"); + expect(html).toContain("Compliance"); + }); + + it("renders the German labels when locale is 'de'", () => { + const html = render(, "de"); + expect(html).toContain("Blutdruck"); + expect(html).toContain("Gewicht"); + expect(html).toContain("Einnahmetreue"); + }); + + it("renders the footer disclaimer", () => { + const html = render(); + expect(html).toContain( + "The Coach reads only the data you've connected.", + ); + }); +}); diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 0e41e91aa..f79824d8a 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -15,7 +15,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; +import { CoachInput } from "./coach-input"; +import { HistoryRail } from "./history-rail"; import { MessageThread } from "./message-thread"; +import { SourcesRail } from "./sources-rail"; import { useCoachConversation, useSendCoachMessage, @@ -193,7 +196,12 @@ export function CoachDrawer({ data-slot="coach-drawer-history" className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col" > - {historyRail ??
} + {historyRail ?? ( + setCurrentConversationId(id)} + /> + )} {/* Centre — message thread. */} @@ -213,11 +221,12 @@ export function CoachDrawer({ className="border-border/70 border-t p-3 sm:p-4" > {composer ?? ( - handleSubmit(inputValue)} disabled={send.isStreaming} + isStreaming={send.isStreaming} /> )}
@@ -228,7 +237,7 @@ export function CoachDrawer({ data-slot="coach-drawer-sources" className="border-border/70 hidden h-full min-h-0 border-l lg:flex lg:flex-col" > - {sourcesRail ??
} + {sourcesRail ?? }
@@ -236,46 +245,3 @@ export function CoachDrawer({ ); } -/** - * 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/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx new file mode 100644 index 000000000..205c116af --- /dev/null +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { type FormEvent, type KeyboardEvent, useCallback } from "react"; +import { Loader2, Mic, Send } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.4.20 phase B2b — Coach composer. + * + * `