From 9f2c403f845e67ff478eef7d4d6988f569ae8cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 21:19:05 +0200 Subject: [PATCH] feat(coach): a chat that behaves like one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer starts as a single line with send beside it and grows only when the text does; the shift-enter hint and the inline disclaimer are gone. A typing indicator bubbles before the first token. The context rail hides by default behind a toggle and stops scrolling sideways, conversations open in the full view instead of a broken side drawer, and the coach preferences move into Settings where the rest of the configuration lives — context toggles and the exclusion list now derive from one source so they can no longer contradict each other. A floating bubble appears only while a coach-initiated nudge is unread. Clearly stated allergies and conditions are remembered immediately through a deterministic extractor instead of waiting for the twenty-turn summary pass. --- src/app/api/insights/chat/route.ts | 15 + .../api/insights/coach/nudge-status/route.ts | 51 ++ .../coach-drawer-mobile-trays.test.tsx | 87 ++- .../__tests__/coach-input.test.tsx | 20 +- .../__tests__/coach-settings-sheet.test.tsx | 123 ----- .../__tests__/mobile-rail-tray.test.tsx | 14 +- .../coach-panel/coach-conversation.tsx | 60 +- .../coach-panel/coach-drawer-body.tsx | 108 ++-- .../insights/coach-panel/coach-drawer.tsx | 4 + .../insights/coach-panel/coach-input.tsx | 129 ++--- .../coach-panel/coach-settings-sheet.tsx | 518 ------------------ .../insights/coach-panel/history-rail.tsx | 2 +- .../insights/coach-panel/message-thread.tsx | 47 +- .../insights/coach-panel/mobile-rail-tray.tsx | 8 +- .../insights/coach-panel/sources-rail.tsx | 15 +- .../settings/coach-prefs-section.tsx | 410 ++++++++++++++ .../__tests__/facts-deterministic.test.ts | 172 ++++++ src/lib/ai/coach/facts.ts | 186 +++++++ src/lib/query-keys.ts | 2 + 19 files changed, 1092 insertions(+), 879 deletions(-) create mode 100644 src/app/api/insights/coach/nudge-status/route.ts delete mode 100644 src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx delete mode 100644 src/components/insights/coach-panel/coach-settings-sheet.tsx create mode 100644 src/components/settings/coach-prefs-section.tsx create mode 100644 src/lib/ai/coach/__tests__/facts-deterministic.test.ts diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 3b77cbfd1..541c4b6c6 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -57,6 +57,7 @@ import { listConversations, } from "@/lib/ai/coach/persistence"; import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared"; +import { storeDeterministicFacts } from "@/lib/ai/coach/facts"; import { buildDateKey, enforceBudget, @@ -331,6 +332,20 @@ async function handleChatRequest(request: NextRequest): Promise { // rolling summary + extract durable facts off the request path. Fire-and- // forget: this turn uses whatever summary is already on disk; the refresh // makes the next long turn fresh. No-ops without an embedded worker. + // v1.16.1 — always-remember categories (allergies, intolerances, explicit + // self-reported diagnoses) must not wait for the >TURN_CAP memory refresh: + // a health-critical statement in the second message of a short chat used + // to never reach the fact store. The deterministic pattern pass is + // provider-free and deduped, so it fires on every user turn. + void storeDeterministicFacts({ + conversationId: workingConversationId, + userId, + message, + locale, + }).catch(() => { + // Fact capture must never break the chat turn; the >TURN_CAP LLM + // extraction remains as the catch-all on long conversations. + }); if (allTurns.length > TURN_CAP) { void enqueueCoachMemoryRefresh({ conversationId: workingConversationId, diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts new file mode 100644 index 000000000..a4aad8dd6 --- /dev/null +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -0,0 +1,51 @@ +/** + * GET /api/insights/coach/nudge-status — has the proactive Coach nudge + * something the caller has not engaged with yet? + * + * v1.16.1 — feeds the floating Coach bubble: the bubble renders only + * while an unseen Coach-initiated nudge exists, instead of being a + * permanent FAB. "Unseen" is derived, not stored: the latest successful + * `COACH_NUDGE` dispatch (the `push_attempts` ledger the nudge cron + * already anchors its frequency cap on) counts as read once the user + * has sent a Coach message after it. The client additionally remembers + * a local "seen" stamp so opening the chat without sending also clears + * the bubble on that device. + * + * No new table, no migration: both timestamps come from existing rows, + * scoped to the caller. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { requireAssistantSurface } from "@/lib/feature-flags"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + await requireAssistantSurface("coach"); + + const [lastNudge, lastUserMessage] = await Promise.all([ + prisma.pushAttempt.findFirst({ + where: { userId: user.id, eventType: "COACH_NUDGE", result: "ok" }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }), + prisma.coachMessage.findFirst({ + where: { role: "user", conversation: { userId: user.id } }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }), + ]); + + const nudgedAt = lastNudge?.createdAt ?? null; + const unread = + nudgedAt !== null && + (lastUserMessage === null || lastUserMessage.createdAt < nudgedAt); + + return apiSuccess({ + nudgedAt: nudgedAt ? nudgedAt.toISOString() : null, + unread, + }); +}); + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx b/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx index 63831a031..d46c0e857 100644 --- a/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx @@ -5,17 +5,15 @@ import { I18nProvider } from "@/lib/i18n/context"; import { CoachDrawerBody } from "../coach-drawer-body"; /** - * v1.4.20 phase B4 — Coach drawer mobile rail trays. + * v1.4.20 phase B4 — Coach drawer rail-tray strip. * - * Two chevron-button triggers along the thread sub-header open - * side-sheets surfacing the history + sources rails. - * - * v1.12.0 — the conversation history is no longer an inline column on - * any viewport. The "Conversations" trigger is the only entry to the - * history rail and is therefore visible at every breakpoint; the - * sources rail stays inline at xl+ so its trigger keeps `xl:hidden`. - * The clinical-decisions disclaimer renders once, directly above the - * composer. + * v1.16.1 — the body lost its inline xl+ sources column (the sources + * rail is tray-only on every viewport, hidden until toggled) and the + * clinical-decisions disclaimer slot. The history affordance forks by + * surface: the drawer hands off to the full-page route via + * `onHistoryClick`; the page passes an inline `historyRail` slot that + * renders as a left column on lg+ (the strip button collapses to + * `lg:hidden` there). */ function render(node: React.ReactNode, locale: "en" | "de" = "en") { @@ -25,33 +23,23 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { } const baseProps = { - sourcesRail: sources, thread: thread, composer: composer, - disclaimer: disclaimer, - onOpenHistoryTray: () => undefined, + onHistoryClick: () => undefined, onOpenSourcesTray: () => undefined, }; -describe(" — mobile rail trays", () => { - it("renders both tray triggers", () => { +describe(" — rail-tray strip", () => { + it("renders both strip triggers", () => { const html = render(); expect(html).toMatch(/data-slot="coach-drawer-history-tray-trigger"/); expect(html).toMatch(/data-slot="coach-drawer-sources-tray-trigger"/); }); - it("keeps the history trigger on every breakpoint and hides the sources trigger on >=xl", () => { - // v1.12.0 — the history rail is tray-only now, so the - // "Conversations" toggle is always visible (no `lg:hidden`) and - // the strip wrapper no longer carries `xl:hidden`. The sources - // trigger keeps `xl:hidden` because the sources rail is inline at - // xl+. + it("keeps both triggers visible on every breakpoint without an inline historyRail", () => { + // v1.16.1 — the sources rail is tray-only now, so its trigger lost + // the `xl:hidden` cap; the history trigger stays visible too. const html = render(); - const strip = html.match( - /]*data-slot="coach-drawer-rail-tray-strip"[^>]*>/, - ); - expect(strip?.[0]).not.toContain("xl:hidden"); - const historyTrigger = html.match( /]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/, ); @@ -61,37 +49,43 @@ describe(" — mobile rail trays", () => { const sourcesTrigger = html.match( /]*data-slot="coach-drawer-sources-tray-trigger"[^>]*>/, ); - expect(sourcesTrigger?.[0]).toContain("xl:hidden"); + expect(sourcesTrigger).not.toBeNull(); + expect(sourcesTrigger?.[0]).not.toContain("xl:hidden"); }); - it("no longer mounts an inline history column; sources rail stays inline at xl+", () => { + it("no longer mounts an inline sources column", () => { const html = render(); - expect(html).not.toContain('data-slot="coach-drawer-history"'); - expect(html).toMatch( - /]*data-slot="coach-drawer-sources"[^>]*xl:flex/, - ); + expect(html).not.toContain('data-slot="coach-drawer-sources"'); }); - it("renders the message thread + composer slots and the disclaimer above the composer", () => { + it("mounts an inline history column on lg+ when historyRail is passed (page surface)", () => { + const html = render( + history} />, + ); + expect(html).toMatch( + /]*data-slot="coach-drawer-history"[^>]*lg:flex/, + ); + expect(html).toContain("history"); + // The strip button collapses on lg+ where the inline column shows. + const historyTrigger = html.match( + /]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/, + ); + expect(historyTrigger?.[0]).toContain("lg:hidden"); + }); + + it("renders the message thread + composer slots without a disclaimer line", () => { const html = render(); expect(html).toMatch(/data-slot="coach-drawer-thread"/); expect(html).toMatch(/data-slot="coach-drawer-composer"/); // Slot content forwarded verbatim. expect(html).toContain("thread"); expect(html).toContain("composer"); - // The disclaimer slot renders inside the composer container, before - // the composer itself. - expect(html).toContain("disclaimer"); - const composerBlock = html.match( - /data-slot="coach-drawer-composer"[^>]*>([\s\S]*?)<\/div>/, - ); - expect(composerBlock?.[1]).toContain("disclaimer"); - const disclaimerIdx = html.indexOf("disclaimer"); - const composerIdx = html.lastIndexOf("composer"); - expect(disclaimerIdx).toBeLessThan(composerIdx); + // v1.16.1 — the clinical-decisions disclaimer was removed from the + // chat UI entirely. + expect(html).not.toContain("coach-composer-disclaimer"); }); - it("uses chevron-style trigger labels — History on the left, Sources on the right", () => { + it("uses the localized trigger labels — History on the left, Sources on the right", () => { const html = render(); const historyTrigger = html.match( /]*data-slot="coach-drawer-history-tray-trigger"[^>]*>[\s\S]*?<\/button>/, @@ -114,9 +108,8 @@ describe(" — mobile rail trays", () => { it("renders the rail triggers in a sub-header strip above the thread", () => { // v1.4.27 R3d MB2 — the triggers were lifted out of the absolute - // overlay into a sub-header strip so the chevrons sit at a 44 px - // tap target and never overlay the first message bubble. The - // strip lives above the thread within the centre column. + // overlay into a sub-header strip so they sit at a 44 px tap + // target and never overlay the first message bubble. const html = render(); expect(html).toMatch(/data-slot="coach-drawer-rail-tray-strip"/); const trigger = html.match( diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index 4785060a0..fc49697c9 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -26,22 +26,18 @@ describe("", () => { expect(html).not.toContain("Coach replies are generated"); }); - it("renders the localised placeholder + the popover-trigger hint", () => { - // v1.4.27 F15 — the verbose prose hint footer was replaced by an - // Info icon button. The same `composerHint` translation surfaces - // as the trigger's `aria-label` (so screen-readers announce it on - // focus) and as the popover body for sighted users on tap. - // - // v1.4.27 MB3 / CF-31 — the disclosure swapped off Radix Tooltip - // (no reliable tap-toggle on touch surfaces) onto Radix Popover, - // so the contract is now "tap to open" instead of "hover". + it("renders the localised placeholder without the retired shortcut hint", () => { + // v1.16.1 — the Enter/Shift+Enter hint (prose footer, later an + // Info-popover) is gone entirely; the behaviour itself is + // unchanged. The composer is a single row: textarea + send button + // on the same baseline. const html = render( {}} onSubmit={() => {}} />, ); expect(html).toContain("Ask anything about your data"); - expect(html).toMatch( - /aria-label="Press Enter to send[^"]*"\s+data-slot="coach-input-hint"/, - ); + expect(html).not.toContain('data-slot="coach-input-hint"'); + expect(html).not.toContain("Press Enter to send"); + expect(html).toContain('data-slot="coach-input-send"'); }); it("renders the German placeholder under the 'de' locale", () => { diff --git a/src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx b/src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx deleted file mode 100644 index b43d9835a..000000000 --- a/src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { toast } from "sonner"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; - -// v1.4.23 W6 (Design-H3 + H4) — verify the settings sheet renders a -// skeleton placeholder while the persisted prefs are loading (no -// `DEFAULT_COACH_PREFS` ghost form) and emits a sonner toast on save -// success. -// -// The shadcn `` wraps Radix Dialog which uses Portals — those -// don't materialise under `renderToStaticMarkup`. We mock the sheet -// primitives down to plain wrappers so the form/skeleton body is -// reachable in the static markup. - -const queryState: { data: unknown } = { data: undefined }; -const mutationCallbacks: { onSuccess: ((env: unknown) => void) | null } = { - onSuccess: null, -}; - -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: queryState.data }), - useMutation: (opts: { onSuccess?: (env: unknown) => void }) => { - mutationCallbacks.onSuccess = opts.onSuccess ?? null; - return { - mutate: (next: unknown) => { - mutationCallbacks.onSuccess?.({ data: next }); - }, - isPending: false, - }; - }, - useQueryClient: () => ({ - setQueryData: vi.fn(), - }), -})); - -vi.mock("sonner", () => ({ - toast: { success: vi.fn() }, -})); - -vi.mock("@/components/ui/sheet", () => ({ - Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) => - open ?
{children}
: null, - SheetContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - SheetHeader: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - SheetTitle: ({ children }: { children: React.ReactNode }) => ( -

{children}

- ), - SheetDescription: ({ children }: { children: React.ReactNode }) => ( -

{children}

- ), - // v1.4.27 R3d MB1 — the settings sheet now renders an inline - // `` in the header instead of the primitive's - // absolutely-positioned close-X. Mock it as a passthrough so the - // existing skeleton + form snapshot assertions keep working. - SheetClose: ({ children }: { children: React.ReactNode }) => <>{children}, -})); - -import { CoachSettingsSheet } from "../coach-settings-sheet"; - -function render(node: React.ReactNode) { - return renderToStaticMarkup( - {node}, - ); -} - -beforeEach(() => { - queryState.data = undefined; - mutationCallbacks.onSuccess = null; - vi.mocked(toast.success).mockClear(); -}); - -describe("", () => { - it("renders a skeleton shell while persisted prefs are loading (Design-H3)", () => { - queryState.data = undefined; - const html = render( - {}} />, - ); - expect(html).toContain('data-slot="coach-prefs-skeleton"'); - // Form body markers must be absent during the skeleton state so - // the user never sees the DEFAULT_COACH_PREFS ghost form. - expect(html).not.toContain('data-slot="coach-prefs-tone"'); - expect(html).not.toContain('data-slot="coach-prefs-evidence"'); - }); - - it("renders the form once persisted prefs land", () => { - queryState.data = DEFAULT_COACH_PREFS; - const html = render( - {}} />, - ); - expect(html).not.toContain('data-slot="coach-prefs-skeleton"'); - expect(html).toContain('data-slot="coach-prefs-tone"'); - // v1.4.27 F14 — the `showEvidenceByDefault` switch is retired. - expect(html).not.toContain('data-slot="coach-prefs-evidence"'); - }); - - it("fires a sonner toast on save success (Design-H4)", () => { - queryState.data = DEFAULT_COACH_PREFS; - render( {}} />); - // Static markup doesn't dispatch click events; trigger the captured - // mutation success path directly. Verifies the wired-up onSuccess - // emits the toast with the localised "Saved." string. - mutationCallbacks.onSuccess?.({ data: DEFAULT_COACH_PREFS }); - expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1); - expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Saved."); - }); - - // v1.4.25 W5 — defaultWindow picker added. - it("renders the default-window picker once persisted prefs land", () => { - queryState.data = DEFAULT_COACH_PREFS; - const html = render( - {}} />, - ); - expect(html).toContain('data-slot="coach-prefs-default-window"'); - expect(html).toContain("Default analysis window"); - }); -}); diff --git a/src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx b/src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx index 11dfbe2db..6491e7586 100644 --- a/src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx +++ b/src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx @@ -68,19 +68,19 @@ describe("", () => { expect(html).toContain("sources-content"); }); - it("keeps the history tray on every breakpoint and hides the sources tray on xl+", () => { - // v1.12.0 — the history rail is tray-only now (no inline column on - // any viewport), so the history tray drops its `lg:hidden` cap and - // is available at every breakpoint. The sources rail still mounts - // inline at xl+, so its tray keeps `xl:hidden`. + it("keeps both trays available on every breakpoint", () => { + // v1.16.1 — the sources rail lost its inline xl+ column; the tray + // is the one sources surface everywhere, so the `xl:hidden` cap is + // gone. The history tray was breakpoint-free already. const html = render(); const historyTray = html.match( /data-slot="coach-drawer-history-tray"[^>]*class="([^"]*)"/, ); expect(historyTray?.[1]).not.toContain("lg:hidden"); - expect(html).toMatch( - /data-slot="coach-drawer-sources-tray"[^>]*class="[^"]*xl:hidden/, + const sourcesTray = html.match( + /data-slot="coach-drawer-sources-tray"[^>]*class="([^"]*)"/, ); + expect(sourcesTray?.[1]).not.toContain("xl:hidden"); }); it("renders both header titles localised", () => { diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 65bc92b26..8e11ec248 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import Link from "next/link"; import { Plus, Settings, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -9,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context"; import { CoachDrawerBody } from "./coach-drawer-body"; import { CoachInput } from "./coach-input"; -import { CoachSettingsSheet } from "./coach-settings-sheet"; import { HistoryRail } from "./history-rail"; import { MessageThread } from "./message-thread"; import { MobileRailTray } from "./mobile-rail-tray"; @@ -25,10 +25,10 @@ import { useCoachConversation, useSendCoachMessage } from "./use-coach"; * * This component is the single source of truth for the Coach * conversation: it owns the active-conversation id, the streaming send - * hook, the composer value, the settings sheet, and the mobile rail - * trays — every piece of Coach v2 behaviour (incremental streaming, - * collapsible history + provenance, the single composer disclaimer, - * the auto-grow composer, refusal / budget handling) lives here once. + * hook, the composer value, and the rail trays — every piece of Coach + * v2 behaviour (incremental streaming, collapsible history + + * provenance, the auto-grow composer, refusal / budget handling) + * lives here once. * The drawer and the page differ ONLY in their chrome: the drawer wraps * this surface in a `` and supplies a close button + a maximize * control; the page renders it edge-to-edge and supplies a minimize @@ -79,6 +79,14 @@ export interface CoachConversationProps { * tears the stream down on its own. */ registerReset?: (reset: () => void) => void; + /** + * v1.16.1 — drawer-only: the "Conversations" affordance no longer + * opens an in-panel left tray (it kept breaking inside the sheet); + * it hands the user off to the full-page Coach route instead. The + * drawer wires this to its maximize handler (close sheet + route); + * the page omits it and renders the list inline / as a tray. + */ + onRequestFullView?: () => void; /** className passthrough for the outer flex column. */ className?: string; /** data-variant on the root, surfaced for e2e + styling hooks. */ @@ -93,6 +101,7 @@ export function CoachConversation({ trailingHeaderActions, autoFocusComposer, registerReset, + onRequestFullView, className, surface, }: CoachConversationProps) { @@ -103,7 +112,6 @@ export function CoachConversation({ >(null); const [historyTrayOpen, setHistoryTrayOpen] = useState(false); const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); const [inputValue, setInputValue] = useResettableValue(prefill ?? ""); const { data: conversation } = useCoachConversation(currentConversationId); @@ -188,23 +196,36 @@ export function CoachConversation({ >