diff --git a/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx new file mode 100644 index 000000000..98b2dd6af --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx @@ -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( + {node}, + ); +} + +describe("", () => { + it("renders the centred greeting, the composer slot, and the chips", () => { + const html = render( + composer} + 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( + {}} />, + "de", + ); + expect(html).toContain("Wie kann ich dir helfen?"); + }); + + it("omits the chip row when no prompts are supplied", () => { + const html = render( + {}} />, + ); + 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…"]); + }); +}); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index a6f785ab6..f5107c9f0 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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 = ( + 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 (
- setHistoryRailOpen((open) => !open) - : undefined - } - historyRail={ - surface === "page" ? ( - ` band already renders the - // "Conversations"

; suppress the rail's own

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 ? ( + handleSubmit(prompt)} + /> + ) : ( + setHistoryRailOpen((open) => !open) + : undefined + } + historyRail={ + surface === "page" ? ( + ` band already renders the + // "Conversations"

; suppress the rail's own

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={ + - ) : undefined - } - thread={ - - } - composer={ -
- {/* v1.16.4 — quiet adopt-into-self-context offer once a + } + composer={ +
+ {/* 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 ? ( - 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 ? ( + 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 ? ( - - dispatchGuided({ type: "START", questions: pendingQuestions }) - } - onLater={() => dispatchGuided({ type: "LATER" })} - onDismissAll={() => - dismissQuestions.mutate(undefined, { - onSuccess: () => dispatchGuided({ type: "LATER" }), - }) - } - /> - ) : null} - handleSubmit(inputValue)} - onCancel={send.cancel} - disabled={send.isStreaming} - isStreaming={send.isStreaming} - autoFocusOnOpen={autoFocusComposer} - placeholder={ - guided.phase === "asking" - ? t("insights.coach.guided.answerPlaceholder") - : undefined - } - /> -
- } - onHistoryClick={ - surface === "drawer" && onRequestFullView - ? onRequestFullView - : () => setHistoryTrayOpen(true) - } - onOpenSourcesTray={() => setSourcesTrayOpen(true)} - /> + {guided.phase === "idle" && pendingQuestions.length > 0 ? ( + + dispatchGuided({ + type: "START", + questions: pendingQuestions, + }) + } + onLater={() => dispatchGuided({ type: "LATER" })} + onDismissAll={() => + dismissQuestions.mutate(undefined, { + onSuccess: () => dispatchGuided({ type: "LATER" }), + }) + } + /> + ) : null} + {composerNode} +
+ } + onHistoryClick={ + surface === "drawer" && onRequestFullView + ? onRequestFullView + : () => setHistoryTrayOpen(true) + } + onOpenSourcesTray={() => setSourcesTrayOpen(true)} + /> + )} `). + * + * The hero does NOT fork composer logic: it RECEIVES the live + * `` 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 `` 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 ( +
+ {/* 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. */} + + ); +}