diff --git a/src/app/insights/blutdruck/page.tsx b/src/app/insights/blutdruck/page.tsx
index 84e74486f..046bf3171 100644
--- a/src/app/insights/blutdruck/page.tsx
+++ b/src/app/insights/blutdruck/page.tsx
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
+import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { getBpTargets } from "@/lib/analytics/bp-targets";
@@ -139,6 +140,8 @@ export default function InsightsBlutdruckPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
+
+
);
}
diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx
index e1ef69dab..314a5ae1c 100644
--- a/src/app/insights/bmi/page.tsx
+++ b/src/app/insights/bmi/page.tsx
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
+import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -140,6 +141,8 @@ export default function InsightsBmiPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
+
+
);
}
diff --git a/src/app/insights/gewicht/page.tsx b/src/app/insights/gewicht/page.tsx
index 6d9003f5a..726c7ebbc 100644
--- a/src/app/insights/gewicht/page.tsx
+++ b/src/app/insights/gewicht/page.tsx
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
+import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
@@ -117,6 +118,8 @@ export default function InsightsGewichtPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
+
+
);
}
diff --git a/src/app/insights/layout.tsx b/src/app/insights/layout.tsx
index 6db91e360..25b0b4cb7 100644
--- a/src/app/insights/layout.tsx
+++ b/src/app/insights/layout.tsx
@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell";
+import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
+import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
/**
* v1.4.25 W4 — shared layout for the routed `/insights` sub-pages.
@@ -10,12 +12,20 @@ import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell
* into a sub-page. The actual page body still owns its own data fetches
* — the layout is a thin presentational frame.
*
- * Critical design rule (Marc directive 2026-05-11):
- * The `` is NOT mounted in this layout. It stays inside
- * `src/app/insights/page.tsx` body so navigating into a sub-page
- * unmounts the drawer (matches the Apple Health convention that AI/
- * coach surfaces only live on the overview, never on a metric page).
+ * v1.4.27 R3d MB4 — the Coach drawer now mounts here (above the routed
+ * children) inside a `` so navigating into a sub-
+ * page no longer unmounts the drawer. Every sub-page mounts a
+ * `` that calls `askCoach()` on the same context;
+ * the mother `/insights/page.tsx` consumes the same hook for its hero
+ * strip + suggested-prompt chips. Decision F (audit MA3) drove the
+ * promotion: a mobile user who navigates from `/insights` to
+ * `/insights/blutdruck` keeps a one-tap path back into the Coach.
*/
export default function InsightsLayout({ children }: { children: ReactNode }) {
- return {children};
+ return (
+
+ {children}
+
+
+ );
}
diff --git a/src/app/insights/medikamente/page.tsx b/src/app/insights/medikamente/page.tsx
index ff982be9e..022a3fbaf 100644
--- a/src/app/insights/medikamente/page.tsx
+++ b/src/app/insights/medikamente/page.tsx
@@ -12,6 +12,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Progress } from "@/components/ui/progress";
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
+import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TherapyTimeline } from "@/components/insights/therapy-timeline";
@@ -225,6 +226,8 @@ export default function InsightsMedikamentePage() {
without an active GLP-1 medication, so the page collapses
back to the compliance grid for everyone else. */}
+
+
);
}
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index 62b698b49..e563ddbe6 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -1,6 +1,5 @@
"use client";
-import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Loader2, TrendingUp } from "lucide-react";
@@ -12,10 +11,10 @@ import { EmptyState } from "@/components/ui/empty-state";
import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card";
import { HeroStrip } from "@/components/insights/hero-strip";
import { DailyBriefing } from "@/components/insights/daily-briefing";
-import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
import { TrendsRow } from "@/components/insights/trends-row";
import { CorrelationRow } from "@/components/insights/correlation-row";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
+import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import type { CorrelationResult } from "@/lib/insights/correlations";
import { toWeekISO } from "@/lib/insights/week-iso";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -96,12 +95,12 @@ export default function InsightsPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
- // CoachDrawer state. The hero strip's "Ask the coach" button +
- // suggested-prompt chips toggle the drawer here; the drawer ingests
- // `coachPrefill` once on open and resets on close so the next open
- // starts blank.
- const [coachOpen, setCoachOpen] = useState(false);
- const [coachPrefill, setCoachPrefill] = useState(null);
+ // v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
+ // `` so every routed sub-page can reach it.
+ // The hero strip + suggested-prompt chips call `askCoach(prefill)`
+ // on the same context, and the drawer itself is mounted next to
+ // the provider in `src/app/insights/layout.tsx`.
+ const coachLaunch = useCoachLaunch();
const { data, isLoading } = useQuery({
queryKey: ["insights", "comprehensive"],
@@ -183,14 +182,16 @@ export default function InsightsPage() {
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
userName={heroGreetingName}
- onAskCoach={(prefill?: string) => {
- setCoachPrefill(prefill ?? null);
- setCoachOpen(true);
- }}
- onPickPrompt={(prompt) => {
- setCoachPrefill(prompt);
- setCoachOpen(true);
- }}
+ onAskCoach={
+ coachLaunch
+ ? (prefill?: string) => coachLaunch.askCoach(prefill ?? null)
+ : undefined
+ }
+ onPickPrompt={
+ coachLaunch
+ ? (prompt) => coachLaunch.askCoach(prompt)
+ : undefined
+ }
weeklyReportReady={weeklyReportReady}
weeklyReportHref={currentWeekHref}
healthScore={analytics?.healthScore ?? undefined}
@@ -217,12 +218,6 @@ export default function InsightsPage() {
cachedAt={advisor.payload?.cachedAt ?? null}
legacyPayload={advisor.payload?.legacyPayload ?? false}
/>
-
-
);
}
diff --git a/src/app/insights/puls/page.tsx b/src/app/insights/puls/page.tsx
index 2a031d43f..d8e5f76b4 100644
--- a/src/app/insights/puls/page.tsx
+++ b/src/app/insights/puls/page.tsx
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
+import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
@@ -164,6 +165,8 @@ export default function InsightsPulsPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
+
+
);
}
diff --git a/src/app/insights/schlaf/page.tsx b/src/app/insights/schlaf/page.tsx
index 596c0941c..be0bcc06d 100644
--- a/src/app/insights/schlaf/page.tsx
+++ b/src/app/insights/schlaf/page.tsx
@@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
+import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -79,6 +80,8 @@ export default function InsightsSchlafPage() {
description={t("insights.sleep.description")}
>
+
+
);
}
diff --git a/src/app/insights/stimmung/page.tsx b/src/app/insights/stimmung/page.tsx
index 21df0ed7b..d4889c2dc 100644
--- a/src/app/insights/stimmung/page.tsx
+++ b/src/app/insights/stimmung/page.tsx
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
+import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -99,6 +100,8 @@ export default function InsightsStimmungPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
+
+
);
}
diff --git a/src/components/insights/coach-launch-button.tsx b/src/components/insights/coach-launch-button.tsx
new file mode 100644
index 000000000..69454539c
--- /dev/null
+++ b/src/components/insights/coach-launch-button.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { Sparkles } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { useTranslations } from "@/lib/i18n/context";
+import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
+
+/**
+ * v1.4.27 R3d MB4 — Coach launch button.
+ *
+ * Decision F: every routed Insights sub-page mounts this button so the
+ * Coach drawer (mounted at the layout root) is always reachable. The
+ * button renders as a sticky-bottom FAB on `
+ {/* Sticky FAB on ` launch.askCoach(prefill ?? null)}
+ aria-label={accessibleLabel}
+ title={accessibleLabel}
+ className={cn(
+ "fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
+ "from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
+ "hover:from-dracula-purple/90 hover:to-dracula-pink/90",
+ "lg:hidden",
+ className,
+ )}
+ >
+
+ {accessibleLabel}
+
+ {/* Inline header-style action on `lg+`. Lives in the page body so
+ it picks up the sub-page layout's spacing instead of fighting
+ a sticky header overlay. */}
+
+ >
+ );
+}
diff --git a/src/components/insights/layout-coach-mount.tsx b/src/components/insights/layout-coach-mount.tsx
new file mode 100644
index 000000000..b93f953b5
--- /dev/null
+++ b/src/components/insights/layout-coach-mount.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
+import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
+
+/**
+ * v1.4.27 R3d MB4 — bridge between the layout-level
+ * `` and the Coach drawer mount.
+ *
+ * The layout file is a server component (it lives next to other server
+ * components and feeds the `metadata` chain), so the actual `useState`
+ * + `` consumer has to live inside a client island. This
+ * file is intentionally tiny — its only job is to read the context and
+ * render the drawer at the layout's mount site.
+ */
+export function LayoutCoachMount() {
+ const launch = useCoachLaunch();
+ if (!launch) return null;
+ return (
+
+ );
+}
diff --git a/src/lib/insights/__tests__/coach-launch-context.test.tsx b/src/lib/insights/__tests__/coach-launch-context.test.tsx
new file mode 100644
index 000000000..4a8fdc5fd
--- /dev/null
+++ b/src/lib/insights/__tests__/coach-launch-context.test.tsx
@@ -0,0 +1,71 @@
+import { describe, it, expect } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import {
+ CoachLaunchProvider,
+ useCoachLaunch,
+} from "../coach-launch-context";
+
+/**
+ * v1.4.27 R3d MB4 — Coach launch context smoke contract.
+ *
+ * The project's test convention is `renderToStaticMarkup` (no
+ * `@testing-library/react` dependency). We can't simulate interaction
+ * inside SSR, but we can verify:
+ * 1. The provider mounts and renders its children.
+ * 2. The hook returns a value-shape (open, prefill, askCoach,
+ * setOpen) when called beneath the provider.
+ * 3. The hook returns `null` when called outside the provider —
+ * consumers degrade gracefully (e.g. the launch button renders
+ * nothing rather than crashing).
+ */
+
+function Probe({ output }: { output: string[] }) {
+ const launch = useCoachLaunch();
+ if (!launch) {
+ output.push("null");
+ return null;
+ }
+ output.push(
+ JSON.stringify({
+ hasAskCoach: typeof launch.askCoach,
+ hasSetOpen: typeof launch.setOpen,
+ open: launch.open,
+ prefill: launch.prefill,
+ }),
+ );
+ return {output.join(",")};
+}
+
+describe("CoachLaunchProvider", () => {
+ it("returns null when consumed outside the provider", () => {
+ const output: string[] = [];
+ renderToStaticMarkup();
+ expect(output).toEqual(["null"]);
+ });
+
+ it("exposes the full context shape when consumed beneath the provider", () => {
+ const output: string[] = [];
+ renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(output).toHaveLength(1);
+ const parsed = JSON.parse(output[0]);
+ expect(parsed.hasAskCoach).toBe("function");
+ expect(parsed.hasSetOpen).toBe("function");
+ expect(parsed.open).toBe(false);
+ expect(parsed.prefill).toBeNull();
+ });
+
+ it("renders the children inside the provider mount", () => {
+ const html = renderToStaticMarkup(
+
+
child
+ ,
+ );
+ expect(html).toContain('data-slot="child-mount"');
+ expect(html).toContain("child");
+ });
+});
diff --git a/src/lib/insights/coach-launch-context.tsx b/src/lib/insights/coach-launch-context.tsx
new file mode 100644
index 000000000..3dbd14c69
--- /dev/null
+++ b/src/lib/insights/coach-launch-context.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+/**
+ * v1.4.27 R3d MB4 — Coach launch context.
+ *
+ * Until v1.4.27 the Coach drawer was mounted inline on
+ * `/insights/page.tsx` body, which meant navigating to a routed
+ * sub-page (`/insights/blutdruck`, etc.) unmounted the drawer and
+ * removed every entry point to the Coach. The MA3 audit + Decision F
+ * promoted the drawer up to `/insights/layout.tsx` so every routed
+ * Insights surface can launch it.
+ *
+ * Shape:
+ * - `` owns the drawer's open / prefill state
+ * and renders the children beneath the launch surface.
+ * - `useCoachLaunch()` returns `{ open, askCoach, setOpen, prefill }`
+ * so any descendant can open the drawer with an optional prefill
+ * string (the hero-strip suggested-prompt chips feed prefills;
+ * the sticky `` opens without one).
+ *
+ * The provider is intentionally tiny — the actual drawer mount sits
+ * next to it in the layout, reading the same state via the hook.
+ */
+export interface CoachLaunchScope {
+ /**
+ * Optional metric the user is looking at when they open the Coach.
+ * Reserved for v1.4.28 so the drawer can pre-narrow the source rail
+ * to the active metric; ignored in v1.4.27.
+ */
+ metric?: string;
+}
+
+interface CoachLaunchValue {
+ /** Whether the Coach drawer is currently open. */
+ open: boolean;
+ /** Current prefill string (or null when the next open should start blank). */
+ prefill: string | null;
+ /** Open the drawer with an optional prefill + scope hint. */
+ askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void;
+ /** Direct setter for the open state — the drawer consumes this. */
+ setOpen: (next: boolean) => void;
+}
+
+const CoachLaunchContext = createContext(null);
+
+export interface CoachLaunchProviderProps {
+ children: ReactNode;
+}
+
+export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
+ const [open, setOpen] = useState(false);
+ const [prefill, setPrefill] = useState(null);
+
+ const askCoach = useCallback(
+ (nextPrefill?: string | null, scope?: CoachLaunchScope) => {
+ // `scope` is currently reserved for v1.4.28 (metric-narrow on the
+ // sources rail). Accept the parameter today so the call sites
+ // don't need to change shape when the rail starts honouring it.
+ void scope;
+ setPrefill(nextPrefill ?? null);
+ setOpen(true);
+ },
+ [],
+ );
+
+ const handleSetOpen = useCallback((next: boolean) => {
+ setOpen(next);
+ if (!next) {
+ // Drop the prefill on close so the next open starts blank.
+ setPrefill(null);
+ }
+ }, []);
+
+ const value = useMemo(
+ () => ({ open, prefill, askCoach, setOpen: handleSetOpen }),
+ [open, prefill, askCoach, handleSetOpen],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Read the Coach launch context. Returns `null` when called outside a
+ * `` so consumer components can degrade gracefully
+ * (e.g. the hero strip's "Ask the coach" action stays disabled until
+ * the provider mounts).
+ */
+export function useCoachLaunch(): CoachLaunchValue | null {
+ return useContext(CoachLaunchContext);
+}