mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user