mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(coach): a chat that behaves like one
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.
This commit is contained in:
1 parent
b55fab5e94
commit
9f2c403f84
19 files changed
+1092
-879
No files matched your search
@@ -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: <span>sources</span>,
|
||||
thread: <span>thread</span>,
|
||||
composer: <span>composer</span>,
|
||||
disclaimer: <span>disclaimer</span>,
|
||||
onOpenHistoryTray: () => undefined,
|
||||
onHistoryClick: () => undefined,
|
||||
onOpenSourcesTray: () => undefined,
|
||||
};
|
||||
|
||||
describe("<CoachDrawerBody> — mobile rail trays", () => {
|
||||
it("renders both tray triggers", () => {
|
||||
describe("<CoachDrawerBody> — rail-tray strip", () => {
|
||||
it("renders both strip triggers", () => {
|
||||
const html = render(<CoachDrawerBody {...baseProps} />);
|
||||
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(<CoachDrawerBody {...baseProps} />);
|
||||
const strip = html.match(
|
||||
/<div[^>]*data-slot="coach-drawer-rail-tray-strip"[^>]*>/,
|
||||
);
|
||||
expect(strip?.[0]).not.toContain("xl:hidden");
|
||||
|
||||
const historyTrigger = html.match(
|
||||
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
|
||||
);
|
||||
@@ -61,37 +49,43 @@ describe("<CoachDrawerBody> — mobile rail trays", () => {
|
||||
const sourcesTrigger = html.match(
|
||||
/<button[^>]*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(<CoachDrawerBody {...baseProps} />);
|
||||
expect(html).not.toContain('data-slot="coach-drawer-history"');
|
||||
expect(html).toMatch(
|
||||
/<aside[^>]*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(
|
||||
<CoachDrawerBody {...baseProps} historyRail={<span>history</span>} />,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<aside[^>]*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(
|
||||
/<button[^>]*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(<CoachDrawerBody {...baseProps} />);
|
||||
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(<CoachDrawerBody {...baseProps} />);
|
||||
const historyTrigger = html.match(
|
||||
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>[\s\S]*?<\/button>/,
|
||||
@@ -114,9 +108,8 @@ describe("<CoachDrawerBody> — 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(<CoachDrawerBody {...baseProps} />);
|
||||
expect(html).toMatch(/data-slot="coach-drawer-rail-tray-strip"/);
|
||||
const trigger = html.match(
|
||||
|
||||
@@ -26,22 +26,18 @@ describe("<CoachInput>", () => {
|
||||
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(
|
||||
<CoachInput value="" onChange={() => {}} 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", () => {
|
||||
|
||||
@@ -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 `<Sheet>` 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 ? <div data-slot="mock-sheet">{children}</div> : null,
|
||||
SheetContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-sheet-content">{children}</div>
|
||||
),
|
||||
SheetHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SheetTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<h2>{children}</h2>
|
||||
),
|
||||
SheetDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<p>{children}</p>
|
||||
),
|
||||
// v1.4.27 R3d MB1 — the settings sheet now renders an inline
|
||||
// `<SheetClose>` 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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
queryState.data = undefined;
|
||||
mutationCallbacks.onSuccess = null;
|
||||
vi.mocked(toast.success).mockClear();
|
||||
});
|
||||
|
||||
describe("<CoachSettingsSheet>", () => {
|
||||
it("renders a skeleton shell while persisted prefs are loading (Design-H3)", () => {
|
||||
queryState.data = undefined;
|
||||
const html = render(
|
||||
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
|
||||
);
|
||||
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(<CoachSettingsSheet open={true} onOpenChange={() => {}} />);
|
||||
// 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(
|
||||
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-prefs-default-window"');
|
||||
expect(html).toContain("Default analysis window");
|
||||
});
|
||||
});
|
||||
@@ -68,19 +68,19 @@ describe("<MobileRailTray>", () => {
|
||||
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(<MobileRailTray {...baseProps} />);
|
||||
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", () => {
|
||||
|
||||
@@ -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 `<Sheet>` 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({
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
{/* v1.16.1 — the Coach preferences moved into Settings → AI
|
||||
(one place for model + behaviour). The header keeps a gear
|
||||
that deep-links there instead of opening an in-chat sheet. */}
|
||||
<Button
|
||||
type="button"
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
data-slot="coach-settings"
|
||||
aria-label={t("insights.coach.settingsAriaLabel")}
|
||||
title={t("insights.coach.settingsAriaLabel")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
>
|
||||
<Settings className="size-4" aria-hidden="true" />
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
aria-label={t("insights.coach.settingsAriaLabel")}
|
||||
title={t("insights.coach.settingsAriaLabel")}
|
||||
>
|
||||
<Settings className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
{trailingHeaderActions}
|
||||
</header>
|
||||
|
||||
<CoachDrawerBody
|
||||
sourcesRail={<SourcesRail />}
|
||||
historyRail={
|
||||
surface === "page" ? (
|
||||
<HistoryRail
|
||||
activeId={currentConversationId}
|
||||
onSelect={(id) => setCurrentConversationId(id)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
thread={
|
||||
<MessageThread
|
||||
conversation={conversation ?? null}
|
||||
@@ -212,14 +233,6 @@ export function CoachConversation({
|
||||
optimisticUser={send.optimisticUser}
|
||||
/>
|
||||
}
|
||||
disclaimer={
|
||||
<p
|
||||
data-slot="coach-composer-disclaimer"
|
||||
className="text-muted-foreground text-xs leading-relaxed"
|
||||
>
|
||||
{t("insights.coach.composerDisclaimer")}
|
||||
</p>
|
||||
}
|
||||
composer={
|
||||
<div>
|
||||
{/* v1.16.0 — pending self-context questions as tappable
|
||||
@@ -241,11 +254,14 @@ export function CoachConversation({
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onOpenHistoryTray={() => setHistoryTrayOpen(true)}
|
||||
onHistoryClick={
|
||||
surface === "drawer" && onRequestFullView
|
||||
? onRequestFullView
|
||||
: () => setHistoryTrayOpen(true)
|
||||
}
|
||||
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
|
||||
/>
|
||||
|
||||
<CoachSettingsSheet open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<MobileRailTray
|
||||
historyOpen={historyTrayOpen}
|
||||
onHistoryOpenChange={setHistoryTrayOpen}
|
||||
|
||||
@@ -15,66 +15,72 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* gives the test a node-renderable surface; the `<CoachDrawer>`
|
||||
* itself stays the integrating shell that owns state + the trays.
|
||||
*
|
||||
* The component is stateless: the parent owns `historyTrayOpen` /
|
||||
* `sourcesTrayOpen` and passes the open-toggles + slot content. This
|
||||
* keeps the trigger logic + the desktop rail layout in one place
|
||||
* while delegating the actual side-sheet to the parent.
|
||||
* The component is stateless: the parent owns the tray state and
|
||||
* passes the open-toggles + slot content.
|
||||
*
|
||||
* v1.12.0 — the history rail is no longer an inline column on any
|
||||
* viewport; it lives behind the always-visible "Conversations" toggle
|
||||
* and opens as the left-edge tray. The body therefore no longer takes
|
||||
* a `historyRail` slot — the parent wires that rail straight into the
|
||||
* `<MobileRailTray>`.
|
||||
* v1.16.1 — two structural changes:
|
||||
* - The sources rail lost its inline xl+ column. "What I draw on" is
|
||||
* now hidden by default on every viewport and slides in as the
|
||||
* right-edge tray when the user taps the toggle — the rail is a
|
||||
* look-up surface, not something worth a permanent 280 px gutter.
|
||||
* - The history affordance forked by surface: the drawer's
|
||||
* "Conversations" button hands off to the full-page Coach route
|
||||
* (the in-panel left tray kept breaking inside the sheet), while
|
||||
* the page renders the conversation list inline as a left column
|
||||
* on lg+ via the `historyRail` slot (tray below lg).
|
||||
*/
|
||||
export interface CoachDrawerBodyProps {
|
||||
sourcesRail: React.ReactNode;
|
||||
thread: React.ReactNode;
|
||||
composer: React.ReactNode;
|
||||
/** Clinical-decisions disclaimer line, pinned above the composer. */
|
||||
disclaimer: React.ReactNode;
|
||||
onOpenHistoryTray: () => void;
|
||||
/**
|
||||
* Inline conversation list (page surface only). When set, it renders
|
||||
* as a left column on lg+ and the history button collapses to the
|
||||
* sub-lg tray trigger. The drawer omits it.
|
||||
*/
|
||||
historyRail?: React.ReactNode;
|
||||
onHistoryClick: () => void;
|
||||
onOpenSourcesTray: () => void;
|
||||
}
|
||||
|
||||
export function CoachDrawerBody({
|
||||
sourcesRail,
|
||||
thread,
|
||||
composer,
|
||||
disclaimer,
|
||||
onOpenHistoryTray,
|
||||
historyRail,
|
||||
onHistoryClick,
|
||||
onOpenSourcesTray,
|
||||
}: CoachDrawerBodyProps) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-drawer-body"
|
||||
// v1.12.0 — the conversation history is no longer a permanent
|
||||
// left column. It used to mount inline on lg+ (a fixed 260px
|
||||
// gutter) which ate the chat width on laptops and read as a
|
||||
// takeover on the narrowed drawer cap. The rail now lives behind
|
||||
// the "Conversations" toggle on EVERY viewport (opens as the
|
||||
// left-edge tray), so the thread keeps the full width by default
|
||||
// and the user summons history on demand. The sources rail stays
|
||||
// inline at xl+ where there is room; below xl it stays on the
|
||||
// chevron tray.
|
||||
className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[1fr_280px]"
|
||||
className={
|
||||
historyRail
|
||||
? "grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[260px_1fr]"
|
||||
: "grid min-h-0 flex-1 grid-cols-1"
|
||||
}
|
||||
>
|
||||
{/* Inline history column — page surface, lg+ only. */}
|
||||
{historyRail && (
|
||||
<aside
|
||||
data-slot="coach-drawer-history"
|
||||
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
|
||||
>
|
||||
<h2 className="border-border/70 text-muted-foreground border-b p-3 text-[11px] font-medium tracking-wide uppercase">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</h2>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{historyRail}</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Centre — message thread. */}
|
||||
<main
|
||||
data-slot="coach-drawer-thread"
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
{/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the
|
||||
absolute overlay into a sub-header strip so the chevrons
|
||||
absolute overlay into a sub-header strip so the buttons
|
||||
sit at a 44 px tap target and never overlay the first
|
||||
message bubble.
|
||||
|
||||
v1.12.0 — the strip is now ALWAYS rendered because the
|
||||
"Conversations" toggle is the only way to reach the history
|
||||
rail on every viewport (the rail is no longer an inline
|
||||
column). The history trigger stays visible at all
|
||||
breakpoints; the sources trigger keeps `xl:hidden` since the
|
||||
sources rail is inline at xl+. */}
|
||||
message bubble. */}
|
||||
<div
|
||||
data-slot="coach-drawer-rail-tray-strip"
|
||||
className="border-border/70 flex items-center justify-between gap-2 border-b px-3 py-2"
|
||||
@@ -82,23 +88,29 @@ export function CoachDrawerBody({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onOpenHistoryTray}
|
||||
onClick={onHistoryClick}
|
||||
data-slot="coach-drawer-history-tray-trigger"
|
||||
aria-label={t("insights.coach.historyTitle")}
|
||||
className="min-h-11 gap-1.5 text-xs"
|
||||
// With an inline history column (page surface, lg+) the
|
||||
// button is redundant above lg and hides there.
|
||||
className={
|
||||
historyRail
|
||||
? "min-h-11 gap-1.5 text-xs lg:hidden"
|
||||
: "min-h-11 gap-1.5 text-xs"
|
||||
}
|
||||
>
|
||||
<MessagesSquare className="size-4" aria-hidden="true" />
|
||||
{t("insights.coach.historyTitle")}
|
||||
</Button>
|
||||
{/* Sources tray trigger stays visible up to xl since the
|
||||
inline sources rail is xl+ only. */}
|
||||
{/* Sources toggle — the rail is hidden by default on every
|
||||
viewport and opens as the right-edge tray. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onOpenSourcesTray}
|
||||
data-slot="coach-drawer-sources-tray-trigger"
|
||||
aria-label={t("insights.coach.sourcesTitle")}
|
||||
className="min-h-11 gap-1.5 text-xs xl:hidden"
|
||||
className="text-muted-foreground hover:text-foreground ml-auto min-h-11 gap-1.5 text-xs"
|
||||
>
|
||||
{t("insights.coach.sourcesTitle")}
|
||||
<ChevronLeft className="size-4" aria-hidden="true" />
|
||||
@@ -106,28 +118,14 @@ export function CoachDrawerBody({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">{thread}</div>
|
||||
{/* Composer pinned to the bottom. v1.12.0 — the clinical-
|
||||
decisions disclaimer renders directly above the composer
|
||||
(the single, always-visible home; the thread-bottom and
|
||||
sources-rail copies were removed). */}
|
||||
{/* Composer pinned to the bottom. */}
|
||||
<div
|
||||
data-slot="coach-drawer-composer"
|
||||
className="border-border/70 flex flex-col gap-2 border-t p-3 sm:p-4"
|
||||
>
|
||||
{disclaimer}
|
||||
{composer}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Sources rail — xl+ only. lg viewports surface it via the
|
||||
mobile chevron tray to keep the underlying /insights column
|
||||
readable behind the narrowed drawer cap. */}
|
||||
<aside
|
||||
data-slot="coach-drawer-sources"
|
||||
className="border-border/70 hidden h-full min-h-0 border-l xl:flex xl:flex-col"
|
||||
>
|
||||
{sourcesRail}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,6 +117,10 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) {
|
||||
prefill={prefill}
|
||||
autoFocusComposer
|
||||
registerReset={registerReset}
|
||||
// v1.16.1 — the "Conversations" affordance hands off to the
|
||||
// full-page route (same path as maximize) instead of opening
|
||||
// the broken in-panel left tray.
|
||||
onRequestFullView={handleMaximize}
|
||||
// Radix requires an accessible name + description on the
|
||||
// dialog content. Mount them through the title/description
|
||||
// render-props so the page can swap in a plain heading.
|
||||
|
||||
@@ -7,14 +7,9 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Info, Loader2, Send, Square } from "lucide-react";
|
||||
import { Loader2, Send, Square } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -186,10 +181,17 @@ export function CoachInput({
|
||||
onSubmit={handleFormSubmit}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{/* v1.16.1 — modern chat-app composer: a single row with the
|
||||
textarea and the send control on the same baseline. The
|
||||
textarea starts at one line and auto-grows; `items-end` keeps
|
||||
the button pinned to the input's last line as it grows. The
|
||||
old footer row (Info-popover hint + right-aligned button) is
|
||||
gone — Enter still sends and Shift+Enter still inserts a
|
||||
newline, the affordance just no longer needs explaining. */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 group rounded-md border",
|
||||
"p-2.5 transition-colors",
|
||||
"flex items-end gap-2 p-2 transition-colors",
|
||||
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:ring-2",
|
||||
)}
|
||||
>
|
||||
@@ -221,86 +223,51 @@ export function CoachInput({
|
||||
// 16 px floor and yanks the viewport on every Coach tap.
|
||||
// Desktop shrinks back to `text-sm` for the compact
|
||||
// composer.
|
||||
"w-full resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
|
||||
"min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
|
||||
// Centre the single-line state against the 36 px send
|
||||
// button so the placeholder and the icon share a baseline.
|
||||
"py-1.5",
|
||||
"max-h-[9.5rem] overflow-auto",
|
||||
"placeholder:text-muted-foreground disabled:opacity-60",
|
||||
)}
|
||||
/>
|
||||
{/* v1.4.27 F15 — the verbose "Enter to send, Shift+Enter for
|
||||
new line" prose footer used to render under the textarea;
|
||||
it ate ~140 px of vertical room for an Apple-Health-like
|
||||
single-message exchange. The hint now sits behind a tiny
|
||||
Info icon left of the send button; the existing
|
||||
translation string surfaces as the popover body so
|
||||
screen-reader users still hear it on focus.
|
||||
|
||||
v1.4.27 MB3 / CF-31 — swapped off Radix `<Tooltip>` (which
|
||||
never tap-toggles reliably on touch) onto `<Popover>` so
|
||||
mobile users can open the hint via a plain tap on the icon
|
||||
button. The aria-label still labels the icon for screen
|
||||
readers; the popover body carries the long-form copy. */}
|
||||
<div className="mt-1.5 flex items-center justify-end gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("insights.coach.composerHint")}
|
||||
data-slot="coach-input-hint"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
"focus-visible:ring-ring/50 inline-flex h-11 w-11",
|
||||
"items-center justify-center rounded",
|
||||
"focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="end"
|
||||
data-slot="coach-input-hint-body"
|
||||
>
|
||||
{t("insights.coach.composerHint")}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{isStreaming && onCancel ? (
|
||||
// While a reply streams, swap the send button for a Stop
|
||||
// control bound to the abort handler so the user can
|
||||
// interrupt a long or off-track reply instead of waiting it
|
||||
// out. The spinner stays inside the Stop affordance so the
|
||||
// in-progress state is still legible.
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
data-slot="coach-input-stop"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Square
|
||||
className="size-3.5 fill-current"
|
||||
{isStreaming && onCancel ? (
|
||||
// While a reply streams, swap the send button for a Stop
|
||||
// control bound to the abort handler so the user can
|
||||
// interrupt a long or off-track reply instead of waiting it
|
||||
// out.
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
data-slot="coach-input-stop"
|
||||
aria-label={t("insights.coach.stop")}
|
||||
title={t("insights.coach.stop")}
|
||||
className="size-9 shrink-0"
|
||||
>
|
||||
<Square className="size-3.5 fill-current" aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!canSubmit}
|
||||
data-slot="coach-input-send"
|
||||
aria-label={t("insights.coach.send")}
|
||||
title={t("insights.coach.send")}
|
||||
className="size-9 shrink-0"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{t("insights.coach.stop")}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!canSubmit}
|
||||
data-slot="coach-input-send"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
||||
) : (
|
||||
<Send className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
<span>{t("insights.coach.send")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Send className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,518 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import {
|
||||
DEFAULT_COACH_CLUSTERS,
|
||||
DEFAULT_COACH_PREFS,
|
||||
coachDataClusterEnum,
|
||||
type CoachDataCluster,
|
||||
type CoachDefaultWindow,
|
||||
type CoachExcludeMetric,
|
||||
type CoachPrefs,
|
||||
type CoachTone,
|
||||
type CoachVerbosity,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
|
||||
/**
|
||||
* v1.4.23 H4 — per-user Coach prompt-tuning sheet.
|
||||
*
|
||||
* Surfaced from the Coach drawer header cog. Reads + writes
|
||||
* `/api/auth/me/coach-prefs`. Mounted as a right-edge `<Sheet>` so it
|
||||
* sits on top of the drawer without conflicting with the existing
|
||||
* mobile rail trays (history = left, sources = right tray on `<xl`,
|
||||
* settings = right sheet — fine because the user only opens one at a
|
||||
* time).
|
||||
*
|
||||
* The four controls match the v1.4.23 H4 spec exactly: tone (3-state),
|
||||
* verbosity (3-state), excludeMetrics (multi-toggle), and
|
||||
* showEvidenceByDefault (single toggle).
|
||||
*
|
||||
* v1.4.23 W6 design-pass:
|
||||
* - Skeleton placeholder while the prefs are loading (Design-H3) so
|
||||
* the user never sees the `DEFAULT_COACH_PREFS` ghost form snap to
|
||||
* their saved values mid-paint.
|
||||
* - On save success the sheet emits a sonner toast (Design-H4) before
|
||||
* closing so the action is visibly acknowledged.
|
||||
*/
|
||||
export interface CoachSettingsSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [
|
||||
"bp",
|
||||
"weight",
|
||||
"pulse",
|
||||
"mood",
|
||||
"compliance",
|
||||
"hrv",
|
||||
"sleep",
|
||||
"resting_hr",
|
||||
"steps",
|
||||
];
|
||||
|
||||
// v1.4.36 W3 T2 — optional context blocks the user can opt out of.
|
||||
// Surfaced as a separate group below the per-metric exclude list so
|
||||
// the intent ("don't ship my profile / medication context to the
|
||||
// model") reads distinctly from the per-metric data toggles above.
|
||||
// Each row gates a single labelled block at the snapshot/feature layer
|
||||
// so empty blocks never reach the prompt.
|
||||
const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
|
||||
"sleep",
|
||||
"medications",
|
||||
"anthropometrics",
|
||||
];
|
||||
|
||||
// v1.4.25 W5 — picker options for the new `defaultWindow` preference.
|
||||
// Mirrors `CoachScopeWindow` so the chat route can fold the selection
|
||||
// straight into `scope.window` when the client didn't supply one.
|
||||
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
|
||||
"last7days",
|
||||
"last30days",
|
||||
"last90days",
|
||||
"allTime",
|
||||
];
|
||||
|
||||
// v1.7.0 — clustered, opt-in data sources. Order matches the design
|
||||
// sketch (default-on clinical clusters first). Each row shows the
|
||||
// cluster label + a member-preview hint + a toggle. Toggling writes
|
||||
// `dataClusters`; the snapshot builder expands the enabled set into the
|
||||
// source set, then subtracts the existing `excludeMetrics` post-filter.
|
||||
const CLUSTER_OPTIONS: CoachDataCluster[] = coachDataClusterEnum.options;
|
||||
|
||||
export function CoachSettingsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CoachSettingsSheetProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data: persisted } = useCoachPrefs({ enabled: open });
|
||||
|
||||
// Local working copy edited by the form. Re-syncs to the persisted
|
||||
// shape on every fetch via the same in-render setState pattern as
|
||||
// the drawer's `useResettableValue` (no useEffect — the
|
||||
// `react-hooks/set-state-in-effect` rule rejects setState inside an
|
||||
// effect when the source is a query result).
|
||||
const [draft, setDraft] = useState<CoachPrefs>(DEFAULT_COACH_PREFS);
|
||||
const [lastSeenPersisted, setLastSeenPersisted] = useState<CoachPrefs | null>(
|
||||
null,
|
||||
);
|
||||
if (persisted && persisted !== lastSeenPersisted) {
|
||||
setLastSeenPersisted(persisted);
|
||||
setDraft(persisted);
|
||||
}
|
||||
|
||||
// v1.7.2 — write through the shared `useSaveCoachPrefs` mutation so
|
||||
// the cog and the chat-side sources rail persist via one code path
|
||||
// and seed the same `coachPrefs()` cache key on success.
|
||||
const save = useSaveCoachPrefs({
|
||||
onSuccess: () => {
|
||||
toast.success(t("insights.coach.settingsSaved"));
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
function toggleExclude(metric: CoachExcludeMetric, next: boolean) {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
excludeMetrics: next
|
||||
? Array.from(new Set([...prev.excludeMetrics, metric]))
|
||||
: prev.excludeMetrics.filter((m) => m !== metric),
|
||||
}));
|
||||
}
|
||||
|
||||
// v1.7.0 — the persisted `dataClusters` may be `undefined` (the user
|
||||
// never opened the picker). Materialise the legacy default set the
|
||||
// first time the user toggles a cluster so the on-screen switches
|
||||
// reflect what the snapshot builder actually resolves.
|
||||
function toggleCluster(cluster: CoachDataCluster, next: boolean) {
|
||||
setDraft((prev) => {
|
||||
const current = prev.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS);
|
||||
const updated = next
|
||||
? Array.from(new Set([...current, cluster]))
|
||||
: current.filter((c) => c !== cluster);
|
||||
return { ...prev, dataClusters: updated };
|
||||
});
|
||||
}
|
||||
|
||||
// The switch state mirrors the resolved set: undefined → legacy
|
||||
// defaults, otherwise the explicit array (including the empty
|
||||
// "everything off" state).
|
||||
const enabledClusters = new Set<CoachDataCluster>(
|
||||
draft.dataClusters ?? DEFAULT_COACH_CLUSTERS,
|
||||
);
|
||||
|
||||
// Render a skeleton shell while the persisted prefs are loading so
|
||||
// we never render the form against `DEFAULT_COACH_PREFS` and snap
|
||||
// the controls when the fetch resolves a moment later.
|
||||
const isLoading = open && !persisted;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
// v1.4.27 R3d MB1 — the default `<SheetContent>` close-X is an
|
||||
// absolutely positioned `opacity-70 rounded-xs` button that
|
||||
// clashed with the in-header layout. Match the coach-drawer
|
||||
// pattern instead: hide the primitive's close-X and render a
|
||||
// matching ghost-icon `<SheetClose>` inline in the header so
|
||||
// the close affordance sits on the same baseline as the rest
|
||||
// of the sheet chrome and clears the 44 px tap target.
|
||||
showCloseButton={false}
|
||||
data-slot="coach-settings-sheet"
|
||||
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-[420px]"
|
||||
>
|
||||
<SheetHeader className="border-border/70 flex-row items-start gap-2 border-b p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SheetTitle className="text-sm font-semibold">
|
||||
{t("insights.coach.settingsTitle")}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-muted-foreground text-xs">
|
||||
{t("insights.coach.settingsDescription")}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="coach-settings-sheet-close"
|
||||
aria-label={t("common.close")}
|
||||
title={t("common.close")}
|
||||
className="text-muted-foreground hover:text-foreground -mt-1 shrink-0"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</SheetHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div
|
||||
data-slot="coach-prefs-skeleton"
|
||||
className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-44 w-full" />
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-9 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4">
|
||||
{/* v1.13.1 — Tone + Verbosity side by side (2-col on ≥sm,
|
||||
stacked on mobile) so the two short selects don't add a
|
||||
full extra scroll length to the sheet. */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{/* Tone */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-tone"
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
{t("insights.coach.settingsToneLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.tone}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-tone"
|
||||
data-slot="coach-prefs-tone"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="warm">
|
||||
{t("insights.coach.settingsToneWarm")}
|
||||
</SelectItem>
|
||||
<SelectItem value="neutral">
|
||||
{t("insights.coach.settingsToneNeutral")}
|
||||
</SelectItem>
|
||||
<SelectItem value="concise">
|
||||
{t("insights.coach.settingsToneConcise")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Verbosity */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-verbosity"
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
{t("insights.coach.settingsVerbosityLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.verbosity}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
verbosity: value as CoachVerbosity,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-verbosity"
|
||||
data-slot="coach-prefs-verbosity"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="brief">
|
||||
{t("insights.coach.settingsVerbosityBrief")}
|
||||
</SelectItem>
|
||||
<SelectItem value="default">
|
||||
{t("insights.coach.settingsVerbosityDefault")}
|
||||
</SelectItem>
|
||||
<SelectItem value="detailed">
|
||||
{t("insights.coach.settingsVerbosityDetailed")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v1.4.25 W5 — default analysis window. Sets the snapshot
|
||||
scope window the Coach reads when the client doesn't
|
||||
supply a per-conversation override. The drawer header
|
||||
carries a small pill that overrides the picked default
|
||||
for the current chat only. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-default-window"
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
{t("insights.coach.settingsDefaultWindowLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsDefaultWindowHint")}
|
||||
</p>
|
||||
<Select
|
||||
value={draft.defaultWindow}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
defaultWindow: value as CoachDefaultWindow,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-default-window"
|
||||
data-slot="coach-prefs-default-window"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_WINDOW_OPTIONS.map((w) => (
|
||||
<SelectItem key={w} value={w}>
|
||||
{t(`insights.coach.window.${w}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* v1.7.0 — clustered, opt-in data sources. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("insights.coach.settingsClustersLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsClustersHint")}
|
||||
</p>
|
||||
<ul
|
||||
data-slot="coach-prefs-cluster-list"
|
||||
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
|
||||
>
|
||||
{CLUSTER_OPTIONS.map((cluster) => {
|
||||
const id = `coach-prefs-cluster-${cluster}`;
|
||||
const checked = enabledClusters.has(cluster);
|
||||
return (
|
||||
<li
|
||||
key={cluster}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="cursor-pointer text-xs font-medium"
|
||||
>
|
||||
{t(`insights.coach.cluster.${cluster}.label`)}
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-[11px] leading-snug">
|
||||
{t(`insights.coach.cluster.${cluster}.hint`)}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
data-slot={`coach-prefs-cluster-${cluster}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => toggleCluster(cluster, next)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Exclude metrics — advanced fine-tune. Subtracts a single
|
||||
metric inside an otherwise-enabled cluster. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("insights.coach.settingsExcludeLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsExcludeHint")}
|
||||
</p>
|
||||
<ul
|
||||
data-slot="coach-prefs-exclude-list"
|
||||
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
|
||||
>
|
||||
{EXCLUDE_OPTIONS.map((metric) => {
|
||||
const id = `coach-prefs-exclude-${metric}`;
|
||||
const checked = draft.excludeMetrics.includes(metric);
|
||||
return (
|
||||
<li
|
||||
key={metric}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<Label htmlFor={id} className="cursor-pointer text-xs">
|
||||
{t(`insights.coach.metric.${metric}`)}
|
||||
</Label>
|
||||
<Switch
|
||||
id={id}
|
||||
data-slot={`coach-prefs-exclude-${metric}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => toggleExclude(metric, next)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* v1.4.36 W3 T2 — optional context blocks. Mirrors the
|
||||
EXCLUDE_OPTIONS list mechanically (same on/off switch
|
||||
pattern, same toggleExclude handler) but groups the
|
||||
profile / medication context toggles in their own
|
||||
section so the intent reads distinctly. Empty blocks
|
||||
are dropped at the snapshot/feature layer when the
|
||||
toggle is on. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("insights.coach.settingsContextLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsContextHint")}
|
||||
</p>
|
||||
<ul
|
||||
data-slot="coach-prefs-context-list"
|
||||
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
|
||||
>
|
||||
{CONTEXT_OPTIONS.map((metric) => {
|
||||
const id = `coach-prefs-context-${metric}`;
|
||||
const checked = draft.excludeMetrics.includes(metric);
|
||||
return (
|
||||
<li
|
||||
key={metric}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<Label htmlFor={id} className="cursor-pointer text-xs">
|
||||
{t(`insights.coach.metric.${metric}`)}
|
||||
</Label>
|
||||
<Switch
|
||||
id={id}
|
||||
data-slot={`coach-prefs-context-${metric}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => toggleExclude(metric, next)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* v1.4.27 F14 — the `showEvidenceByDefault` switch was
|
||||
retired. The evidence disclosure is now always closed
|
||||
by default and the user expands by click; surfacing
|
||||
raw values automatically created an UX trap. The
|
||||
persisted `coachPrefs.showEvidenceByDefault` field
|
||||
stays in the schema for backward compatibility, but no
|
||||
UI reads or writes it. */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-border/70 flex items-center justify-end gap-2 border-t p-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-slot="coach-prefs-cancel"
|
||||
>
|
||||
{t("insights.coach.settingsCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => save.mutate(draft)}
|
||||
disabled={save.isPending || isLoading}
|
||||
data-slot="coach-prefs-save"
|
||||
>
|
||||
{save.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{t("insights.coach.settingsSave")}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export function HistoryRail({
|
||||
(the rail still has to stand alone on desktop). */}
|
||||
<h3
|
||||
data-slot="coach-history-rail-heading"
|
||||
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase"
|
||||
>
|
||||
<MessagesSquare
|
||||
className="text-muted-foreground size-3.5"
|
||||
|
||||
@@ -444,7 +444,6 @@ function ChatBubble({
|
||||
"border-border/60 bg-muted/40 text-foreground",
|
||||
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
|
||||
"text-sm leading-relaxed whitespace-pre-wrap",
|
||||
inProgress && "animate-pulse motion-reduce:animate-none",
|
||||
)}
|
||||
>
|
||||
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from
|
||||
@@ -452,12 +451,19 @@ function ChatBubble({
|
||||
raw `content` is the streamed Coach reply (or the
|
||||
persisted twin after `done` fires); both paths are AI-
|
||||
authored so they share the same leak surface as the
|
||||
insight prose elsewhere. */}
|
||||
{content
|
||||
? stripChartTokens(content)
|
||||
: inProgress
|
||||
? t("insights.coach.thinking")
|
||||
: ""}
|
||||
insight prose elsewhere.
|
||||
|
||||
v1.16.1 — before the first token lands, the bubble shows
|
||||
a classic typing indicator (three pulsing dots) instead
|
||||
of the old whole-bubble pulse + "Thinking…" prose. The
|
||||
prose stays as the screen-reader announcement. */}
|
||||
{content ? (
|
||||
stripChartTokens(content)
|
||||
) : inProgress ? (
|
||||
<TypingDots label={t("insights.coach.thinking")} />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
{safeError && (
|
||||
<p className="text-warning/90 text-xs">{safeError}</p>
|
||||
@@ -572,6 +578,33 @@ function ChatBubble({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.1 — classic chat typing indicator: three dots pulsing in
|
||||
* sequence inside the assistant bubble, shown only between submit and
|
||||
* the first streamed token. Uses the stock `animate-pulse` keyframe
|
||||
* with staggered delays so no custom keyframe is introduced;
|
||||
* `motion-reduce` freezes the dots and the `label` stays as the
|
||||
* screen-reader text either way.
|
||||
*/
|
||||
function TypingDots({ label }: { label: string }) {
|
||||
return (
|
||||
<span
|
||||
data-slot="coach-typing-indicator"
|
||||
className="inline-flex items-center gap-1 py-1"
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/70 size-1.5 animate-pulse rounded-full motion-reduce:animate-none"
|
||||
style={{ animationDelay: `${i * 250}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface CoachMessageFeedbackProps {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
@@ -68,13 +68,15 @@ export function MobileRailTray({
|
||||
<div className="h-full min-h-0 overflow-y-auto">{historyRail}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{/* Sources tray — slides in from the right edge. `xl:hidden`
|
||||
because the sources rail is inline on xl+. */}
|
||||
{/* Sources tray — slides in from the right edge. v1.16.1 — the
|
||||
rail no longer has an inline xl+ column; the tray is the one
|
||||
sources surface on every viewport (hidden by default, opened
|
||||
via the toggle in the rail-tray strip). */}
|
||||
<Sheet open={sourcesOpen} onOpenChange={onSourcesOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
data-slot="coach-drawer-sources-tray"
|
||||
className="w-[88vw] max-w-[320px] p-0 xl:hidden"
|
||||
className="w-[88vw] max-w-[320px] p-0"
|
||||
>
|
||||
<SheetHeader className="border-border/70 border-b p-3">
|
||||
<SheetTitle className="text-sm">
|
||||
|
||||
@@ -95,11 +95,20 @@ export function SourcesRail({ className }: SourcesRailProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-sources-rail"
|
||||
className={cn("flex h-full min-h-0 flex-col gap-3 p-3", className)}
|
||||
// v1.16.1 — `min-w-0` + `overflow-x-hidden` pin the rail to its
|
||||
// column width: the switch rows used to push past the 280/320 px
|
||||
// container and paint a horizontal scrollbar under the rail.
|
||||
className={cn(
|
||||
"flex h-full min-h-0 min-w-0 flex-col gap-3 overflow-x-hidden p-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<h3
|
||||
data-slot="coach-sources-rail-heading"
|
||||
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
|
||||
// No horizontal inset — the heading sits flush with the row
|
||||
// boxes below it (headings and their content share one left
|
||||
// edge across the Coach surfaces).
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase"
|
||||
>
|
||||
<Eye className="text-muted-foreground size-3.5" aria-hidden="true" />
|
||||
{t("insights.coach.sourcesTitle")}
|
||||
@@ -111,7 +120,7 @@ export function SourcesRail({ className }: SourcesRailProps) {
|
||||
<div data-slot="coach-sources-window" className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="coach-sources-window-select"
|
||||
className="text-muted-foreground px-1 text-[10px] font-medium tracking-wide uppercase"
|
||||
className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.coach.windowLabel")}
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.16.1 — Settings → AI "Coach preferences" card.
|
||||
*
|
||||
* The prompt-tuning form moved out of the in-chat sheet
|
||||
* (`coach-settings-sheet.tsx`, retired) into the AI settings section so
|
||||
* model + behaviour live in one place; the chat header keeps a gear
|
||||
* that deep-links here. Reads + writes `/api/auth/me/coach-prefs`
|
||||
* through the shared `useCoachPrefs` / `useSaveCoachPrefs` pair — the
|
||||
* same persisted row the chat's "What I draw on" rail edits.
|
||||
*
|
||||
* One source of truth for data scope: `excludeMetrics`. The optional
|
||||
* context group (sleep / medications / profile) used to render its own
|
||||
* switches with the same checked-polarity as the exclude list, so
|
||||
* "exclude sleep" and "send sleep context" could read as both ON at
|
||||
* once. The context switches now DERIVE from the exclusion list
|
||||
* (checked = not excluded = sent), so the two groups can never
|
||||
* contradict each other.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import {
|
||||
DEFAULT_COACH_CLUSTERS,
|
||||
DEFAULT_COACH_PREFS,
|
||||
coachDataClusterEnum,
|
||||
type CoachDataCluster,
|
||||
type CoachDefaultWindow,
|
||||
type CoachExcludeMetric,
|
||||
type CoachPrefs,
|
||||
type CoachTone,
|
||||
type CoachVerbosity,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
|
||||
const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [
|
||||
"bp",
|
||||
"weight",
|
||||
"pulse",
|
||||
"mood",
|
||||
"compliance",
|
||||
"hrv",
|
||||
"sleep",
|
||||
"resting_hr",
|
||||
"steps",
|
||||
];
|
||||
|
||||
// Optional context blocks. Derived from `excludeMetrics` (see header
|
||||
// comment): a switch here reads "send this block" and is simply the
|
||||
// inverse of the exclusion entry.
|
||||
const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
|
||||
"sleep",
|
||||
"medications",
|
||||
"anthropometrics",
|
||||
];
|
||||
|
||||
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
|
||||
"last7days",
|
||||
"last30days",
|
||||
"last90days",
|
||||
"allTime",
|
||||
];
|
||||
|
||||
const CLUSTER_OPTIONS: CoachDataCluster[] = coachDataClusterEnum.options;
|
||||
|
||||
export interface CoachPrefsSectionProps {
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data: persisted } = useCoachPrefs({ enabled: isAuthenticated });
|
||||
|
||||
// Local working copy edited by the form; re-syncs to the persisted
|
||||
// shape on every fetch via the in-render setState pattern (the
|
||||
// `react-hooks/set-state-in-effect` rule rejects setState inside an
|
||||
// effect when the source is a query result).
|
||||
const [draft, setDraft] = useState<CoachPrefs>(DEFAULT_COACH_PREFS);
|
||||
const [lastSeenPersisted, setLastSeenPersisted] = useState<CoachPrefs | null>(
|
||||
null,
|
||||
);
|
||||
if (persisted && persisted !== lastSeenPersisted) {
|
||||
setLastSeenPersisted(persisted);
|
||||
setDraft(persisted);
|
||||
}
|
||||
|
||||
const save = useSaveCoachPrefs({
|
||||
onSuccess: () => {
|
||||
toast.success(t("insights.coach.settingsSaved"));
|
||||
},
|
||||
});
|
||||
|
||||
function setExcluded(metric: CoachExcludeMetric, excluded: boolean) {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
excludeMetrics: excluded
|
||||
? Array.from(new Set([...prev.excludeMetrics, metric]))
|
||||
: prev.excludeMetrics.filter((m) => m !== metric),
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleCluster(cluster: CoachDataCluster, next: boolean) {
|
||||
setDraft((prev) => {
|
||||
const current = prev.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS);
|
||||
const updated = next
|
||||
? Array.from(new Set([...current, cluster]))
|
||||
: current.filter((c) => c !== cluster);
|
||||
return { ...prev, dataClusters: updated };
|
||||
});
|
||||
}
|
||||
|
||||
const enabledClusters = new Set<CoachDataCluster>(
|
||||
draft.dataClusters ?? DEFAULT_COACH_CLUSTERS,
|
||||
);
|
||||
|
||||
const isLoading = isAuthenticated && !persisted;
|
||||
|
||||
return (
|
||||
<Card data-slot="coach-prefs-section">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
{t("insights.coach.settingsTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("insights.coach.settingsDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{isLoading ? (
|
||||
<CardContent
|
||||
data-slot="coach-prefs-skeleton"
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-44 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{/* Tone */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="coach-prefs-tone" className="text-xs font-medium">
|
||||
{t("insights.coach.settingsToneLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.tone}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="coach-prefs-tone" data-slot="coach-prefs-tone">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="warm">
|
||||
{t("insights.coach.settingsToneWarm")}
|
||||
</SelectItem>
|
||||
<SelectItem value="neutral">
|
||||
{t("insights.coach.settingsToneNeutral")}
|
||||
</SelectItem>
|
||||
<SelectItem value="concise">
|
||||
{t("insights.coach.settingsToneConcise")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Verbosity */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-verbosity"
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
{t("insights.coach.settingsVerbosityLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.verbosity}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
verbosity: value as CoachVerbosity,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-verbosity"
|
||||
data-slot="coach-prefs-verbosity"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="brief">
|
||||
{t("insights.coach.settingsVerbosityBrief")}
|
||||
</SelectItem>
|
||||
<SelectItem value="default">
|
||||
{t("insights.coach.settingsVerbosityDefault")}
|
||||
</SelectItem>
|
||||
<SelectItem value="detailed">
|
||||
{t("insights.coach.settingsVerbosityDetailed")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default analysis window */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-default-window"
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
{t("insights.coach.settingsDefaultWindowLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsDefaultWindowHint")}
|
||||
</p>
|
||||
<Select
|
||||
value={draft.defaultWindow}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
defaultWindow: value as CoachDefaultWindow,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-default-window"
|
||||
data-slot="coach-prefs-default-window"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_WINDOW_OPTIONS.map((w) => (
|
||||
<SelectItem key={w} value={w}>
|
||||
{t(`insights.coach.window.${w}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Clustered, opt-in data sources */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("insights.coach.settingsClustersLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsClustersHint")}
|
||||
</p>
|
||||
<ul
|
||||
data-slot="coach-prefs-cluster-list"
|
||||
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
|
||||
>
|
||||
{CLUSTER_OPTIONS.map((cluster) => {
|
||||
const id = `coach-prefs-cluster-${cluster}`;
|
||||
const checked = enabledClusters.has(cluster);
|
||||
return (
|
||||
<li
|
||||
key={cluster}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="cursor-pointer text-xs font-medium"
|
||||
>
|
||||
{t(`insights.coach.cluster.${cluster}.label`)}
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-[11px] leading-snug">
|
||||
{t(`insights.coach.cluster.${cluster}.hint`)}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
data-slot={`coach-prefs-cluster-${cluster}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => toggleCluster(cluster, next)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Exclude metrics — checked = excluded. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("insights.coach.settingsExcludeLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsExcludeHint")}
|
||||
</p>
|
||||
<ul
|
||||
data-slot="coach-prefs-exclude-list"
|
||||
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
|
||||
>
|
||||
{EXCLUDE_OPTIONS.map((metric) => {
|
||||
const id = `coach-prefs-exclude-${metric}`;
|
||||
const checked = draft.excludeMetrics.includes(metric);
|
||||
return (
|
||||
<li
|
||||
key={metric}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<Label htmlFor={id} className="cursor-pointer text-xs">
|
||||
{t(`insights.coach.metric.${metric}`)}
|
||||
</Label>
|
||||
<Switch
|
||||
id={id}
|
||||
data-slot={`coach-prefs-exclude-${metric}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => setExcluded(metric, next)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Optional context blocks — checked = sent, derived as the
|
||||
inverse of the exclusion list so the two groups can never
|
||||
contradict each other. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("insights.coach.settingsContextLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsContextHint")}
|
||||
</p>
|
||||
<ul
|
||||
data-slot="coach-prefs-context-list"
|
||||
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
|
||||
>
|
||||
{CONTEXT_OPTIONS.map((metric) => {
|
||||
const id = `coach-prefs-context-${metric}`;
|
||||
const sent = !draft.excludeMetrics.includes(metric);
|
||||
return (
|
||||
<li
|
||||
key={metric}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<Label htmlFor={id} className="cursor-pointer text-xs">
|
||||
{t(`insights.coach.metric.${metric}`)}
|
||||
</Label>
|
||||
<Switch
|
||||
id={id}
|
||||
data-slot={`coach-prefs-context-${metric}`}
|
||||
checked={sent}
|
||||
onCheckedChange={(next) => setExcluded(metric, !next)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
<CardFooter className="justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => save.mutate(draft)}
|
||||
disabled={save.isPending || isLoading}
|
||||
data-slot="coach-prefs-save"
|
||||
>
|
||||
{save.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{t("insights.coach.settingsSave")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user