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:
Marc-André Bombeck committed 2026-06-05 00:05:20 +02:00
1 parent e0f60d82cc
commit 488cc09ae9
12 files changed
+506 -322

No files matched your search

+2
View File
@@ -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…",
+2
View File
@@ -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…",
+2
View File
@@ -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…",
+2
View File
@@ -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…",
+2
View File
@@ -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…",
+2
View File
@@ -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ę…",
+88
View File
@@ -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.