feat(coach): mobile rail trays for the AI Coach drawer

The history + sources rails were desktop-only since B2b. B4 surfaces
them on `<lg` via two chevron-button triggers anchored to the inside
edges of the message thread. Tapping a trigger opens a side <Sheet>
that hosts the same HistoryRail / SourcesRail instance the desktop
layout uses, so the user can browse conversations or check what data
the Coach is reading without losing message-thread context.

Refactors the body grid into a separate <CoachDrawerBody> presentational
component so the SSR test harness can pin the trigger slots + their
lg:hidden Tailwind variant without rendering the outer Radix Sheet
portal (which is client-only).

Selecting a conversation from the mobile history tray closes the tray
automatically so the user lands back on the message thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 17:22:38 +02:00
1 parent ff1e335c19
commit ebb2bc0602
3 files changed
+287 -46

No files matched your search

@@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachDrawerBody } from "../coach-drawer-body";
/**
* v1.4.20 phase B4 — Coach drawer mobile rail trays.
*
* The drawer hides the history + sources rails on `<lg`. B4 adds two
* chevron-button triggers along the edges of the message thread that
* open side-sheets surfacing the same rails. The triggers live on
* `<CoachDrawerBody>` so the SSR test can pin their `lg:hidden` class
* + slot markers without rendering the outer Radix `<Sheet>` portal
* (which is client-only).
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const baseProps = {
historyRail: <span>history</span>,
sourcesRail: <span>sources</span>,
thread: <span>thread</span>,
composer: <span>composer</span>,
onOpenHistoryTray: () => undefined,
onOpenSourcesTray: () => undefined,
};
describe("<CoachDrawerBody> — mobile rail trays", () => {
it("renders both tray triggers", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(/data-slot="coach-drawer-history-tray-trigger"/);
expect(html).toMatch(/data-slot="coach-drawer-sources-tray-trigger"/);
});
it("hides both triggers on >=lg via the lg:hidden class", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(historyTrigger).not.toBeNull();
expect(historyTrigger?.[0]).toContain("lg:hidden");
const sourcesTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-sources-tray-trigger"[^>]*>/,
);
expect(sourcesTrigger).not.toBeNull();
expect(sourcesTrigger?.[0]).toContain("lg:hidden");
});
it("renders the desktop history + sources rails (visible on >=lg)", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(
/<aside[^>]*data-slot="coach-drawer-history"[^>]*lg:flex/,
);
expect(html).toMatch(
/<aside[^>]*data-slot="coach-drawer-sources"[^>]*lg:flex/,
);
});
it("renders the message thread + composer slots", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(/data-slot="coach-drawer-thread"/);
expect(html).toMatch(/data-slot="coach-drawer-composer"/);
// Slot content forwarded verbatim.
expect(html).toContain("thread");
expect(html).toContain("composer");
});
it("uses chevron-style trigger labels — History on the left, Sources on the right", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>[\s\S]*?<\/button>/,
);
expect(historyTrigger).not.toBeNull();
expect(historyTrigger?.[0]).toContain("Conversations");
const sourcesTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-sources-tray-trigger"[^>]*>[\s\S]*?<\/button>/,
);
expect(sourcesTrigger).not.toBeNull();
expect(sourcesTrigger?.[0]).toContain("What I can see");
});
it("renders the German trigger labels in the de locale", () => {
const html = render(<CoachDrawerBody {...baseProps} />, "de");
expect(html).toContain("Unterhaltungen");
expect(html).toContain("Worauf ich zugreife");
});
it("the thread region wraps the rail triggers in a relative container", () => {
// The triggers use absolute positioning; the thread region must
// be `relative` for them to anchor inside it.
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(
/<main[^>]*data-slot="coach-drawer-thread"[^>]*relative/,
);
});
});
@@ -0,0 +1,106 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.20 phase B4 — pure presentational body for `<CoachDrawer>`.
*
* The drawer's outer Radix `<Sheet>` does not surface in SSR (its
* portal is client-only), so the unit suite couldn't pin the
* mobile-only rail-tray triggers via the `<CoachDrawer>` itself.
* Lifting the body grid + chevron triggers + composer slot up here
* gives the test a node-renderable surface; the `<CoachDrawer>`
* itself stays the integrating shell that owns state + the trays.
*
* The component is stateless: the parent owns `historyTrayOpen` /
* `sourcesTrayOpen` and passes the open-toggles + slot content. This
* keeps the trigger logic + the desktop rail layout in one place
* while delegating the actual side-sheet to the parent.
*/
export interface CoachDrawerBodyProps {
historyRail: React.ReactNode;
sourcesRail: React.ReactNode;
thread: React.ReactNode;
composer: React.ReactNode;
onOpenHistoryTray: () => void;
onOpenSourcesTray: () => void;
}
export function CoachDrawerBody({
historyRail,
sourcesRail,
thread,
composer,
onOpenHistoryTray,
onOpenSourcesTray,
}: CoachDrawerBodyProps) {
const { t } = useTranslations();
return (
<div
data-slot="coach-drawer-body"
className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[260px_1fr_280px]"
>
{/* History rail — desktop only. Mobile users summon it via the
tray trigger inside the thread column. */}
<aside
data-slot="coach-drawer-history"
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
>
{historyRail}
</aside>
{/* Centre — message thread. */}
<main
data-slot="coach-drawer-thread"
className="relative flex h-full min-h-0 flex-col"
>
{/* v1.4.20 phase B4 — mobile rail tray triggers. Hidden on
`>=lg` because the rails are inline there. */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onOpenHistoryTray}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="bg-background/80 border-border/60 absolute left-2 top-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur lg:hidden"
>
<ChevronRight className="size-3" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")}
className="bg-background/80 border-border/60 absolute right-2 top-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur lg:hidden"
>
{t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-3" aria-hidden="true" />
</Button>
<div className="min-h-0 flex-1">{thread}</div>
{/* Composer pinned to the bottom. */}
<div
data-slot="coach-drawer-composer"
className="border-border/70 border-t p-3 sm:p-4"
>
{composer}
</div>
</main>
{/* Sources rail — desktop only. */}
<aside
data-slot="coach-drawer-sources"
className="border-border/70 hidden h-full min-h-0 border-l lg:flex lg:flex-col"
>
{sourcesRail}
</aside>
</div>
);
}
@@ -15,6 +15,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachInput } from "./coach-input";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
@@ -74,6 +75,12 @@ export function CoachDrawer({
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);
// The composer's input value is seeded from `prefill` whenever the
// parent toggles `open` — we mount the drawer with a key derived from
// (open, prefill) so the lazy initialiser fires fresh on each
@@ -185,61 +192,86 @@ export function CoachDrawer({
</TooltipProvider>
</SheetHeader>
{/* Body — three columns on lg+, single column on smaller. */}
<div
data-slot="coach-drawer-body"
className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[260px_1fr_280px]"
>
{/* History rail — desktop only. Mobile collapses; v1.4.21 will
ship a tray for it. */}
<aside
data-slot="coach-drawer-history"
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
>
{historyRail ?? (
{/* 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
historyRail={
historyRail ?? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => setCurrentConversationId(id)}
/>
)}
</aside>
{/* Centre — message thread. */}
<main
data-slot="coach-drawer-thread"
className="flex h-full min-h-0 flex-col"
>
<div className="min-h-0 flex-1">
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
)
}
sourcesRail={sourcesRail ?? <SourcesRail />}
thread={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
/>
}
composer={
composer ?? (
<CoachInput
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
/>
</div>
{/* Composer pinned to the bottom. */}
<div
data-slot="coach-drawer-composer"
className="border-border/70 border-t p-3 sm:p-4"
>
{composer ?? (
<CoachInput
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
)
}
onOpenHistoryTray={() => setHistoryTrayOpen(true)}
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 `<lg` viewport
can summon either rail without leaving the message thread. */}
<Sheet open={historyTrayOpen} onOpenChange={setHistoryTrayOpen}>
<SheetContent
side="left"
data-slot="coach-drawer-history-tray"
className="w-[88vw] max-w-[320px] p-0 lg:hidden"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.historyTitle")}
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">
{historyRail ?? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
}}
/>
)}
</div>
</main>
{/* Sources rail — desktop only. */}
<aside
data-slot="coach-drawer-sources"
className="border-border/70 hidden h-full min-h-0 border-l lg:flex lg:flex-col"
</SheetContent>
</Sheet>
<Sheet open={sourcesTrayOpen} onOpenChange={setSourcesTrayOpen}>
<SheetContent
side="right"
data-slot="coach-drawer-sources-tray"
className="w-[88vw] max-w-[320px] p-0 lg:hidden"
>
{sourcesRail ?? <SourcesRail />}
</aside>
</div>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.sourcesTitle")}
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">
{sourcesRail ?? <SourcesRail />}
</div>
</SheetContent>
</Sheet>
</SheetContent>
</Sheet>
);