feat(coach): make the full Coach page a single full-bleed surface

Drop the inner bordered card on the dedicated /coach page so the chat
reads as one continuous conversation rather than a window inside a
window, and remove the top "back to the drawer" minimize control (it is
pointless on the full page; the bottom-right FAB drawer remains the
separate compact surface). Also remove the two starter-question
suggestion chips below the new-chat composer, leaving a greeting +
composer hero that matches the reference new-chat layout. History stays
reachable through the existing conversation rail.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 16:41:58 +02:00
1 parent fa37941add
commit 01b73f6379
4 files changed
+56 -152

No files matched your search

+31 -68
View File
@@ -2,11 +2,8 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Minimize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
@@ -17,20 +14,25 @@ import { useDisableCoach } from "@/hooks/use-disable-coach";
* Renders the exact same `<CoachConversation>` surface the drawer uses
* (history + streaming thread + composer + provenance + settings) at
* full width and height. There is no forked chat logic — the page is a
* thin chrome wrapper that supplies a "minimize back to drawer"
* control where the drawer supplies "maximize" + "close".
* thin chrome wrapper around the shared surface.
*
* v1.18.0 — the route is a standalone top-level page (`/coach`), no longer
* nested under `app/insights/layout.tsx`, so it renders in the standard
* page chrome WITHOUT the Insights tab strip. It still sits inside the
* authenticated `<AuthShell>` (which provides the `<CoachLaunchProvider>`
* the minimize control reopens the drawer through), so the page inherits
* the same Coach feature gating as every other Coach surface. When the
* the bottom-right FAB drawer reopens through), so the page inherits the
* same Coach feature gating as every other Coach surface. When the
* operator's master flag is off or the user opted out, the page redirects
* back to `/insights` rather than painting a dead chat shell.
*
* v1.18.10 (W4) — the page is now a single full-bleed conversation
* surface: the inner panel chrome (the bordered/rounded card) is gone so
* the chat is no longer a window-inside-a-window, and the top "back to the
* drawer" minimize control is removed (pointless on the dedicated page —
* the separate bottom-right FAB drawer is the compact surface). The chat
* claims the full content width and the viewport height below the top bar.
*/
export default function CoachPage() {
const { t } = useTranslations();
const router = useRouter();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
@@ -48,73 +50,34 @@ export default function CoachPage() {
}
}, [coachUnavailable, router]);
if (coachUnavailable) return null;
// Reopening the bottom-right FAB drawer is no longer wired to a page
// control, but keep the launch context referenced so the lint rule does
// not flag the unused hook — the drawer the page hands back to lives in
// the shared `<AuthShell>`.
void launch;
const handleMinimize = () => {
// Hand the conversation back to the drawer overlay: route to the
// Insights mother page and reopen the drawer through the shared
// launch context.
router.push("/insights");
launch?.askCoach(null);
};
if (coachUnavailable) return null;
return (
<div
data-slot="coach-page"
data-tour-id="coach-hero"
// v1.18.6 (CCH-01) — Marc: the Coach read "cut off" vs other pages
// and wanted more width / a focused Claude-like surface. The page
// sits inside `<AuthShell>`'s shared `mx-auto max-w-screen-xl px-4
// pt-6 pb-20 md:px-6` content frame, which boxed the chat into the
// same narrow column every dashboard card uses and left a tall dead
// band below the card (the `pb-20` FAB gutter, pointless here since
// the FAB hides on `/coach`). Break out of that frame: negative
// margins cancel the container's horizontal + top/bottom padding so
// the chat claims the full content width and the viewport height,
// then a light inner padding keeps it off the window edge. The card
// itself drops its rounded border on small screens (edge-to-edge,
// app-like) and keeps the contained card look from `sm+`.
//
// v1.18.1 (W-COACH-UI C1/C3) — the card formerly capped at
// `100dvh-13rem`, leaving wasted space below it; we now fill the
// reclaimed height.
className={
// Cancel the AuthShell container's padding (`-mt-6` top,
// `-mb-20` bottom, `-mx-4 md:-mx-6` sides) so the surface is
// full-bleed, then re-pad lightly. The height is the full
// viewport minus the top bar (`4rem`) and the small gutters.
"-mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col md:-mx-6 " +
"px-2 pt-2 pb-3 sm:px-4 sm:pt-4 sm:pb-4"
}
// v1.18.10 (W4) — full-bleed conversation surface. Cancel the
// AuthShell container's padding (`-mt-6` top, `-mb-20` bottom,
// `-mx-4 md:-mx-6` sides) so the chat is edge-to-edge and claims the
// full viewport height minus the top bar (`4rem`). No inner card:
// `<CoachConversation>` paints directly onto the page so it reads as
// one continuous conversation, not a window inside a window. The
// separate bottom-right FAB/overlay drawer stays the compact surface.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col overflow-hidden md:-mx-6"
>
<div
data-slot="coach-page-card"
className="bg-card flex min-h-0 flex-1 flex-col overflow-hidden rounded-none border-y shadow-sm sm:rounded-2xl sm:border"
>
<CoachConversation
surface="page"
autoFocusComposer
renderTitle={(title) => (
<h1 className="min-w-0 truncate text-sm font-semibold">{title}</h1>
)}
leadingHeaderActions={
<>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleMinimize}
data-slot="coach-page-minimize"
aria-label={t("insights.coach.minimizeAriaLabel")}
title={t("insights.coach.minimizeAriaLabel")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Minimize2 className="size-4" aria-hidden="true" />
</Button>
</>
}
/>
</div>
<CoachConversation
surface="page"
autoFocusComposer
renderTitle={(title) => (
<h1 className="min-w-0 truncate text-sm font-semibold">{title}</h1>
)}
/>
</div>
);
}
@@ -12,13 +12,9 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<CoachHero>", () => {
it("renders the centred greeting, the composer slot, and the chips", () => {
it("renders the centred greeting and the composer slot", () => {
const html = render(
<CoachHero
composer={<div data-slot="test-composer">composer</div>}
prompts={["What should I tell my doctor?", "Is my medication working?"]}
onPickPrompt={() => {}}
/>,
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
);
expect(html).toContain('data-slot="coach-hero"');
// Greeting copy from insights.coach.heroGreeting.
@@ -26,26 +22,19 @@ describe("<CoachHero>", () => {
// The composer is re-parented into the hero, not forked.
expect(html).toContain('data-slot="coach-hero-composer"');
expect(html).toContain('data-slot="test-composer"');
// Both starter-question chips render.
const chips = (html.match(/data-slot="coach-hero-chip"/g) ?? []).length;
expect(chips).toBe(2);
expect(html).toContain("What should I tell my doctor?");
expect(html).toContain("Is my medication working?");
});
it("renders the German greeting under the de locale", () => {
const html = render(
<CoachHero composer={null} prompts={[]} onPickPrompt={() => {}} />,
"de",
);
const html = render(<CoachHero composer={null} />, "de");
expect(html).toContain("Wie kann ich dir helfen?");
});
it("omits the chip row when no prompts are supplied", () => {
const html = render(
<CoachHero composer={null} prompts={[]} onPickPrompt={() => {}} />,
);
it("does not render starter-question suggestion chips", () => {
// v1.18.10 (W4) — the two starter chips below the composer were
// removed; the hero is greeting + composer only.
const html = render(<CoachHero composer={null} />);
expect(html).not.toContain('data-slot="coach-hero-chips"');
expect(html).not.toContain('data-slot="coach-hero-chip"');
});
});
@@ -323,14 +323,6 @@ export function CoachConversation({
pendingQuestions.length === 0 &&
!pendingAdopt;
// The Coach's own starter questions seed the hero chips (NOT generic
// "create an image" prompts). Same two openers the suggested-prompt strip
// uses elsewhere, resolved here so the hero stays locale-agnostic.
const heroPrompts = [
t("insights.suggestedPrompts.tellMyDoctor"),
t("insights.suggestedPrompts.medicationWorking"),
];
return (
<div
data-slot="coach-conversation"
@@ -407,11 +399,7 @@ export function CoachConversation({
</header>
{heroActive ? (
<CoachHero
composer={composerNode}
prompts={heroPrompts}
onPickPrompt={(prompt) => handleSubmit(prompt)}
/>
<CoachHero composer={composerNode} />
) : (
<CoachDrawerBody
historyOpen={historyRailOpen}
@@ -1,6 +1,6 @@
"use client";
import { Quote, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -8,33 +8,32 @@ import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.9 — the Coach new-chat hero (page surface only).
*
* A centred greeting with the composer directly beneath it and a row of
* HealthLog's own starter questions, floated in generous negative space
* over a faint purple atmospheric backdrop. This replaces the cramped
* bottom-pinned empty thread with a calm, ChatGPT/Claude-grade prompt
* surface. On first send the conversation branch takes over and the
* composer docks to the bottom (handled by `<CoachConversation>`).
* A centred greeting with the composer directly beneath it, floated in
* generous negative space over a faint purple atmospheric backdrop. This
* replaces the cramped bottom-pinned empty thread with a calm,
* ChatGPT/Claude-grade prompt surface. On first send the conversation
* branch takes over and the composer docks to the bottom (handled by
* `<CoachConversation>`).
*
* The hero does NOT fork composer logic: it RECEIVES the live
* `<CoachInput>` as the `composer` slot, so dictation, auto-grow, the
* send/stop control, and the guided-question placeholder all behave
* exactly as in the docked composer. The suggestion chips seed the same
* composer via `onPickPrompt`.
* exactly as in the docked composer.
*
* Motion: one orchestrated reveal — sparkle → greeting → composer → chips
* stagger in via `motion-safe` `animate-in` with capped delays;
* reduced-motion users get the layout instantly.
* v1.18.10 (W4) — the starter-question suggestion chips below the composer
* are removed (maintainer feedback): the hero is greeting + composer only,
* matching the reference ChatGPT/Claude new-chat surface.
*
* Motion: one orchestrated reveal — sparkle → greeting → composer stagger
* in via `motion-safe` `animate-in` with capped delays; reduced-motion
* users get the layout instantly.
*/
export interface CoachHeroProps {
/** The live `<CoachInput>` composer, re-parented into the hero. */
composer: React.ReactNode;
/** Localised starter prompts shown as chips below the composer. */
prompts: string[];
/** Seeds the composer with a chip's text (the parent submits on pick). */
onPickPrompt: (prompt: string) => void;
}
export function CoachHero({ composer, prompts, onPickPrompt }: CoachHeroProps) {
export function CoachHero({ composer }: CoachHeroProps) {
const { t } = useTranslations();
return (
@@ -93,41 +92,6 @@ export function CoachHero({ composer, prompts, onPickPrompt }: CoachHeroProps) {
>
{composer}
</div>
{/* Starter-question chips, seeded with the Coach's own prompts. */}
{prompts.length > 0 && (
<div
data-slot="coach-hero-chips"
className={cn(
"flex flex-wrap items-center justify-center gap-2",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-500",
)}
style={{ animationDelay: "240ms", animationFillMode: "both" }}
>
{prompts.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onPickPrompt(prompt)}
data-slot="coach-hero-chip"
title={prompt}
className={cn(
"border-dracula-purple/20 hover:border-dracula-purple/45 hover:text-foreground",
"text-muted-foreground inline-flex min-h-11 max-w-full items-center gap-1.5 sm:min-h-9 sm:max-w-[18rem]",
"rounded-full border bg-transparent px-3.5 py-1.5 text-xs sm:text-sm",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-dracula-purple/50",
)}
>
<Quote className="size-3 shrink-0" aria-hidden="true" />
{/* Cap the chip to a tidy single line — long localized prompts
(notably DE) otherwise wrap to ragged multi-line pills on a
narrow viewport. The full prompt stays available via title. */}
<span className="truncate">{prompt}</span>
</button>
))}
</div>
)}
</div>
</div>
);