diff --git a/messages/de.json b/messages/de.json index 2db6f697a..f64af651f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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…", diff --git a/messages/en.json b/messages/en.json index 3218a3471..6f19121c8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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…", diff --git a/messages/es.json b/messages/es.json index 1a11cfbfd..bfe248f37 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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…", diff --git a/messages/fr.json b/messages/fr.json index 0637c7c0f..bc28abecf 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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…", diff --git a/messages/it.json b/messages/it.json index 8ad148028..33cb19473 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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…", diff --git a/messages/pl.json b/messages/pl.json index c8a3910a7..bb254d9ae 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ę…", diff --git a/src/app/insights/coach/page.tsx b/src/app/insights/coach/page.tsx new file mode 100644 index 000000000..a601d6f9b --- /dev/null +++ b/src/app/insights/coach/page.tsx @@ -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 `` 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 `` (which provides the + * `` 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 `` / ``: + // 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 ( +
+ ( +

{title}

+ )} + leadingHeaderActions={ + + } + /> +
+ ); +} diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx new file mode 100644 index 000000000..901ce4c99 --- /dev/null +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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 + * (``, right/bottom `` 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 `` 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 `` / + * `` (required for the dialog's accessible name) while + * the page mounts a plain `

` / `

`. + */ +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 ``, the page in an + * `

`. 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 ( +
+
+ {leadingHeaderActions} + +
+ {renderTitle ? ( + renderTitle(title) + ) : ( +

{title}

+ )} + {renderDescription ? ( + renderDescription(tagline) + ) : ( +

+ {tagline} +

+ )} +
+ + + {trailingHeaderActions} +
+ + } + thread={ + + } + disclaimer={ +

+ {t("insights.coach.composerDisclaimer")} +

+ } + composer={ + handleSubmit(inputValue)} + onCancel={send.cancel} + disabled={send.isStreaming} + isStreaming={send.isStreaming} + autoFocusOnOpen={autoFocusComposer} + /> + } + onOpenHistoryTray={() => setHistoryTrayOpen(true)} + onOpenSourcesTray={() => setSourcesTrayOpen(true)} + /> + + + { + setCurrentConversationId(id); + setHistoryTrayOpen(false); + }} + /> + } + sourcesOpen={sourcesTrayOpen} + onSourcesOpenChange={setSourcesTrayOpen} + sourcesRail={} + /> +
+ ); +} diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 9fbce42b2..edccfe1f7 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -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 `` 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 `` overlay). * * Mounts above `/insights` so the user keeps the dashboard context - * behind it. Single-column on `` component, reused verbatim by the + * full-page Coach route (`/insights/coach`). This file is the drawer + * CHROME only: the `` 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 `` 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( - controlledValue: T, -): [T, Dispatch>] { - const [value, setValue] = useState(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(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( - 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 ` { - 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 ( @@ -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 - // '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. */} - - -
- {/* 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. */} + ( - {drawerTitle} + {title} + )} + renderDescription={(tagline) => ( - {t("insights.coach.tagline")} + {tagline} -
- {/* 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. */} - - - + )} + // Maximize control at the leading edge — hands the + // conversation off to the full-page Coach route. + leadingHeaderActions={ - -
- - {/* 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. */} - } - thread={ - } - disclaimer={ -

- {t("insights.coach.composerDisclaimer")} -

+ // Close button — the drawer's own, replacing the Sheet's + // absolutely-positioned default so the action cluster shares + // one baseline. + trailingHeaderActions={ + + + } - composer={ - composer ?? ( - 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 - - {/* 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. */} - { - setCurrentConversationId(id); - setHistoryTrayOpen(false); - }} - /> - ) - } - sourcesOpen={sourcesTrayOpen} - onSourcesOpenChange={setSourcesTrayOpen} - sourcesRail={sourcesRail ?? } />
diff --git a/src/components/insights/coach-panel/use-resettable-value.ts b/src/components/insights/coach-panel/use-resettable-value.ts new file mode 100644 index 000000000..04904f95b --- /dev/null +++ b/src/components/insights/coach-panel/use-resettable-value.ts @@ -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 `` 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( + controlledValue: T, +): [T, Dispatch>] { + const [value, setValue] = useState(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(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( + previous: T, + incoming: T, +): { reset: true; value: T } | { reset: false } { + return Object.is(previous, incoming) + ? { reset: false } + : { reset: true, value: incoming }; +} diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index f6393b192..7eb133646 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -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", ]; /** diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 515fff8d2..7f233d57d 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.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.