mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(coach): add a full-page conversation mode with a maximize control
Extract the Coach chat surface (header actions, message thread, composer, provenance rails, settings, mobile trays) into a shared <CoachConversation> component so the drawer and a new full-page route render the exact same implementation — no forked chat logic. The drawer keeps only its <Sheet> chrome (close button + maximize control); the page supplies a minimize-back-to-drawer control. The maximize control in the drawer header navigates to /insights/coach and closes the drawer (aborting any in-flight stream); the page's minimize control routes back to /insights and reopens the drawer through the shared launch context. Both surfaces preserve incremental streaming, collapsible history + provenance, the single composer disclaimer, the auto-grow composer, refusal / budget handling, and the aiMode gating — the full page redirects to /insights when the operator flag is off or the user has opted out. Lift useResettableValue / nextResettableValue into their own module so the shared surface seeds its composer from the same controlled-prefill contract without importing the drawer shell. Register the new route in the coach feature-flag and per-user-disable gate-site invariants, and add the maximize / minimize labels across all six locales.
This commit is contained in:
1 parent
e0f60d82cc
commit
488cc09ae9
12 files changed
+506
-322
No files matched your search
@@ -1736,6 +1736,8 @@
|
||||
"errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.",
|
||||
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
|
||||
"newChat": "Neuer Chat",
|
||||
"maximizeAriaLabel": "Im Vollbild öffnen",
|
||||
"minimizeAriaLabel": "Zurück zum Seitenfenster",
|
||||
"send": "Senden",
|
||||
"stop": "Stopp",
|
||||
"thinking": "Denke nach…",
|
||||
|
||||
@@ -1736,6 +1736,8 @@
|
||||
"errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.",
|
||||
"tagline": "Personal health coach, grounded in your data.",
|
||||
"newChat": "New chat",
|
||||
"maximizeAriaLabel": "Open in full page",
|
||||
"minimizeAriaLabel": "Back to the drawer",
|
||||
"send": "Send",
|
||||
"stop": "Stop",
|
||||
"thinking": "Thinking…",
|
||||
|
||||
@@ -1736,6 +1736,8 @@
|
||||
"errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.",
|
||||
"tagline": "Coach personal de salud, apoyado en tus datos.",
|
||||
"newChat": "Nueva conversación",
|
||||
"maximizeAriaLabel": "Abrir en pantalla completa",
|
||||
"minimizeAriaLabel": "Volver al panel",
|
||||
"send": "Enviar",
|
||||
"stop": "Detener",
|
||||
"thinking": "Pensando…",
|
||||
|
||||
@@ -1736,6 +1736,8 @@
|
||||
"errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.",
|
||||
"tagline": "Coach personnel de santé, ancré sur tes données.",
|
||||
"newChat": "Nouvelle conversation",
|
||||
"maximizeAriaLabel": "Ouvrir en pleine page",
|
||||
"minimizeAriaLabel": "Revenir au panneau",
|
||||
"send": "Envoyer",
|
||||
"stop": "Arrêter",
|
||||
"thinking": "Réflexion en cours…",
|
||||
|
||||
@@ -1736,6 +1736,8 @@
|
||||
"errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.",
|
||||
"tagline": "Coach personale di salute, basato sui tuoi dati.",
|
||||
"newChat": "Nuova chat",
|
||||
"maximizeAriaLabel": "Apri a pagina intera",
|
||||
"minimizeAriaLabel": "Torna al pannello",
|
||||
"send": "Invia",
|
||||
"stop": "Interrompi",
|
||||
"thinking": "Sto pensando…",
|
||||
|
||||
@@ -1736,6 +1736,8 @@
|
||||
"errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.",
|
||||
"tagline": "Osobisty coach zdrowia, oparty na twoich danych.",
|
||||
"newChat": "Nowa rozmowa",
|
||||
"maximizeAriaLabel": "Otwórz na pełnej stronie",
|
||||
"minimizeAriaLabel": "Powrót do panelu",
|
||||
"send": "Wyślij",
|
||||
"stop": "Zatrzymaj",
|
||||
"thinking": "Myślę…",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Minimize2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
|
||||
/**
|
||||
* v1.12.0 (Coach v2 #6) — full-page Coach conversation.
|
||||
*
|
||||
* Renders the exact same `<CoachConversation>` surface the drawer uses
|
||||
* (history + streaming thread + composer + provenance + settings) at
|
||||
* full width and height. There is no forked chat logic — the page is a
|
||||
* thin chrome wrapper that supplies a "minimize back to drawer"
|
||||
* control where the drawer supplies "maximize" + "close".
|
||||
*
|
||||
* The route lives inside `app/insights/layout.tsx` (sticky tab strip +
|
||||
* mobile FAB) and the authenticated `<AuthShell>` (which provides the
|
||||
* `<CoachLaunchProvider>` the minimize control reopens the drawer
|
||||
* through), so the page inherits the same Coach feature gating as every
|
||||
* other Insights surface. When the operator's master flag is off or the
|
||||
* user opted out, the page redirects back to `/insights` rather than
|
||||
* painting a dead chat shell.
|
||||
*/
|
||||
export default function CoachPage() {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
|
||||
const coachUnavailable = !flags.coach || disableCoach;
|
||||
|
||||
// Coach surface gating mirrors `<CoachLaunchButton>` / `<LayoutCoachFab>`:
|
||||
// operator master flag OR per-user opt-out hides the Coach entirely.
|
||||
// Send a direct navigator back to the Insights mother page so the
|
||||
// route is never a dead-end.
|
||||
useEffect(() => {
|
||||
if (coachUnavailable) {
|
||||
router.replace("/insights");
|
||||
}
|
||||
}, [coachUnavailable, router]);
|
||||
|
||||
if (coachUnavailable) return null;
|
||||
|
||||
const handleMinimize = () => {
|
||||
// Hand the conversation back to the drawer overlay: route to the
|
||||
// Insights mother page and reopen the drawer through the shared
|
||||
// launch context.
|
||||
router.push("/insights");
|
||||
launch?.askCoach(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-page"
|
||||
className="flex h-[calc(100dvh-13rem)] min-h-[28rem] flex-col overflow-hidden rounded-2xl border"
|
||||
>
|
||||
<CoachConversation
|
||||
surface="page"
|
||||
autoFocusComposer
|
||||
renderTitle={(title) => (
|
||||
<h1 className="min-w-0 truncate text-sm font-semibold">{title}</h1>
|
||||
)}
|
||||
leadingHeaderActions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleMinimize}
|
||||
data-slot="coach-page-minimize"
|
||||
aria-label={t("insights.coach.minimizeAriaLabel")}
|
||||
title={t("insights.coach.minimizeAriaLabel")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
>
|
||||
<Minimize2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Settings, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
import { SourcesRail } from "./sources-rail";
|
||||
import { useResettableValue } from "./use-resettable-value";
|
||||
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
|
||||
|
||||
/**
|
||||
* v1.12.0 (Coach v2 #6) — shared chat surface for both the Coach drawer
|
||||
* (`<CoachDrawer>`, right/bottom `<Sheet>` overlay) and the full-page
|
||||
* Coach route (`/insights/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.
|
||||
* 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
|
||||
* control. Neither forks the chat logic.
|
||||
*
|
||||
* The title + description render through `renderTitle` / `renderDescription`
|
||||
* render-props so the drawer can mount the Radix `<SheetTitle>` /
|
||||
* `<SheetDescription>` (required for the dialog's accessible name) while
|
||||
* the page mounts a plain `<h1>` / `<p>`.
|
||||
*/
|
||||
export interface CoachConversationProps {
|
||||
/**
|
||||
* Pre-fill for the composer (suggested-prompt chip click). Resets the
|
||||
* composer on change via `useResettableValue`; the user can still type
|
||||
* freely between prop changes.
|
||||
*/
|
||||
prefill?: string | null;
|
||||
/**
|
||||
* Renders the conversation title. The surface passes the resolved
|
||||
* title string; the drawer wraps it in `<SheetTitle>`, the page in an
|
||||
* `<h1>`. Falls back to a plain element when omitted.
|
||||
*/
|
||||
renderTitle?: (title: string) => React.ReactNode;
|
||||
/** Renders the tagline line beneath the title (see `renderTitle`). */
|
||||
renderDescription?: (tagline: string) => React.ReactNode;
|
||||
/**
|
||||
* Chrome-specific header action(s) — the maximize control (drawer) or
|
||||
* the minimize control (page) — mounted before the avatar so the
|
||||
* surface-toggle sits at the leading edge of the header.
|
||||
*/
|
||||
leadingHeaderActions?: React.ReactNode;
|
||||
/**
|
||||
* Chrome-specific trailing control — the drawer's close button. The
|
||||
* page leaves this empty (it has no close affordance of its own).
|
||||
*/
|
||||
trailingHeaderActions?: React.ReactNode;
|
||||
/**
|
||||
* Focus the composer on mount. The drawer sets this so the composer
|
||||
* takes focus when the sheet opens; the page sets it so a keyboard
|
||||
* user lands in the composer after the route change.
|
||||
*/
|
||||
autoFocusComposer?: boolean;
|
||||
/**
|
||||
* Hands the chrome an imperative reset (abort any in-flight SSE +
|
||||
* clear the thread + composer). The drawer wires this to its close
|
||||
* handler so closing the overlay does not leave a stream running. The
|
||||
* page omits it — navigating away unmounts the whole subtree, which
|
||||
* tears the stream down on its own.
|
||||
*/
|
||||
registerReset?: (reset: () => void) => void;
|
||||
/** className passthrough for the outer flex column. */
|
||||
className?: string;
|
||||
/** data-variant on the root, surfaced for e2e + styling hooks. */
|
||||
surface: "drawer" | "page";
|
||||
}
|
||||
|
||||
export function CoachConversation({
|
||||
prefill,
|
||||
renderTitle,
|
||||
renderDescription,
|
||||
leadingHeaderActions,
|
||||
trailingHeaderActions,
|
||||
autoFocusComposer,
|
||||
registerReset,
|
||||
className,
|
||||
surface,
|
||||
}: CoachConversationProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const [currentConversationId, setCurrentConversationId] = useState<
|
||||
string | null
|
||||
>(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);
|
||||
const send = useSendCoachMessage({
|
||||
onDone: (resolvedId) => {
|
||||
setCurrentConversationId(resolvedId);
|
||||
},
|
||||
});
|
||||
|
||||
// Hand the chrome an imperative reset so the drawer can abort the
|
||||
// in-flight SSE stream + clear the thread on close. Re-registered when
|
||||
// the closed-over state setters / send hook change identity so the
|
||||
// callback never captures a stale conversation id.
|
||||
useEffect(() => {
|
||||
if (!registerReset) return;
|
||||
registerReset(() => {
|
||||
send.cancel();
|
||||
setCurrentConversationId(null);
|
||||
setInputValue("");
|
||||
});
|
||||
}, [registerReset, send, setInputValue]);
|
||||
|
||||
async function handleSubmit(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || send.isStreaming) return;
|
||||
setInputValue("");
|
||||
await send.send({
|
||||
conversationId: currentConversationId ?? undefined,
|
||||
message: trimmed,
|
||||
});
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
setCurrentConversationId(null);
|
||||
setInputValue("");
|
||||
send.reset();
|
||||
}
|
||||
|
||||
const title = conversation?.title ?? t("insights.coach.newChat");
|
||||
const tagline = t("insights.coach.tagline");
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-conversation"
|
||||
data-variant={surface}
|
||||
className={cn("flex min-h-0 flex-1 flex-col", className)}
|
||||
>
|
||||
<header
|
||||
data-slot="coach-conversation-header"
|
||||
className="border-border/70 flex flex-row items-center gap-2 border-b p-3 sm:gap-3 sm:p-4"
|
||||
>
|
||||
{leadingHeaderActions}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="from-dracula-purple to-dracula-pink flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
|
||||
>
|
||||
<Sparkles className="text-background size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renderTitle ? (
|
||||
renderTitle(title)
|
||||
) : (
|
||||
<p className="min-w-0 truncate text-sm font-semibold">{title}</p>
|
||||
)}
|
||||
{renderDescription ? (
|
||||
renderDescription(tagline)
|
||||
) : (
|
||||
<p className="text-muted-foreground truncate text-[11px]">
|
||||
{tagline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNewChat}
|
||||
data-slot="coach-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
title={t("insights.coach.newChat")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
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" />
|
||||
</Button>
|
||||
{trailingHeaderActions}
|
||||
</header>
|
||||
|
||||
<CoachDrawerBody
|
||||
sourcesRail={<SourcesRail />}
|
||||
thread={
|
||||
<MessageThread
|
||||
conversation={conversation ?? null}
|
||||
streaming={send.streaming}
|
||||
optimisticUser={send.optimisticUser}
|
||||
/>
|
||||
}
|
||||
disclaimer={
|
||||
<p
|
||||
data-slot="coach-composer-disclaimer"
|
||||
className="text-muted-foreground text-xs leading-relaxed"
|
||||
>
|
||||
{t("insights.coach.composerDisclaimer")}
|
||||
</p>
|
||||
}
|
||||
composer={
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={() => handleSubmit(inputValue)}
|
||||
onCancel={send.cancel}
|
||||
disabled={send.isStreaming}
|
||||
isStreaming={send.isStreaming}
|
||||
autoFocusOnOpen={autoFocusComposer}
|
||||
/>
|
||||
}
|
||||
onOpenHistoryTray={() => setHistoryTrayOpen(true)}
|
||||
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
|
||||
/>
|
||||
|
||||
<CoachSettingsSheet open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<MobileRailTray
|
||||
historyOpen={historyTrayOpen}
|
||||
onHistoryOpenChange={setHistoryTrayOpen}
|
||||
historyRail={
|
||||
<HistoryRail
|
||||
activeId={currentConversationId}
|
||||
onSelect={(id) => {
|
||||
setCurrentConversationId(id);
|
||||
setHistoryTrayOpen(false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sourcesOpen={sourcesTrayOpen}
|
||||
onSourcesOpenChange={setSourcesTrayOpen}
|
||||
sourcesRail={<SourcesRail />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Plus, Settings, Sparkles, X } from "lucide-react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Maximize2, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -10,117 +10,48 @@ import {
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
|
||||
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";
|
||||
import { SourcesRail } from "./sources-rail";
|
||||
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
|
||||
import { CoachConversation } from "./coach-conversation";
|
||||
|
||||
// v1.12.0 — `useResettableValue` + `nextResettableValue` moved into
|
||||
// `./use-resettable-value` so the shared `<CoachConversation>` surface
|
||||
// can seed its composer without importing the drawer shell. Re-exported
|
||||
// here for the existing prefill-controller unit test import path.
|
||||
export {
|
||||
useResettableValue,
|
||||
nextResettableValue,
|
||||
} from "./use-resettable-value";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B2b — AI Coach drawer (right-side `<Sheet>` overlay).
|
||||
*
|
||||
* Mounts above `/insights` so the user keeps the dashboard context
|
||||
* behind it. Single-column on `<lg`; on desktop the history rail and
|
||||
* sources rail mount alongside the message thread (delivered in
|
||||
* commit 3, this file lays out the slots).
|
||||
*
|
||||
* The drawer is fully-controlled by the parent (`open` /
|
||||
* behind it. The drawer is fully-controlled by the parent (`open` /
|
||||
* `onOpenChange`). The `prefill` prop lets the hero strip's
|
||||
* suggested-prompt chips pre-populate the input on open.
|
||||
*
|
||||
* History rail + sources rail import-load asynchronously to keep the
|
||||
* drawer's first-paint cheap. Until commit 3 lands, those slots
|
||||
* render as visual placeholders that match the column widths.
|
||||
* v1.12.0 (Coach v2 #6) — the chat surface itself (header actions,
|
||||
* body, thread, composer, rails, settings, mobile trays) is now the
|
||||
* shared `<CoachConversation>` component, reused verbatim by the
|
||||
* full-page Coach route (`/insights/coach`). This file is the drawer
|
||||
* CHROME only: the `<Sheet>` wrapper, the close button, and the
|
||||
* maximize control that hands the conversation off to the full page.
|
||||
*/
|
||||
export interface CoachDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
/** Optional pre-fill for the input box (suggested-prompt chip click). */
|
||||
prefill?: string | null;
|
||||
/**
|
||||
* Optional rail slots — passed by the parent so commit-3 can mount
|
||||
* the history + sources rails without coupling them to the drawer
|
||||
* shell. Falls through to a no-op when omitted.
|
||||
*/
|
||||
historyRail?: React.ReactNode;
|
||||
sourcesRail?: React.ReactNode;
|
||||
/**
|
||||
* Composer slot — the parent wires `<CoachInput>` here in commit 3
|
||||
* so this shell stays presentational. The shell renders its own
|
||||
* minimal input fallback when no slot is supplied (used during the
|
||||
* commit-2 boundary to keep the drawer functional in isolation).
|
||||
*/
|
||||
composer?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.23 H3 — local state seeded from a controlled prop, reset on
|
||||
* prop change. Same render-phase pattern React's docs recommend in
|
||||
* place of `useEffect(() => setState(prop), [prop])` (which the
|
||||
* `react-hooks/set-state-in-effect` ESLint rule banned). The reset
|
||||
* runs during render: React detects the queued setState, restarts
|
||||
* the render with the new value, and commits a single coherent
|
||||
* snapshot — no double paint, no flash of stale state.
|
||||
*
|
||||
* Exported so the drawer's prefill-reset behaviour can be unit-
|
||||
* tested in isolation without standing up the whole Sheet portal.
|
||||
*/
|
||||
export function useResettableValue<T>(
|
||||
controlledValue: T,
|
||||
): [T, Dispatch<SetStateAction<T>>] {
|
||||
const [value, setValue] = useState<T>(controlledValue);
|
||||
// Mirror the last observed controlled value via a sibling useState
|
||||
// pair (per React docs:
|
||||
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes).
|
||||
// useRef is the wrong tool here — ESLint rejects ref read+write
|
||||
// during render, and useState already gives us identity tracking
|
||||
// with no extra cost.
|
||||
const [lastSeen, setLastSeen] = useState<T>(controlledValue);
|
||||
if (!Object.is(controlledValue, lastSeen)) {
|
||||
setLastSeen(controlledValue);
|
||||
setValue(controlledValue);
|
||||
}
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.23 H3 — pure decision function behind `useResettableValue`.
|
||||
* Given the previous controlled value the hook recorded and the
|
||||
* incoming controlled value, returns either `{ reset: true, value }`
|
||||
* (the next render must seed local state with `value`) or
|
||||
* `{ reset: false }` (local state survives — the user's edits are
|
||||
* preserved). Pure + dependency-free so it tests cleanly without a
|
||||
* React renderer — pin the contract here and trust the hook
|
||||
* implementation to wire the same comparison.
|
||||
*/
|
||||
export function nextResettableValue<T>(
|
||||
previous: T,
|
||||
incoming: T,
|
||||
): { reset: true; value: T } | { reset: false } {
|
||||
return Object.is(previous, incoming)
|
||||
? { reset: false }
|
||||
: { reset: true, value: incoming };
|
||||
}
|
||||
|
||||
export function CoachDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
prefill,
|
||||
historyRail,
|
||||
sourcesRail,
|
||||
composer,
|
||||
}: CoachDrawerProps) {
|
||||
export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
// v1.4.27 R3d MB1 — below the `sm` breakpoint (640 px) the Coach
|
||||
// drawer slides up from the bottom edge of the viewport instead of
|
||||
// sliding in from the right. Right-side slide makes the back-arrow
|
||||
@@ -129,86 +60,33 @@ export function CoachDrawer({
|
||||
// its existing right-side slide and `sm:max-w-[720px]` cap.
|
||||
const isPhoneViewport = useIsMobile("sm");
|
||||
|
||||
const [currentConversationId, setCurrentConversationId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
// v1.4.20 phase B4 — mobile rail trays. The history + sources rails
|
||||
// are hidden on `<lg` from B2b; the chevron buttons below toggle them
|
||||
// as side-sheets so the user can browse conversations + see what
|
||||
// data the Coach is using without losing the message thread context.
|
||||
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
|
||||
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
|
||||
// v1.4.23 H4 — Coach prompt-tuning sheet. The v1.4.22 B5 audit had
|
||||
// removed the placeholder cog from the drawer header; v1.4.23 H4
|
||||
// returns it with a real surface backed by `/api/auth/me/coach-prefs`.
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
// v1.4.23 H3 — `prefill` is now a fully-controlled prop. The
|
||||
// v1.4.20 implementation used `key={prefill}` on the parent mount
|
||||
// to force a fresh `useState()` initialiser run on every prefill
|
||||
// transition (the "weaponise-React-keys" pattern senior-dev
|
||||
// review flagged as Sr-HIGH-4). The replacement is a tiny
|
||||
// `useResettableValue` hook below: tracks the last observed
|
||||
// prefill in a ref and schedules a same-render state update when
|
||||
// the prop changes — a textbook React render-phase update,
|
||||
// ESLint-clean against `react-hooks/set-state-in-effect`. The
|
||||
// user can still type freely; the drawer only resets the composer
|
||||
// when the parent changes which suggested-prompt chip is active.
|
||||
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
|
||||
|
||||
// v1.7.2 — the data-source scope is no longer ephemeral drawer state.
|
||||
// Both the chat-side sources rail and the settings cog now drive the
|
||||
// persisted `coachPrefsJson` row (clusters + default window) through
|
||||
// `PUT /api/auth/me/coach-prefs`. The chat request carries no `scope`
|
||||
// override at all, so the snapshot builder expands the saved clusters
|
||||
// and folds the saved window server-side — "what the rail shows" and
|
||||
// "what the model receives" cannot drift.
|
||||
|
||||
const { data: conversation } = useCoachConversation(currentConversationId);
|
||||
const send = useSendCoachMessage({
|
||||
onDone: (resolvedId) => {
|
||||
setCurrentConversationId(resolvedId);
|
||||
},
|
||||
});
|
||||
// The shared surface hands us an imperative reset so closing the
|
||||
// drawer can abort an in-flight SSE stream + clear the thread.
|
||||
const resetRef = useRef<(() => void) | null>(null);
|
||||
const registerReset = useCallback((reset: () => void) => {
|
||||
resetRef.current = reset;
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) {
|
||||
// Abort any in-flight streamed reply so closing the drawer
|
||||
// doesn't leave the SSE request running in the background.
|
||||
send.cancel();
|
||||
// Reset thread + composer on close so the next open starts on
|
||||
// the rail's empty hint instead of re-rendering the previous
|
||||
// conversation by accident.
|
||||
setCurrentConversationId(null);
|
||||
setInputValue("");
|
||||
// Abort any in-flight streamed reply + reset the thread so the
|
||||
// next open starts on the rail's empty hint.
|
||||
resetRef.current?.();
|
||||
}
|
||||
onOpenChange(next);
|
||||
},
|
||||
[onOpenChange, send, setInputValue],
|
||||
[onOpenChange],
|
||||
);
|
||||
|
||||
async function handleSubmit(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || send.isStreaming) return;
|
||||
setInputValue("");
|
||||
// v1.7.2 — no `scope` override on the wire. The route resolves the
|
||||
// source set and window from the user's persisted `coachPrefsJson`
|
||||
// (the same row the sources rail + settings cog edit), so the chat
|
||||
// request stays minimal and the prompt scope is guaranteed to match
|
||||
// what the rail displays.
|
||||
await send.send({
|
||||
conversationId: currentConversationId ?? undefined,
|
||||
message: trimmed,
|
||||
});
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
setCurrentConversationId(null);
|
||||
setInputValue("");
|
||||
send.reset();
|
||||
}
|
||||
|
||||
const drawerTitle = conversation?.title ?? t("insights.coach.newChat");
|
||||
const handleMaximize = useCallback(() => {
|
||||
// Hand the conversation off to the full-page surface. Close the
|
||||
// drawer first (aborts any in-flight stream via `handleOpenChange`)
|
||||
// then route to the dedicated page.
|
||||
onOpenChange(false);
|
||||
resetRef.current?.();
|
||||
router.push("/insights/coach");
|
||||
}, [onOpenChange, router]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -218,189 +96,74 @@ export function CoachDrawer({
|
||||
// above `sm` it keeps its existing right-side slide.
|
||||
side={isPhoneViewport ? "bottom" : "right"}
|
||||
// v1.4.25 W5 — render our own Close button inside the header so
|
||||
// X / cog / new-chat sit on the same baseline with identical
|
||||
// size, color, and hit target. The Sheet's default close-X is
|
||||
// absolutely positioned with `opacity-70` + tiny `rounded-xs`,
|
||||
// which Marc flagged as misaligned with the rest of the header
|
||||
// cluster.
|
||||
// X / maximize sit on the same baseline with identical size,
|
||||
// color, and hit target.
|
||||
showCloseButton={false}
|
||||
data-slot="coach-drawer"
|
||||
data-variant={isPhoneViewport ? "bottom-sheet" : "side-sheet"}
|
||||
className={cn(
|
||||
// Drawer keeps the dashboard context behind it. On laptops
|
||||
// (1280-1366px viewports) the previous lg:max-w-[1080px] cap
|
||||
// left only ~200-286px of underlying /insights visible — a
|
||||
// takeover, not a sheet. Drop the lg cap to min(960px,75vw)
|
||||
// so /insights always retains a readable column; restore
|
||||
// the wider three-column layout at xl+ (≥1280px sources rail
|
||||
// is hidden below xl by the body's lg:hidden chevron rules).
|
||||
// Drawer keeps the dashboard context behind it. Drop the lg
|
||||
// cap to min(960px,75vw) so /insights always retains a
|
||||
// readable column; restore the wider layout at xl+.
|
||||
"w-full p-0 sm:max-w-[720px]",
|
||||
"lg:!max-w-[min(960px,75vw)] xl:!max-w-[1080px]",
|
||||
isPhoneViewport
|
||||
? // Bottom-sheet caps at 90 dvh so a 10 % slice of the
|
||||
// underlying /insights page stays visible — same
|
||||
// convention as every other mobile sheet on the app via
|
||||
// <ResponsiveSheet>'s phone branch (v1.4.28 R3c BK-M5
|
||||
// alignment; previously 95 dvh, which left only a 5 %
|
||||
// sliver and read as a takeover instead of a sheet).
|
||||
// Rounded top corners match the iOS bottom-sheet feel.
|
||||
"flex h-[90dvh] max-h-[90dvh] flex-col gap-0 rounded-t-2xl"
|
||||
? "flex h-[90dvh] max-h-[90dvh] flex-col gap-0 rounded-t-2xl"
|
||||
: "flex h-[100dvh] flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{/* Header (full width). Avatar + title + new-chat button +
|
||||
settings cog + close X. v1.4.25 W5 — the three header
|
||||
actions (new chat, settings, close) all use the same
|
||||
`ghost / size-icon / size-9` shape so they share a single
|
||||
visual cluster instead of feeling like three different
|
||||
controls. The close X used to be the Sheet's absolutely
|
||||
positioned default; bringing it inline normalises the row
|
||||
and frees the `pr-*` reservation. */}
|
||||
<SheetHeader
|
||||
data-slot="coach-drawer-header"
|
||||
className="border-border/70 flex-row items-center gap-2 border-b p-3 sm:gap-3 sm:p-4"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="from-dracula-purple to-dracula-pink flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
|
||||
>
|
||||
<Sparkles className="text-background size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* v1.4.27 R3d MB4 / CF-73 — pin `min-w-0` on the title
|
||||
node itself in addition to the wrapper so the
|
||||
truncate clipping survives any flex-shrink quirk
|
||||
with very long conversation titles. */}
|
||||
<CoachConversation
|
||||
surface="drawer"
|
||||
prefill={prefill}
|
||||
autoFocusComposer
|
||||
registerReset={registerReset}
|
||||
// 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.
|
||||
renderTitle={(title) => (
|
||||
<SheetTitle className="min-w-0 truncate text-sm font-semibold">
|
||||
{drawerTitle}
|
||||
{title}
|
||||
</SheetTitle>
|
||||
)}
|
||||
renderDescription={(tagline) => (
|
||||
<SheetDescription className="text-muted-foreground truncate text-[11px]">
|
||||
{t("insights.coach.tagline")}
|
||||
{tagline}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
{/* v1.7.2 — the per-conversation window override pill was
|
||||
retired. The analysis window now persists alongside the
|
||||
data-source clusters via the sources rail (and the
|
||||
settings cog), so a single, sticky window control replaces
|
||||
the ephemeral header override. */}
|
||||
{/* v1.4.25 W5 — header action cluster. All three buttons
|
||||
share the same `ghost / size-icon / size-9` shape so they
|
||||
visually belong together. 36×36 px hit target meets the
|
||||
WCAG 2.1 AA touch-target minimum on mobile; the icons
|
||||
themselves stay 18 px to match the avatar's optical
|
||||
weight. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNewChat}
|
||||
data-slot="coach-drawer-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
title={t("insights.coach.newChat")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
data-slot="coach-drawer-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" />
|
||||
</Button>
|
||||
<SheetClose asChild>
|
||||
)}
|
||||
// Maximize control at the leading edge — hands the
|
||||
// conversation off to the full-page Coach route.
|
||||
leadingHeaderActions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="coach-drawer-close"
|
||||
aria-label={t("common.close")}
|
||||
title={t("common.close")}
|
||||
onClick={handleMaximize}
|
||||
data-slot="coach-drawer-maximize"
|
||||
aria-label={t("insights.coach.maximizeAriaLabel")}
|
||||
title={t("insights.coach.maximizeAriaLabel")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
<Maximize2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Body — three columns on lg+, single column on smaller.
|
||||
The body slot is a separate component so the SSR test
|
||||
harness can pin the mobile rail-tray triggers without
|
||||
rendering the Sheet portal. */}
|
||||
<CoachDrawerBody
|
||||
sourcesRail={sourcesRail ?? <SourcesRail />}
|
||||
thread={
|
||||
<MessageThread
|
||||
conversation={conversation ?? null}
|
||||
streaming={send.streaming}
|
||||
optimisticUser={send.optimisticUser}
|
||||
/>
|
||||
}
|
||||
disclaimer={
|
||||
<p
|
||||
data-slot="coach-composer-disclaimer"
|
||||
className="text-muted-foreground text-xs leading-relaxed"
|
||||
>
|
||||
{t("insights.coach.composerDisclaimer")}
|
||||
</p>
|
||||
// Close button — the drawer's own, replacing the Sheet's
|
||||
// absolutely-positioned default so the action cluster shares
|
||||
// one baseline.
|
||||
trailingHeaderActions={
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="coach-drawer-close"
|
||||
aria-label={t("common.close")}
|
||||
title={t("common.close")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</SheetClose>
|
||||
}
|
||||
composer={
|
||||
composer ?? (
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={() => handleSubmit(inputValue)}
|
||||
onCancel={send.cancel}
|
||||
disabled={send.isStreaming}
|
||||
isStreaming={send.isStreaming}
|
||||
// v1.4.27 MB3 / CF-30 — the composer mounts on drawer
|
||||
// open and unmounts on close (the body collapses with
|
||||
// the sheet), so a one-shot mount focus matches the
|
||||
// "drawer just opened" semantic without any re-render
|
||||
// gymnastics.
|
||||
autoFocusOnOpen
|
||||
/>
|
||||
)
|
||||
}
|
||||
onOpenHistoryTray={() => setHistoryTrayOpen(true)}
|
||||
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
|
||||
/>
|
||||
|
||||
{/* v1.4.23 H4 — Coach prompt-tuning sheet. Right-edge sheet so
|
||||
it doesn't conflict with the existing rail trays (those are
|
||||
<lg / <xl only; the settings sheet works on every
|
||||
viewport). */}
|
||||
<CoachSettingsSheet
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
/>
|
||||
{/* v1.4.28 R3c (BK-F-M6) — mobile-only rail trays carved out of
|
||||
the drawer shell. Each tray surfaces the same HistoryRail /
|
||||
SourcesRail instance the desktop layout uses; the parent
|
||||
still owns the open/closed state + the rail callbacks so
|
||||
the carve-out is pure render. */}
|
||||
<MobileRailTray
|
||||
historyOpen={historyTrayOpen}
|
||||
onHistoryOpenChange={setHistoryTrayOpen}
|
||||
historyRail={
|
||||
historyRail ?? (
|
||||
<HistoryRail
|
||||
activeId={currentConversationId}
|
||||
onSelect={(id) => {
|
||||
setCurrentConversationId(id);
|
||||
setHistoryTrayOpen(false);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
sourcesOpen={sourcesTrayOpen}
|
||||
onSourcesOpenChange={setSourcesTrayOpen}
|
||||
sourcesRail={sourcesRail ?? <SourcesRail />}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
/**
|
||||
* v1.4.23 H3 — local state seeded from a controlled prop, reset on
|
||||
* prop change. Same render-phase pattern React's docs recommend in
|
||||
* place of `useEffect(() => setState(prop), [prop])` (which the
|
||||
* `react-hooks/set-state-in-effect` ESLint rule banned). The reset
|
||||
* runs during render: React detects the queued setState, restarts
|
||||
* the render with the new value, and commits a single coherent
|
||||
* snapshot — no double paint, no flash of stale state.
|
||||
*
|
||||
* v1.12.0 — lifted out of `coach-drawer.tsx` into its own module so the
|
||||
* shared `<CoachConversation>` surface (drawer + full-page) can seed its
|
||||
* composer from the same controlled-prefill contract without importing
|
||||
* the drawer shell.
|
||||
*
|
||||
* Exported so the drawer's prefill-reset behaviour can be unit-tested in
|
||||
* isolation without standing up the whole Sheet portal.
|
||||
*/
|
||||
export function useResettableValue<T>(
|
||||
controlledValue: T,
|
||||
): [T, Dispatch<SetStateAction<T>>] {
|
||||
const [value, setValue] = useState<T>(controlledValue);
|
||||
// Mirror the last observed controlled value via a sibling useState
|
||||
// pair (per React docs:
|
||||
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes).
|
||||
// useRef is the wrong tool here — ESLint rejects ref read+write
|
||||
// during render, and useState already gives us identity tracking
|
||||
// with no extra cost.
|
||||
const [lastSeen, setLastSeen] = useState<T>(controlledValue);
|
||||
if (!Object.is(controlledValue, lastSeen)) {
|
||||
setLastSeen(controlledValue);
|
||||
setValue(controlledValue);
|
||||
}
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.23 H3 — pure decision function behind `useResettableValue`.
|
||||
* Given the previous controlled value the hook recorded and the
|
||||
* incoming controlled value, returns either `{ reset: true, value }`
|
||||
* (the next render must seed local state with `value`) or
|
||||
* `{ reset: false }` (local state survives — the user's edits are
|
||||
* preserved). Pure + dependency-free so it tests cleanly without a
|
||||
* React renderer — pin the contract here and trust the hook
|
||||
* implementation to wire the same comparison.
|
||||
*/
|
||||
export function nextResettableValue<T>(
|
||||
previous: T,
|
||||
incoming: T,
|
||||
): { reset: true; value: T } | { reset: false } {
|
||||
return Object.is(previous, incoming)
|
||||
? { reset: false }
|
||||
: { reset: true, value: incoming };
|
||||
}
|
||||
@@ -283,6 +283,9 @@ describe("Coach disable cascade invariant", () => {
|
||||
"src/components/insights/layout-coach-fab.tsx",
|
||||
"src/components/insights/layout-coach-mount.tsx",
|
||||
"src/components/insights/suggested-prompts.tsx",
|
||||
// v1.12.0 — the full-page Coach route gates on `flags.coach` and
|
||||
// redirects to `/insights` when the operator master flag is off.
|
||||
"src/app/insights/coach/page.tsx",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -280,6 +280,10 @@ describe("Coach per-user disableCoach invariant", () => {
|
||||
"src/components/insights/layout-coach-fab.tsx",
|
||||
"src/components/insights/layout-coach-mount.tsx",
|
||||
"src/components/insights/suggested-prompts.tsx",
|
||||
// v1.12.0 — the full-page Coach route mirrors the launch-button /
|
||||
// FAB gate: operator master flag OR per-user opt-out redirects the
|
||||
// page back to `/insights` instead of painting a dead chat shell.
|
||||
"src/app/insights/coach/page.tsx",
|
||||
// The hook itself + its `useAuth`-backed reader.
|
||||
"src/hooks/use-disable-coach.ts",
|
||||
// Cross-cut gates owned by sibling invariants / route tests.
|
||||
|
||||
Reference in new issue
Block a user