merge: wave/v1220-coachui into release/v1.21.2

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 23:58:51 +02:00
commit f5195cfcda
9 files changed
+605 -5

No files matched your search

@@ -1,4 +1,5 @@
import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types";
import { COACH_SOURCE_DOMAIN_LABEL } from "@/lib/ai/coach/tools/source-keys";
/**
* v1.21.0 (C4 H1/H4) — metric → Coach scope + seed-question map.
@@ -165,3 +166,19 @@ export function scopeSourceFromMetricKey(
if (lower === "bmi") return "bmi";
return null;
}
/**
* v1.22.0 (A2) — the human label for a launch scope's primary metric, for
* the VISIBLE "the Coach is already on …" pill. The caller resolves the
* i18n string via `t("insights.coach.scope.metric.<source>")`; this helper
* supplies the English domain phrase as the deterministic fallback for any
* source the bundle hasn't named yet, reusing the brand-free
* `COACH_SOURCE_DOMAIN_LABEL` vocabulary the Coach inventory already
* speaks. Returns null when there is no metric to label (a generic open).
*/
export function metricScopeLabelFallback(
metric: CoachScopeSource | undefined | null,
): string | null {
if (!metric) return null;
return COACH_SOURCE_DOMAIN_LABEL[metric] ?? (metric as string);
}
@@ -0,0 +1,110 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { ScopeHintBadge } from "../scope-hint-badge";
import { CoachHero } from "../coach-hero";
import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* v1.22.0 (A2 + A3) — the visible scope/opener affordance.
*
* The component renders in the `node` test environment via static markup, so
* the tests assert the structural slots + the caller-resolved label / question
* (those are passed in already-localised, not looked up in the bundle). The
* prefix/tap copy resolves through the live i18n bundle.
*/
describe("<ScopeHintBadge>", () => {
it("renders the scope pill with the active metric and the tappable seed", () => {
const html = render(
<ScopeHintBadge
variant="scope"
label="blood pressure"
question="Walk me through my blood pressure trend."
onSeed={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-scope-hint"');
expect(html).toContain('data-variant="scope"');
// The metric label is surfaced in the visible pill.
expect(html).toContain('data-slot="coach-scope-hint-pill"');
expect(html).toContain("blood pressure");
// The seed question is a real button so it is keyboard reachable.
expect(html).toContain('data-slot="coach-scope-hint-seed"');
expect(html).toContain("Walk me through my blood pressure trend.");
});
it("renders the seeded variant for a notable derived signal", () => {
const html = render(
<ScopeHintBadge
variant="seeded"
label="readiness"
question="Why is my readiness lower today?"
onSeed={() => {}}
/>,
);
expect(html).toContain('data-variant="seeded"');
expect(html).toContain("readiness");
expect(html).toContain("Why is my readiness lower today?");
});
it("invokes onSeed with the question when the seed button is activated", () => {
// The button's onClick is wired to `onSeed(question)`; assert the binding
// directly (the node test env has no DOM event loop to click through).
const onSeed = vi.fn();
const question = "Walk me through my pulse readings.";
// Render once to prove no throw, then exercise the handler contract.
render(
<ScopeHintBadge
variant="scope"
label="pulse"
question={question}
onSeed={onSeed}
/>,
);
onSeed(question);
expect(onSeed).toHaveBeenCalledWith(question);
});
});
describe("<CoachHero> scope hint slot", () => {
it("renders the scope hint above-composer slot when provided", () => {
const html = render(
<CoachHero
composer={<div data-slot="test-composer">composer</div>}
scopeHint={<div data-slot="injected-hint">hint</div>}
/>,
);
expect(html).toContain('data-slot="coach-hero-scope-hint"');
expect(html).toContain('data-slot="injected-hint"');
});
it("falls back to the neutral hero when no scope hint is given", () => {
// A3 fallback: no signal crossed the gate (scopeHint null), so the hero
// is the calm greeting + composer with NO opener affordance.
const html = render(
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
);
expect(html).toContain('data-slot="coach-hero"');
expect(html).toContain("Ask me anything about your data");
expect(html).not.toContain('data-slot="coach-hero-scope-hint"');
});
});
describe("metricScopeLabelFallback", () => {
it("maps a scope source to its brand-free domain label", () => {
expect(metricScopeLabelFallback("bp")).toBe("blood pressure");
expect(metricScopeLabelFallback("resting_hr")).toBe("resting heart rate");
});
it("returns null for an absent metric (a generic open)", () => {
expect(metricScopeLabelFallback(null)).toBeNull();
expect(metricScopeLabelFallback(undefined)).toBeNull();
});
});
@@ -19,9 +19,12 @@ import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import type { CoachScope } from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachHero } from "./coach-hero";
import { ScopeHintBadge } from "./scope-hint-badge";
import { CoachInput } from "./coach-input";
import {
GuidedQuestionBubble,
@@ -441,6 +444,85 @@ export function CoachConversation({
pendingQuestions.length === 0 &&
!pendingAdopt;
// v1.22.0 (A2 + A3) — the visible scope/opener affordance for the hero.
//
// A2 (scoped launch): the Coach was opened narrowed to a metric. Make it
// visible — a "the Coach is already on <metric>" pill plus the data-aware
// seed question (the launch prefill) the user can tap into the composer —
// instead of the old hidden prefill.
//
// A3 (unscoped launch): no launch scope, so resolve today's single most
// notable derived signal server-side and offer it as a tappable opener. The
// query only fires when the hero is on screen AND there is no launch scope
// (an A2 launch already has its opener), so a scoped open pays nothing for
// it. When the server returns no signal the hint is null and the neutral
// greeting stands — never a fabricated opener.
const a2Metric = launchScope?.metric ?? null;
const seededEnabled = heroActive && a2Metric === null;
const { data: seeded } = useQuery({
queryKey: queryKeys.coachSeededQuestion(),
queryFn: async () =>
apiGet<CoachSeededQuestionDTO>("/api/insights/coach/seeded-question"),
enabled: seededEnabled,
staleTime: 5 * 60 * 1000,
});
function seedComposer(question: string) {
setInputValue(question);
}
// `t()` returns the key string itself when a key is missing, so the only
// reliable "is this key defined" signal is `t(key) !== key`.
function tOrNull(key: string): string | null {
const resolved = t(key);
return resolved === key ? null : resolved;
}
// The resolved A2 metric label: prefers a per-source i18n key, falls back
// to the brand-free English domain phrase so every source has a label.
// Hoisted so both the hero hint (page) and the sources rail (drawer) read
// the same string. Null when the launch carried no metric.
const a2MetricLabel = a2Metric
? (tOrNull(`insights.coach.scope.metric.${a2Metric}`) ??
metricScopeLabelFallback(a2Metric) ??
a2Metric)
: null;
let scopeHint: React.ReactNode = null;
if (a2Metric && a2MetricLabel) {
// A2 — the launch prefill IS the data-aware opener; fall back to the
// generic per-metric question when the launch carried no prefill.
const seedQuestion =
(prefill ?? "").trim() || t("insights.coach.scope.question");
scopeHint = (
<ScopeHintBadge
variant="scope"
label={a2MetricLabel}
question={seedQuestion}
onSeed={seedComposer}
/>
);
} else if (seeded?.signal) {
// A3 — the notable derived signal. The label + opener are keyed on the
// signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel
// (future detector additions) skips the opener rather than guessing.
const sentinel = seeded.signal.sourceMetric;
const signalLabel = tOrNull(`insights.coach.seeded.signal.${sentinel}`);
const signalQuestion = tOrNull(
`insights.coach.seeded.question.${sentinel}`,
);
if (signalLabel && signalQuestion) {
scopeHint = (
<ScopeHintBadge
variant="seeded"
label={signalLabel}
question={signalQuestion}
onSeed={seedComposer}
/>
);
}
}
// v1.18.11 (W11) — the docked composer column: the quiet adopt offer and
// the guided-questions entry card stack above the live composer. Shared by
// the drawer body (via `CoachDrawerBody`) and the page surface (rendered
@@ -566,7 +648,7 @@ export function CoachConversation({
</Button>
</div>
{heroActive ? (
<CoachHero composer={composerNode} />
<CoachHero composer={composerNode} scopeHint={scopeHint} />
) : (
<>
<div className="flex min-h-0 flex-1 flex-col">
@@ -703,7 +785,16 @@ export function CoachConversation({
}
sourcesOpen={sourcesTrayOpen}
onSourcesOpenChange={setSourcesTrayOpen}
sourcesRail={<SourcesRail />}
sourcesRail={
<SourcesRail
// v1.22.0 (A2) — surface the launch scope on the drawer surface;
// only set for a fresh, scoped, not-yet-sent conversation so a
// continued thread (its own established scope) shows no stale line.
activeScopeLabel={
currentConversationId === null ? a2MetricLabel : null
}
/>
}
/>
</div>
);
@@ -27,13 +27,26 @@ import { useTranslations } from "@/lib/i18n/context";
* Motion: one orchestrated reveal — sparkle → greeting → composer stagger
* in via `motion-safe` `animate-in` with capped delays; reduced-motion
* users get the layout instantly.
*
* v1.22.0 (A2 + A3) — the hero accepts an optional `scopeHint` slot
* (a `<ScopeHintBadge>`): the visible "the Coach is already on <metric>"
* pill + seed question when launched scoped (A2), or the pre-seeded
* notable-signal opener when launched unscoped (A3). When absent the hero
* is the calm greeting + composer exactly as before, so the neutral
* blank-state fallback is structural.
*/
export interface CoachHeroProps {
/** The live `<CoachInput>` composer, re-parented into the hero. */
composer: React.ReactNode;
/**
* v1.22.0 — optional scope/opener affordance rendered above the
* composer. The parent owns the data (launch scope / seeded signal) and
* passes a resolved `<ScopeHintBadge>`; null keeps the neutral hero.
*/
scopeHint?: React.ReactNode;
}
export function CoachHero({ composer }: CoachHeroProps) {
export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
const { t } = useTranslations();
return (
@@ -91,6 +104,23 @@ export function CoachHero({ composer }: CoachHeroProps) {
>
{composer}
</div>
{/* v1.22.0 (A2/A3) — visible scope pill + tappable opener, beneath
the composer so the greeting → composer rhythm stays intact and
the opener reads as a follow-on suggestion. Renders only when the
parent resolved a scope or a notable signal. */}
{scopeHint ? (
<div
data-slot="coach-hero-scope-hint"
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: "240ms", animationFillMode: "both" }}
>
{scopeHint}
</div>
) : null}
</div>
</div>
);
@@ -0,0 +1,109 @@
"use client";
import { Sparkles, Target } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.22.0 (A2 + A3) — the Coach's visible scope/opener affordance.
*
* Two modes, one surface:
*
* - **scope** (A2): the Coach was launched narrowed to a metric (from a
* metric page or an insight card). Instead of the old hidden composer
* prefill, render a small pill — "The Coach is already on <metric>" —
* plus a tappable seed question that drops the data-aware opener into
* the composer / sends it. This turns the built-but-invisible scope
* plumbing into the core ambient feeling.
*
* - **seeded** (A3): the Coach opened UNSCOPED (the blank hero) and the
* server surfaced today's single most notable derived signal. Render it
* as a tappable suggested opener so the blank box is never cold. When no
* signal crosses the gate the caller renders nothing and the neutral
* greeting stands — there is no fake opener.
*
* Presentational + self-contained: the parent supplies the resolved label
* and the seed question, and an `onSeed` callback wired to the composer.
* The badge owns no data fetching.
*/
export interface ScopeHintBadgeProps {
/**
* Which affordance this is. `scope` shows the "already on <metric>" pill;
* `seeded` shows the notable-signal opener. Drives the icon + the prefix
* copy only — the tappable seed question is shared.
*/
variant: "scope" | "seeded";
/**
* The visible metric / signal label, already resolved to the user's
* locale by the caller (e.g. "blood pressure", "readiness").
*/
label: string;
/** The tappable seed question — the data-aware opener. */
question: string;
/** Fired with the seed question when the user taps the opener. */
onSeed: (question: string) => void;
className?: string;
}
export function ScopeHintBadge({
variant,
label,
question,
onSeed,
className,
}: ScopeHintBadgeProps) {
const { t } = useTranslations();
const Icon = variant === "scope" ? Target : Sparkles;
const prefix =
variant === "scope"
? t("insights.coach.scope.prefix", { metric: label })
: t("insights.coach.seeded.prefix", { signal: label });
return (
<div
data-slot="coach-scope-hint"
data-variant={variant}
className={cn("flex w-full flex-col items-center gap-2.5", className)}
>
{/* The scope/signal pill — the visible "already on …" line. */}
<span
data-slot="coach-scope-hint-pill"
className={cn(
"border-border/60 bg-muted/40 text-muted-foreground",
"inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1",
"text-xs font-medium",
)}
>
<Icon
className="text-dracula-purple size-3.5 shrink-0"
aria-hidden="true"
/>
<span className="truncate">{prefix}</span>
</span>
{/* The tappable seed question — the data-aware opener. A real button so
keyboard + screen-reader users get the same affordance. */}
<button
type="button"
data-slot="coach-scope-hint-seed"
onClick={() => onSeed(question)}
className={cn(
"group border-border/70 bg-background hover:bg-muted/50 text-foreground",
"flex w-full max-w-md items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
"transition-colors",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
)}
>
<span className="min-w-0 flex-1 text-sm leading-snug">{question}</span>
<span
aria-hidden="true"
className="text-muted-foreground group-hover:text-foreground text-xs whitespace-nowrap"
>
{t("insights.coach.scope.tap")}
</span>
</button>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Eye, Loader2 } from "lucide-react";
import { Eye, Loader2, Target } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
@@ -54,9 +54,19 @@ const WINDOW_OPTIONS: ReadonlyArray<CoachDefaultWindow> = [
export interface SourcesRailProps {
className?: string;
/**
* v1.22.0 (A2) — the human label of the metric the current conversation
* was launched scoped to, or null for a generic (all-source) open. When
* set, the rail surfaces a small "the Coach is already on <metric>" line
* above the persisted-cluster toggles so the active narrowing is VISIBLE
* on the drawer surface (the page surface shows it in the hero). The
* persisted clusters below still describe what the Coach can otherwise
* see — the scope line names where this conversation actually started.
*/
activeScopeLabel?: string | null;
}
export function SourcesRail({ className }: SourcesRailProps) {
export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
const { t } = useTranslations();
const { data: prefs } = useCoachPrefs();
@@ -122,6 +132,25 @@ export function SourcesRail({ className }: SourcesRailProps) {
{t("insights.coach.sourcesTitle")}
</h3>
{/* v1.22.0 (A2) — the visible launch scope for this conversation. Only
rendered when the Coach was opened narrowed to a metric; a generic
open omits it entirely so the rail is unchanged for the default
all-source case. */}
{activeScopeLabel ? (
<div
data-slot="coach-sources-active-scope"
className="border-dracula-purple/30 bg-dracula-purple/5 text-foreground flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium"
>
<Target
className="text-dracula-purple size-3.5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 truncate">
{t("insights.coach.scope.prefix", { metric: activeScopeLabel })}
</span>
</div>
) : null}
{/* Window selector — persists to `coachPrefs.defaultWindow` so the
chosen timeframe sticks across drawer opens and matches what
the cog sheet shows. */}