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 ?
{children}
: null, + SheetContent: ({ + children, + "data-slot": dataSlot, + className, + }: { + children: React.ReactNode; + "data-slot"?: string; + className?: string; + }) => ( +
+ {children} +
+ ), + SheetHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SheetTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +import { MobileRailTray } from "../mobile-rail-tray"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const baseProps = { + historyOpen: true, + onHistoryOpenChange: () => undefined, + historyRail: history-content, + sourcesOpen: true, + onSourcesOpenChange: () => undefined, + sourcesRail: sources-content, +}; + +describe("", () => { + it("renders both trays with their data-slot identifiers when open", () => { + const html = render(); + expect(html).toContain('data-slot="coach-drawer-history-tray"'); + expect(html).toContain('data-slot="coach-drawer-sources-tray"'); + }); + + it("forwards the rail content slots verbatim", () => { + const html = render(); + expect(html).toContain("history-content"); + expect(html).toContain("sources-content"); + }); + + it("hides the history tray on lg+ and the sources tray on xl+", () => { + // Matches the breakpoints documented inline: the history rail + // mounts inline at lg+, so the tray is `lg:hidden`. The sources + // rail mounts inline at xl+ only, so the tray is `xl:hidden`. + const html = render(); + expect(html).toMatch( + /data-slot="coach-drawer-history-tray"[^>]*class="[^"]*lg:hidden/, + ); + expect(html).toMatch( + /data-slot="coach-drawer-sources-tray"[^>]*class="[^"]*xl:hidden/, + ); + }); + + it("renders both header titles localised", () => { + const enHtml = render(, "en"); + expect(enHtml).toContain("Conversations"); + expect(enHtml).toContain("What I can see"); + + const deHtml = render(, "de"); + expect(deHtml).toContain("Unterhaltungen"); + expect(deHtml).toContain("Worauf ich zugreife"); + }); + + it("does not render either tray when its open prop is false", () => { + // The mock's `Sheet` returns `null` when `open === false`. Pin + // that contract so the carve-out stays gated on `historyOpen` / + // `sourcesOpen` and never paints orphan overlays. + const html = render( + , + ); + expect(html).not.toContain('data-slot="coach-drawer-history-tray"'); + expect(html).not.toContain('data-slot="coach-drawer-sources-tray"'); + }); +}); diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 7f3d68983..517887c9e 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -30,6 +30,7 @@ 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 { DEFAULT_COACH_SCOPE, SourcesRail } from "./sources-rail"; import { useCoachConversation, useSendCoachMessage } from "./use-coach"; import type { @@ -488,74 +489,52 @@ export function CoachDrawer({ onOpenSourcesTray={() => setSourcesTrayOpen(true)} /> - {/* v1.4.20 phase B4 — mobile-only rail trays. Each renders a - slide-in panel from the matching edge and surfaces the same - HistoryRail / SourcesRail instance the desktop layout uses, - so a `>=lg` viewport never shows them but a ` - - - - {t("insights.coach.historyTitle")} - - -
- {historyRail ?? ( - { - setCurrentConversationId(id); - setHistoryTrayOpen(false); - }} - /> - )} -
-
-
{/* v1.4.23 H4 — Coach prompt-tuning sheet. Right-edge sheet so - it doesn't conflict with the existing left/right rail - trays (those are - - - - - {t("insights.coach.sourcesTitle")} - - -
- {sourcesRail ?? ( - { - setScope((prev) => ({ ...prev, sources: next.sources })); - setWindowOverride( - next.window === savedDefaultWindow ? null : next.window, - ); - }} - /> - )} -
-
-
+ {/* 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")} + + +
{sourcesRail}
+
+
+ + ); +}