mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +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 { 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 ?? <div className="p-3" />}
|
||||
{historyRail ?? (
|
||||
<HistoryRail
|
||||
activeId={currentConversationId}
|
||||
onSelect={(id) => setCurrentConversationId(id)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Centre — message thread. */}
|
||||
@@ -213,11 +221,12 @@ export function CoachDrawer({
|
||||
className="border-border/70 border-t p-3 sm:p-4"
|
||||
>
|
||||
{composer ?? (
|
||||
<FallbackComposer
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={() => handleSubmit(inputValue)}
|
||||
disabled={send.isStreaming}
|
||||
isStreaming={send.isStreaming}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -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 ?? <div className="p-3" />}
|
||||
{sourcesRail ?? <SourcesRail />}
|
||||
</aside>
|
||||
</div>
|
||||
</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