mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user