mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(coach): composer, history rail, sources rail
Fills the three drawer slots from commit 2 with their full
implementations:
- `<CoachInput>` is a Dracula-styled `<textarea>` card. Plain Enter
and Cmd/Ctrl+Enter both submit; Shift+Enter inserts a newline.
The mic button is disabled with an "Voice input arrives with the
iOS app in v1.5" tooltip — the natural shipper of voice is the
native client. A footer disclaimer ("Coach replies are
generated. Clinical decisions belong with your doctor.") sits
beneath the box.
- `<HistoryRail>` reads from `useCoachConversations()`, exposes a
substring filter, marks the active conversation, and offers a
hover-revealed delete affordance with a confirm-then-go flow
driven by `useDeleteCoachConversation()`.
- `<SourcesRail>` lists the five contracts the snapshot helper
surfaces (BP, weight, pulse, mood, compliance) with a static
fresh-dot indicator. A v1.4.21 follow-up swaps in
`<IntegrationStatusPill>` once the rail decides whether to lean
on `/api/auth/me` or a dedicated `/api/insights/data-sources`
endpoint.
- `<CoachDrawer>` mounts these as the defaults so callers only
need to pass open + onOpenChange + prefill; the slot props stay
as escape hatches for tests.
Tests added: composer keyboard / disabled / streaming states (10),
sources-rail render + locale (5), history-rail render + locale +
empty / active states (7).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
195448ae19
commit
8dac3891b8
7 files changed
+810
-46
No files matched your search
@@ -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(
|
||||||
|
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("<CoachInput>", () => {
|
||||||
|
it("mounts the textarea, mic, send button, and disclaimer slots", () => {
|
||||||
|
const html = render(
|
||||||
|
<CoachInput value="" onChange={() => {}} 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(
|
||||||
|
<CoachInput value="" onChange={() => {}} 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(
|
||||||
|
<CoachInput value="" onChange={() => {}} 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(
|
||||||
|
<CoachInput value="" onChange={() => {}} 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(
|
||||||
|
/<button[^>]*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(
|
||||||
|
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
|
||||||
|
);
|
||||||
|
const sendTag = html.match(
|
||||||
|
/<button[^>]*data-slot="coach-input-send"[^>]*>/,
|
||||||
|
);
|
||||||
|
expect(sendTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the send button when value is whitespace-only", () => {
|
||||||
|
const html = render(
|
||||||
|
<CoachInput value=" " onChange={() => {}} onSubmit={() => {}} />,
|
||||||
|
);
|
||||||
|
const sendTag = html.match(
|
||||||
|
/<button[^>]*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(
|
||||||
|
<CoachInput
|
||||||
|
value="Why was BP higher on Monday?"
|
||||||
|
onChange={() => {}}
|
||||||
|
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(
|
||||||
|
/<button[^>]*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(
|
||||||
|
<CoachInput
|
||||||
|
value="Hello"
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
disabled
|
||||||
|
isStreaming
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const sendTag = html.match(
|
||||||
|
/<button[^>]*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(
|
||||||
|
<CoachInput value="" onChange={handler} onSubmit={() => {}} />,
|
||||||
|
);
|
||||||
|
handler("typed");
|
||||||
|
expect(handler).toHaveBeenCalledWith("typed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the textarea with rows=2 to match the artboard", () => {
|
||||||
|
const html = render(
|
||||||
|
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(html).toMatch(
|
||||||
|
/data-slot="coach-input-textarea"[^>]*rows="2"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 `<QueryClientProvider>`. 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(
|
||||||
|
<I18nProvider initialLocale={locale}>
|
||||||
|
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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("<HistoryRail>", () => {
|
||||||
|
it("renders the rail wrapper + label", () => {
|
||||||
|
const client = makeClientWithConversations(samplePage);
|
||||||
|
const html = render(
|
||||||
|
<HistoryRail activeId={null} onSelect={() => {}} />,
|
||||||
|
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(
|
||||||
|
<HistoryRail activeId={null} onSelect={() => {}} />,
|
||||||
|
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(
|
||||||
|
<HistoryRail activeId="c2" onSelect={() => {}} />,
|
||||||
|
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(
|
||||||
|
<HistoryRail activeId={null} onSelect={() => {}} />,
|
||||||
|
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(
|
||||||
|
<HistoryRail activeId={null} onSelect={() => {}} />,
|
||||||
|
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(
|
||||||
|
<HistoryRail activeId={null} onSelect={() => {}} />,
|
||||||
|
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(
|
||||||
|
<HistoryRail activeId={null} onSelect={() => {}} />,
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
const buttons = html.match(/data-slot="coach-history-delete"/g) ?? [];
|
||||||
|
expect(buttons.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("<SourcesRail>", () => {
|
||||||
|
it("renders the rail wrapper + label", () => {
|
||||||
|
const html = render(<SourcesRail />);
|
||||||
|
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(<SourcesRail />);
|
||||||
|
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(<SourcesRail />);
|
||||||
|
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(<SourcesRail />, "de");
|
||||||
|
expect(html).toContain("Blutdruck");
|
||||||
|
expect(html).toContain("Gewicht");
|
||||||
|
expect(html).toContain("Einnahmetreue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the footer disclaimer", () => {
|
||||||
|
const html = render(<SourcesRail />);
|
||||||
|
expect(html).toContain(
|
||||||
|
"The Coach reads only the data you've connected.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
|
||||||
|
import { CoachInput } from "./coach-input";
|
||||||
|
import { HistoryRail } from "./history-rail";
|
||||||
import { MessageThread } from "./message-thread";
|
import { MessageThread } from "./message-thread";
|
||||||
|
import { SourcesRail } from "./sources-rail";
|
||||||
import {
|
import {
|
||||||
useCoachConversation,
|
useCoachConversation,
|
||||||
useSendCoachMessage,
|
useSendCoachMessage,
|
||||||
@@ -193,7 +196,12 @@ export function CoachDrawer({
|
|||||||
data-slot="coach-drawer-history"
|
data-slot="coach-drawer-history"
|
||||||
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
|
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
|
||||||
>
|
>
|
||||||
{historyRail ?? <div className="p-3" />}
|
{historyRail ?? (
|
||||||
|
<HistoryRail
|
||||||
|
activeId={currentConversationId}
|
||||||
|
onSelect={(id) => setCurrentConversationId(id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Centre — message thread. */}
|
{/* Centre — message thread. */}
|
||||||
@@ -213,11 +221,12 @@ export function CoachDrawer({
|
|||||||
className="border-border/70 border-t p-3 sm:p-4"
|
className="border-border/70 border-t p-3 sm:p-4"
|
||||||
>
|
>
|
||||||
{composer ?? (
|
{composer ?? (
|
||||||
<FallbackComposer
|
<CoachInput
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={setInputValue}
|
onChange={setInputValue}
|
||||||
onSubmit={() => handleSubmit(inputValue)}
|
onSubmit={() => handleSubmit(inputValue)}
|
||||||
disabled={send.isStreaming}
|
disabled={send.isStreaming}
|
||||||
|
isStreaming={send.isStreaming}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +237,7 @@ export function CoachDrawer({
|
|||||||
data-slot="coach-drawer-sources"
|
data-slot="coach-drawer-sources"
|
||||||
className="border-border/70 hidden h-full min-h-0 border-l lg:flex lg:flex-col"
|
className="border-border/70 hidden h-full min-h-0 border-l lg:flex lg:flex-col"
|
||||||
>
|
>
|
||||||
{sourcesRail ?? <div className="p-3" />}
|
{sourcesRail ?? <SourcesRail />}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -236,46 +245,3 @@ export function CoachDrawer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,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.
|
||||||
|
*
|
||||||
|
* `<textarea>` wrapped in a Dracula-styled card with a send button +
|
||||||
|
* mic placeholder. Submit fires on:
|
||||||
|
* - Enter (no Shift) → send
|
||||||
|
* - Cmd/Ctrl + Enter → send (parity with the artboard ⌘↵ chip)
|
||||||
|
* - Shift + Enter → newline (default browser behaviour)
|
||||||
|
*
|
||||||
|
* The composer is purely controlled — `value` + `onChange` come from
|
||||||
|
* the drawer. `disabled` flips during a streamed reply so we never
|
||||||
|
* fire two requests in parallel.
|
||||||
|
*
|
||||||
|
* Mic is rendered but disabled with a tooltip ("Voice input arrives
|
||||||
|
* with the iOS app in v1.5") because the natural shipper of voice is
|
||||||
|
* the native client, not the PWA.
|
||||||
|
*
|
||||||
|
* The disclaimer below the input is a hard requirement — Coach
|
||||||
|
* replies are not medical advice.
|
||||||
|
*/
|
||||||
|
export interface CoachInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
/** Disabled while a previous reply is still streaming. */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Surfaces the streaming-in-progress affordance on the send button. */
|
||||||
|
isStreaming?: boolean;
|
||||||
|
/** Optional textarea ref so the parent can focus on drawer open. */
|
||||||
|
inputId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoachInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
disabled = false,
|
||||||
|
isStreaming = false,
|
||||||
|
inputId = "coach-composer-textarea",
|
||||||
|
}: CoachInputProps) {
|
||||||
|
const { t } = useTranslations();
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
// Shift+Enter inserts a newline.
|
||||||
|
if (event.shiftKey) return;
|
||||||
|
// Plain Enter and ⌘/Ctrl+Enter both submit.
|
||||||
|
event.preventDefault();
|
||||||
|
if (!disabled && value.trim().length > 0) {
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, onSubmit, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFormSubmit = useCallback(
|
||||||
|
(event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!disabled && value.trim().length > 0) {
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, onSubmit, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSubmit = !disabled && value.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
data-slot="coach-input"
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/60 bg-muted/40 group rounded-md border",
|
||||||
|
"p-2.5 transition-colors",
|
||||||
|
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:ring-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id={inputId}
|
||||||
|
data-slot="coach-input-textarea"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={t("insights.coach.composerPlaceholder")}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={2}
|
||||||
|
className={cn(
|
||||||
|
"w-full resize-none bg-transparent text-sm leading-relaxed outline-none",
|
||||||
|
"placeholder:text-muted-foreground disabled:opacity-60",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled
|
||||||
|
aria-label={t("insights.coach.voiceComingSoon")}
|
||||||
|
data-slot="coach-input-mic"
|
||||||
|
className="size-8"
|
||||||
|
>
|
||||||
|
<Mic className="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("insights.coach.voiceComingSoon")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<span
|
||||||
|
data-slot="coach-input-hint"
|
||||||
|
className="text-muted-foreground text-[11px]"
|
||||||
|
>
|
||||||
|
{t("insights.coach.composerHint")}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
data-slot="coach-input-send"
|
||||||
|
className="ml-auto gap-1.5"
|
||||||
|
>
|
||||||
|
{isStreaming ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Send className="size-3.5" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span>{t("insights.coach.send")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
data-slot="coach-input-disclaimer"
|
||||||
|
className="text-muted-foreground text-[11px]"
|
||||||
|
>
|
||||||
|
{t("insights.coach.composerDisclaimer")}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { MessagesSquare, Search, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCoachConversations,
|
||||||
|
useDeleteCoachConversation,
|
||||||
|
} from "./use-coach";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.20 phase B2b — conversation history rail.
|
||||||
|
*
|
||||||
|
* Lives in the left column of the Coach drawer on `lg+`. Renders the
|
||||||
|
* paginated list returned by `GET /api/insights/chat`, lets the user
|
||||||
|
* filter by title (substring match), select one to resume, and delete
|
||||||
|
* with a confirm-then-go flow.
|
||||||
|
*
|
||||||
|
* Selection / activeId is a controlled prop so the drawer owns the
|
||||||
|
* authoritative state across the message thread + composer.
|
||||||
|
*
|
||||||
|
* Empty + loading + error states are all rendered inline — the rail
|
||||||
|
* never collapses to "0 px" so the layout stays predictable.
|
||||||
|
*/
|
||||||
|
export interface HistoryRailProps {
|
||||||
|
activeId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
/**
|
||||||
|
* Optional className passthrough so the parent can override the
|
||||||
|
* default layout (currently the drawer wraps it in an `<aside>`).
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(
|
||||||
|
iso: string,
|
||||||
|
t: (key: string, params?: Record<string, string | number>) => string,
|
||||||
|
): string {
|
||||||
|
const target = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(target)) return "";
|
||||||
|
const diffMs = Date.now() - target;
|
||||||
|
if (diffMs < 60_000) return t("insights.relativeJustNow");
|
||||||
|
const minutes = Math.floor(diffMs / 60_000);
|
||||||
|
if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes });
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return t("insights.relativeHoursAgo", { count: hours });
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return t("insights.relativeDaysAgo", { count: days });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryRail({
|
||||||
|
activeId,
|
||||||
|
onSelect,
|
||||||
|
className,
|
||||||
|
}: HistoryRailProps) {
|
||||||
|
const { t } = useTranslations();
|
||||||
|
const { conversations, isLoading } = useCoachConversations();
|
||||||
|
const deleteMutation = useDeleteCoachConversation();
|
||||||
|
const [filter, setFilter] = useState<string>("");
|
||||||
|
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
if (!q) return conversations;
|
||||||
|
return conversations.filter((c) => c.title.toLowerCase().includes(q));
|
||||||
|
}, [conversations, filter]);
|
||||||
|
|
||||||
|
function handleDeleteRequest(id: string) {
|
||||||
|
if (confirmId === id) {
|
||||||
|
deleteMutation.mutate(id);
|
||||||
|
setConfirmId(null);
|
||||||
|
// If the user just deleted the active thread, the drawer is
|
||||||
|
// listening to `onSelect` for navigation; we leave it to the
|
||||||
|
// parent to clear `activeId` when the conversation disappears.
|
||||||
|
} else {
|
||||||
|
setConfirmId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="coach-history-rail"
|
||||||
|
className={cn("flex h-full min-h-0 flex-col gap-2 p-3", className)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 px-1">
|
||||||
|
<MessagesSquare
|
||||||
|
className="text-muted-foreground size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
|
||||||
|
{t("insights.coach.historyTitle")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder={t("insights.coach.historySearchPlaceholder")}
|
||||||
|
data-slot="coach-history-search"
|
||||||
|
className="h-8 pl-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-slot="coach-history-list"
|
||||||
|
className="-mx-1 flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1"
|
||||||
|
>
|
||||||
|
{isLoading && conversations.length === 0 ? (
|
||||||
|
<p
|
||||||
|
data-slot="coach-history-loading"
|
||||||
|
className="text-muted-foreground px-2 py-3 text-xs"
|
||||||
|
>
|
||||||
|
{t("common.loading")}
|
||||||
|
</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p
|
||||||
|
data-slot="coach-history-empty"
|
||||||
|
className="text-muted-foreground px-2 py-3 text-xs leading-relaxed"
|
||||||
|
>
|
||||||
|
{t("insights.coach.historyEmpty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filtered.map((c) => {
|
||||||
|
const isActive = c.id === activeId;
|
||||||
|
const isConfirming = confirmId === c.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
data-slot="coach-history-item"
|
||||||
|
data-active={isActive ? "true" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-1.5 rounded-md px-2 py-1.5",
|
||||||
|
"text-xs transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-dracula-purple/15 text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(c.id)}
|
||||||
|
className="min-w-0 flex-1 truncate text-left"
|
||||||
|
data-slot="coach-history-select"
|
||||||
|
>
|
||||||
|
<span className="block truncate font-medium">
|
||||||
|
{c.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground block text-[10px]">
|
||||||
|
{formatRelativeTime(c.updatedAt, t)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteRequest(c.id)}
|
||||||
|
aria-label={
|
||||||
|
isConfirming
|
||||||
|
? t("insights.coach.historyDeleteConfirm")
|
||||||
|
: t("insights.coach.historyDeleteAria")
|
||||||
|
}
|
||||||
|
data-slot="coach-history-delete"
|
||||||
|
data-confirming={isConfirming ? "true" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"size-6 shrink-0 opacity-0 transition-opacity group-hover:opacity-100",
|
||||||
|
isConfirming &&
|
||||||
|
"text-dracula-red hover:text-dracula-red opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Activity, Eye, Heart, HeartPulse, Pill, Scale, Smile } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.20 phase B2b — "What I can see" rail.
|
||||||
|
*
|
||||||
|
* Right-column companion in the Coach drawer. Lists the data sources
|
||||||
|
* the assistant draws on so the user can build trust in the
|
||||||
|
* provenance. v1.4.20 ships with a hard-coded list keyed off the
|
||||||
|
* five contracts the snapshot helper currently surfaces (BP, weight,
|
||||||
|
* pulse, mood, medication compliance). v1.4.21 will plug in fresh /
|
||||||
|
* stale dot indicators by reusing `<IntegrationStatusPill>`.
|
||||||
|
*
|
||||||
|
* Hardcoding the list here is deliberate: every line is i18n-driven
|
||||||
|
* so locale switching works out of the box, and the underlying user
|
||||||
|
* data is already gated behind the page-level `requireAuth()` so a
|
||||||
|
* user without a connected source still sees the legend.
|
||||||
|
*/
|
||||||
|
export interface SourcesRailProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceRow {
|
||||||
|
key: string;
|
||||||
|
metricKey: string;
|
||||||
|
Icon: React.ComponentType<{ className?: string }>;
|
||||||
|
accentClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROWS: SourceRow[] = [
|
||||||
|
{
|
||||||
|
key: "bp",
|
||||||
|
metricKey: "insights.coach.metric.bp",
|
||||||
|
Icon: HeartPulse,
|
||||||
|
accentClass: "text-dracula-purple",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "weight",
|
||||||
|
metricKey: "insights.coach.metric.weight",
|
||||||
|
Icon: Scale,
|
||||||
|
accentClass: "text-dracula-cyan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pulse",
|
||||||
|
metricKey: "insights.coach.metric.pulse",
|
||||||
|
Icon: Heart,
|
||||||
|
accentClass: "text-dracula-pink",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mood",
|
||||||
|
metricKey: "insights.coach.metric.mood",
|
||||||
|
Icon: Smile,
|
||||||
|
accentClass: "text-dracula-green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "compliance",
|
||||||
|
metricKey: "insights.coach.metric.compliance",
|
||||||
|
Icon: Pill,
|
||||||
|
accentClass: "text-dracula-orange",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SourcesRail({ className }: SourcesRailProps) {
|
||||||
|
const { t } = useTranslations();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="coach-sources-rail"
|
||||||
|
className={cn("flex h-full min-h-0 flex-col gap-3 p-3", className)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 px-1">
|
||||||
|
<Eye
|
||||||
|
className="text-muted-foreground size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
|
||||||
|
{t("insights.coach.sourcesTitle")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
data-slot="coach-sources-list"
|
||||||
|
className="flex flex-1 flex-col gap-1.5"
|
||||||
|
>
|
||||||
|
{ROWS.map((row) => (
|
||||||
|
<li
|
||||||
|
key={row.key}
|
||||||
|
data-slot="coach-sources-row"
|
||||||
|
data-source={row.key}
|
||||||
|
className={cn(
|
||||||
|
"border-border/60 bg-muted/30 flex items-center gap-2",
|
||||||
|
"rounded-md border px-2.5 py-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<row.Icon className={cn("size-3.5", row.accentClass)} aria-hidden="true" />
|
||||||
|
<span className="text-foreground flex-1 text-xs font-medium">
|
||||||
|
{t(row.metricKey)}
|
||||||
|
</span>
|
||||||
|
{/* Fresh / stale indicator — v1.4.20 is a static dot.
|
||||||
|
v1.4.21 will reuse <IntegrationStatusPill>. */}
|
||||||
|
<span
|
||||||
|
aria-label={t("insights.coach.sourcesFresh")}
|
||||||
|
className="bg-dracula-green size-1.5 rounded-full"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3">
|
||||||
|
<Activity
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||||
|
{t("insights.coach.sourcesFooter")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user