feat(coach): centred new-chat hero on the page surface

Replace the cramped empty thread on the /coach page with a centred hero:
a greeting, the live composer beneath it, and HealthLog's own starter
questions as chips, over a faint purple atmospheric backdrop with one
staggered entrance reveal. The hero re-parents the existing composer
rather than forking it, so dictation, auto-grow, and the send/stop
control behave identically; the first send docks the composer to the
bottom and the conversation takes over. Gated to the page surface and
the fresh new-chat state, so the drawer, guided-question, and adopt
flows are untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 12:12:54 +02:00
1 parent 88ee5bb4f8
commit 144bfbe553
3 files changed
+333 -88

No files matched your search

@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachHero } from "../coach-hero";
import { splitProseSegments } from "../streamed-prose";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<CoachHero>", () => {
it("renders the centred greeting, the composer slot, and the chips", () => {
const html = render(
<CoachHero
composer={<div data-slot="test-composer">composer</div>}
prompts={["What should I tell my doctor?", "Is my medication working?"]}
onPickPrompt={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-hero"');
// Greeting copy from insights.coach.heroGreeting.
expect(html).toContain("How can I help you?");
// 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",
);
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={() => {}} />,
);
expect(html).not.toContain('data-slot="coach-hero-chips"');
});
});
describe("splitProseSegments", () => {
it("splits prose into word+trailing-space segments", () => {
expect(splitProseSegments("Looking at your data")).toEqual([
"Looking ",
"at ",
"your ",
"data",
]);
});
it("returns an empty array for empty input", () => {
expect(splitProseSegments("")).toEqual([]);
});
it("keeps a single whitespace-free token intact", () => {
expect(splitProseSegments("Drafting…")).toEqual(["Drafting…"]);
});
});
@@ -13,6 +13,7 @@ import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachHero } from "./coach-hero";
import { CoachInput } from "./coach-input";
import {
GuidedQuestionBubble,
@@ -283,6 +284,53 @@ export function CoachConversation({
}),
);
// v1.18.9 — the live composer, lifted so it can mount in the new-chat
// hero (centred) OR docked at the bottom of the conversation without
// forking any composer logic (dictation, auto-grow, send/stop, the
// guided-question placeholder all behave identically in both spots).
const composerNode = (
<CoachInput
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
onCancel={send.cancel}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
autoFocusOnOpen={autoFocusComposer}
placeholder={
guided.phase === "asking"
? t("insights.coach.guided.answerPlaceholder")
: undefined
}
/>
);
// v1.18.9 — the centred new-chat hero replaces the cramped empty thread
// on the PAGE surface only. It shows exactly when the surface is a fresh,
// untouched new chat: no selected conversation, nothing streaming or
// optimistic, no error, and none of the guided / adopt / pending-question
// affordances in play (those belong in the docked conversation flow). The
// first send flips `send.isStreaming` / `send.optimisticUser`, so the hero
// unmounts and the docked thread + bottom composer take over.
const heroActive =
surface === "page" &&
currentConversationId === null &&
!send.isStreaming &&
!send.streaming.content &&
!send.streaming.errorCode &&
!send.optimisticUser &&
guided.phase === "idle" &&
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"
@@ -358,100 +406,98 @@ export function CoachConversation({
{trailingHeaderActions}
</header>
<CoachDrawerBody
historyOpen={historyRailOpen}
onToggleHistory={
surface === "page"
? () => setHistoryRailOpen((open) => !open)
: undefined
}
historyRail={
surface === "page" ? (
<HistoryRail
activeId={currentConversationId}
// v1.18.1 — the `<aside>` band already renders the
// "Conversations" <h2>; suppress the rail's own <h3> so the
// heading is not stacked twice on the page surface.
hideHeading
onSelect={(id) => {
// v1.16.5 — switching conversations drops the guided
// session; unanswered questions stay pending.
setCurrentConversationId(id);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
{heroActive ? (
<CoachHero
composer={composerNode}
prompts={heroPrompts}
onPickPrompt={(prompt) => handleSubmit(prompt)}
/>
) : (
<CoachDrawerBody
historyOpen={historyRailOpen}
onToggleHistory={
surface === "page"
? () => setHistoryRailOpen((open) => !open)
: undefined
}
historyRail={
surface === "page" ? (
<HistoryRail
activeId={currentConversationId}
// v1.18.1 — the `<aside>` band already renders the
// "Conversations" <h2>; suppress the rail's own <h3> so the
// heading is not stacked twice on the page surface.
hideHeading
onSelect={(id) => {
// v1.16.5 — switching conversations drops the guided
// session; unanswered questions stay pending.
setCurrentConversationId(id);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
/>
) : undefined
}
thread={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
) : undefined
}
thread={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
}
composer={
<div>
{/* v1.16.4 — quiet adopt-into-self-context offer once a
}
composer={
<div>
{/* v1.16.4 — quiet adopt-into-self-context offer once a
clarifying question has been answered. Self-removes
after settle; v1.16.5 reports the outcome back into the
guided machine for the closing summary. */}
{pendingAdopt && !send.isStreaming ? (
<SelfContextAdoptOffer
question={pendingAdopt.question}
answer={pendingAdopt.answer}
onDismiss={() => setPendingAdopt(null)}
onSettled={(adoption) =>
dispatchGuided({
type: "ADOPTION_SETTLED",
index: pendingAdopt.guidedIndex,
adoption,
})
}
/>
) : null}
{/* v1.16.5 — guided clarifying-questions entry card (V2 of
{pendingAdopt && !send.isStreaming ? (
<SelfContextAdoptOffer
question={pendingAdopt.question}
answer={pendingAdopt.answer}
onDismiss={() => setPendingAdopt(null)}
onSettled={(adoption) =>
dispatchGuided({
type: "ADOPTION_SETTLED",
index: pendingAdopt.guidedIndex,
adoption,
})
}
/>
) : null}
{/* v1.16.5 — guided clarifying-questions entry card (V2 of
the v1.16.0 chips). Offers the in-chat sequence while
questions pend and the flow hasn't started. */}
{guided.phase === "idle" && pendingQuestions.length > 0 ? (
<GuidedQuestionsCard
count={pendingQuestions.length}
disabled={send.isStreaming || dismissQuestions.isPending}
onStart={() =>
dispatchGuided({ type: "START", questions: pendingQuestions })
}
onLater={() => dispatchGuided({ type: "LATER" })}
onDismissAll={() =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "LATER" }),
})
}
/>
) : null}
<CoachInput
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
onCancel={send.cancel}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
autoFocusOnOpen={autoFocusComposer}
placeholder={
guided.phase === "asking"
? t("insights.coach.guided.answerPlaceholder")
: undefined
}
/>
</div>
}
onHistoryClick={
surface === "drawer" && onRequestFullView
? onRequestFullView
: () => setHistoryTrayOpen(true)
}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
{guided.phase === "idle" && pendingQuestions.length > 0 ? (
<GuidedQuestionsCard
count={pendingQuestions.length}
disabled={send.isStreaming || dismissQuestions.isPending}
onStart={() =>
dispatchGuided({
type: "START",
questions: pendingQuestions,
})
}
onLater={() => dispatchGuided({ type: "LATER" })}
onDismissAll={() =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "LATER" }),
})
}
/>
) : null}
{composerNode}
</div>
}
onHistoryClick={
surface === "drawer" && onRequestFullView
? onRequestFullView
: () => setHistoryTrayOpen(true)
}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
)}
<MobileRailTray
historyOpen={historyTrayOpen}
@@ -0,0 +1,130 @@
"use client";
import { Quote, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
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>`).
*
* 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`.
*
* 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.
*/
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) {
const { t } = useTranslations();
return (
<div
data-slot="coach-hero"
className="relative flex min-h-0 flex-1 flex-col items-center justify-center overflow-hidden px-4 py-8 sm:px-6"
>
{/* Atmospheric backdrop — a faint purple radial glow behind the
hero, never busy. Pointer-events-none + aria-hidden so it is
purely decorative; it sits behind the content via -z-10. */}
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 -z-10",
"bg-[radial-gradient(60%_50%_at_50%_38%,color-mix(in_srgb,var(--dracula-purple)_14%,transparent),transparent_70%)]",
)}
/>
<div className="flex w-full max-w-2xl flex-col items-center gap-6 text-center">
{/* Sparkle mark */}
<div
aria-hidden="true"
className={cn(
"from-dracula-purple to-dracula-pink flex size-12 items-center justify-center rounded-2xl bg-gradient-to-br shadow-sm",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:duration-500",
)}
>
<Sparkles className="text-background size-6" />
</div>
{/* Greeting — a slightly larger, tighter display treatment of the
existing font (weight/tracking/size only, no new font). */}
<div
className={cn(
"flex flex-col gap-1.5",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
)}
style={{ animationDelay: "80ms", animationFillMode: "both" }}
>
<h1 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{t("insights.coach.heroGreeting")}
</h1>
<p className="text-muted-foreground text-sm">
{t("insights.coach.heroSubline")}
</p>
</div>
{/* Composer — the live <CoachInput>, centred. */}
<div
data-slot="coach-hero-composer"
className={cn(
"w-full",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
)}
style={{ animationDelay: "160ms", animationFillMode: "both" }}
>
{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"
className={cn(
"border-dracula-purple/20 hover:border-dracula-purple/45 hover:text-foreground",
"text-muted-foreground inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
"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" />
<span>{prompt}</span>
</button>
))}
</div>
)}
</div>
</div>
);
}