diff --git a/src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx b/src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx
new file mode 100644
index 000000000..7d1fc171b
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx
@@ -0,0 +1,108 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+/**
+ * v1.4.28 R3c (BK-F-M6) — pin the carved-out mobile rail tray's
+ * presentational contract. The carve-out is pure render: the parent
+ * owns open/closed state + the rail content; this component just
+ * paints two side-sheets with the right slots when open.
+ *
+ * The shadcn `` portals its content out of the React tree, so
+ * we mock the primitives down to plain wrappers (same pattern as
+ * `coach-settings-sheet.test.tsx`) to keep the slot assertions
+ * reachable in static markup.
+ */
+vi.mock("@/components/ui/sheet", () => ({
+ Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
+ open ?
-
-
+ {/* 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 ?? (
+ {
+ setScope((prev) => ({ ...prev, sources: next.sources }));
+ setWindowOverride(
+ next.window === savedDefaultWindow ? null : next.window,
+ );
+ }}
+ />
+ )
+ }
+ />
);
diff --git a/src/components/insights/coach-panel/mobile-rail-tray.tsx b/src/components/insights/coach-panel/mobile-rail-tray.tsx
new file mode 100644
index 000000000..a48b75d08
--- /dev/null
+++ b/src/components/insights/coach-panel/mobile-rail-tray.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * v1.4.20 phase B4 — mobile-only rail trays for the Coach drawer.
+ *
+ * The desktop layout pins the history rail (lg+) and the sources rail
+ * (xl+) alongside the message thread. Below those breakpoints the
+ * rails are reachable via chevron triggers in the thread's sub-header
+ * strip; each trigger opens the matching side-sheet here.
+ *
+ * v1.4.28 R3c (BK-F-M6) — carved out of `` (~560 LOC).
+ * The parent still owns the rail content + the open/closed state for
+ * each tray so this component stays a thin presentational shell that
+ * can be unit-tested in isolation without standing up the drawer's
+ * snapshot fetch + scope-picker hooks.
+ *
+ * The component takes pre-rendered rail nodes rather than the raw
+ * rail components so the caller controls how each rail's
+ * `onSelect` / `onScopeChange` callbacks close the tray (some flows
+ * dismiss the tray after a selection; the contract for that lives in
+ * the parent, not here).
+ */
+export interface MobileRailTrayProps {
+ historyOpen: boolean;
+ onHistoryOpenChange: (next: boolean) => void;
+ historyRail: ReactNode;
+ sourcesOpen: boolean;
+ onSourcesOpenChange: (next: boolean) => void;
+ sourcesRail: ReactNode;
+}
+
+export function MobileRailTray({
+ historyOpen,
+ onHistoryOpenChange,
+ historyRail,
+ sourcesOpen,
+ onSourcesOpenChange,
+ sourcesRail,
+}: MobileRailTrayProps) {
+ const { t } = useTranslations();
+ return (
+ <>
+ {/* History tray — slides in from the left edge. `lg:hidden`
+ because the history rail is inline on lg+. */}
+
+
+
+
+ {t("insights.coach.historyTitle")}
+
+
+
{historyRail}
+
+
+ {/* Sources tray — slides in from the right edge. `xl:hidden`
+ because the sources rail is inline on xl+. */}
+
+
+
+
+ {t("insights.coach.sourcesTitle")}
+
+
+