From 44796d1c0e4e31426231987bc2e49b912d59173e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:37:54 +0200 Subject: [PATCH] feat(coach): carry metric scope into the Coach launch Make the launch context's metric scope live instead of discarding it. A conversation opened from a metric surface now narrows its snapshot to that metric with a data-aware seed question, and the global FAB inherits the page's ambient scope so drilling into a metric and tapping it no longer opens a blank chat. The scope threads through the drawer into the first turn of a fresh conversation. --- src/components/insights/coach-metric-scope.ts | 167 ++++++++++++++++++ .../__tests__/coach-launch-scope.test.ts | 76 ++++++++ .../coach-panel/coach-conversation.tsx | 42 +++++ .../insights/coach-panel/coach-drawer.tsx | 15 +- .../insights/layout-coach-mount.tsx | 4 + src/components/insights/sub-page-shell.tsx | 45 ++++- src/lib/insights/coach-launch-context.tsx | 102 +++++++++-- 7 files changed, 431 insertions(+), 20 deletions(-) create mode 100644 src/components/insights/coach-metric-scope.ts create mode 100644 src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts diff --git a/src/components/insights/coach-metric-scope.ts b/src/components/insights/coach-metric-scope.ts new file mode 100644 index 000000000..317cb64a0 --- /dev/null +++ b/src/components/insights/coach-metric-scope.ts @@ -0,0 +1,167 @@ +import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types"; + +/** + * v1.21.0 (C4 H1/H4) — metric → Coach scope + seed-question map. + * + * The Coach launch context can now carry a live `scope` (a + * `CoachScopeSource` + window) and a `prefill` seed question into the + * conversation. This module is the single place that resolves "the user + * is looking at metric X" into "open the Coach narrowed to source X with + * a data-aware opener." + * + * Two callers consume it: + * - `` registers the active metric's scope as the page's + * ambient scope so the global FAB opens contextual to that page + * (no per-metric header icon — CCH-04 stays intact). + * - the "Ask the Coach" affordance on insight/assessment cards passes + * an explicit scope so a card tap lands a pre-scoped conversation. + * + * Seed questions are plain English strings, mirroring the empty-state + * `coachPrefill` convention already in the tree — the Coach chat route + * is English/German-gated and treats the prefill as composer seed text, + * not an i18n key. + */ + +export interface CoachMetricScope { + /** Primary source the snapshot narrows to. */ + metric: CoachScopeSource; + /** Extra sources to include alongside `metric` (e.g. correlations). */ + also?: CoachScopeSource[]; + /** Optional day-window override; defaults to the route's `last30days`. */ + window?: CoachScopeWindow; + /** Composer seed question — a data-aware opener for the metric. */ + question: string; +} + +/** + * `SubPageShell` passes the metric's `explainerMetric` token (the key + * feeding `insights.subPage.explainer.Body`). Map the tokens that + * resolve to a snapshot source to a scope + opener. Tokens absent here + * (mobility / gait micro-metrics with no dedicated snapshot block) simply + * carry no ambient scope — the FAB opens the default snapshot, exactly as + * before, so this is purely additive. + */ +const EXPLAINER_METRIC_SCOPE: Record = { + bloodPressure: { + metric: "bp", + question: + "Walk me through my blood pressure trend over the last 30 days — anything I should keep an eye on?", + }, + weight: { + metric: "weight", + question: + "How has my weight been trending lately, and is the direction something to act on?", + }, + bmi: { + metric: "bmi", + question: "What does my BMI trend tell me, and how should I read it?", + }, + pulse: { + metric: "pulse", + question: + "Walk me through my pulse readings — is anything out of the ordinary?", + }, + restingHr: { + metric: "resting_hr", + question: + "How has my resting heart rate been trending, and what does it say about my fitness?", + }, + hrv: { + metric: "hrv", + question: + "What is my heart-rate variability telling me about recovery and stress lately?", + }, + sleep: { + metric: "sleep", + question: + "Walk me through my recent sleep — duration, consistency, and anything worth changing.", + }, + mood: { + metric: "mood", + question: "What patterns do you see in my mood over the last few weeks?", + }, + medications: { + metric: "compliance", + question: + "How is my medication adherence looking, and is it lined up with how I've been feeling?", + }, + steps: { + metric: "steps", + question: + "How active have I been lately based on my steps, and how does it compare to a healthy baseline?", + }, + activeEnergy: { + metric: "active_energy", + question: + "What does my active-energy trend say about my activity level lately?", + }, + cardioFitness: { + metric: "vo2_max", + question: + "What does my cardio fitness (VO₂ max) trend mean, and how do I move it in the right direction?", + }, + bloodGlucose: { + metric: "glucose", + question: + "Walk me through my recent glucose readings — are they in a healthy range?", + }, + oxygenSaturation: { + metric: "spo2", + question: "What does my blood-oxygen (SpO₂) trend tell me?", + }, + respiratoryRate: { + metric: "respiratory_rate", + question: "What does my respiratory-rate trend say about my health?", + }, + workouts: { + metric: "workouts", + question: + "Walk me through my recent workouts — load, frequency, and how I'm recovering between them.", + }, + // Recovery is a synthesis page; anchor on HRV + resting HR + sleep so the + // Coach reads the inputs that drive the recovery read. + recoveryPage: { + metric: "hrv", + also: ["resting_hr", "sleep"], + window: "last7days", + question: + "Why is my recovery where it is right now, and what's driving it?", + }, +}; + +/** Resolve a `SubPageShell` explainer token to a Coach scope, or null. */ +export function metricScopeFromExplainer( + explainerMetric: string | undefined, +): CoachMetricScope | null { + if (!explainerMetric) return null; + return EXPLAINER_METRIC_SCOPE[explainerMetric] ?? null; +} + +/** + * Resolve a recommendation / correlation `metricSource.type` — the + * model's snapshot-key vocabulary ("bloodPressure", "weight", "pulse", + * "mood", "medications.compliance30", "sleep", "steps", …) — to a single + * `CoachScopeSource`, or null when the key has no snapshot block to + * narrow to. Lets a card's "Ask the Coach" affordance pre-scope the + * conversation to the metric the card is about. + */ +export function scopeSourceFromMetricKey( + metricKey: string | undefined, +): CoachScopeSource | null { + if (!metricKey) return null; + const lower = metricKey.toLowerCase(); + if (lower.startsWith("medications.compliance") || lower === "medication") { + return "compliance"; + } + if (lower === "bloodpressure" || lower === "blood_pressure") return "bp"; + if (lower === "weight") return "weight"; + if (lower === "pulse") return "pulse"; + if (lower === "resting_hr" || lower === "restinghr") return "resting_hr"; + if (lower === "hrv") return "hrv"; + if (lower === "mood") return "mood"; + if (lower === "sleep" || lower.startsWith("sleep")) return "sleep"; + if (lower === "steps" || lower === "activity") return "steps"; + if (lower === "bloodglucose" || lower === "blood_glucose") return "glucose"; + if (lower === "bmi") return "bmi"; + return null; +} diff --git a/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts new file mode 100644 index 000000000..0619ce106 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { launchScopeToCoachScope } from "../coach-conversation"; +import { + metricScopeFromExplainer, + scopeSourceFromMetricKey, +} from "../../coach-metric-scope"; + +/** + * v1.21.0 (C4 H1/H4) — the Coach launch scope is now live. These tests pin + * the conversion from the UI launch scope to the chat route's wire + * `CoachScope`, plus the two metric→source resolvers that feed it. + */ +describe("launchScopeToCoachScope", () => { + it("returns undefined for an absent / metric-less scope (default snapshot)", () => { + expect(launchScopeToCoachScope(null)).toBeUndefined(); + expect(launchScopeToCoachScope(undefined)).toBeUndefined(); + expect(launchScopeToCoachScope({})).toBeUndefined(); + }); + + it("maps a single metric to a one-source scope", () => { + expect(launchScopeToCoachScope({ metric: "bp" })).toEqual({ + sources: ["bp"], + }); + }); + + it("includes `also` sources and dedupes against the primary", () => { + expect( + launchScopeToCoachScope({ metric: "bp", also: ["compliance", "bp"] }), + ).toEqual({ sources: ["bp", "compliance"] }); + }); + + it("threads the window through only when present", () => { + expect( + launchScopeToCoachScope({ metric: "hrv", window: "last7days" }), + ).toEqual({ sources: ["hrv"], window: "last7days" }); + expect(launchScopeToCoachScope({ metric: "hrv" })).not.toHaveProperty( + "window", + ); + }); +}); + +describe("metricScopeFromExplainer", () => { + it("resolves a mapped explainer token to a scope + opener", () => { + const resolved = metricScopeFromExplainer("bloodPressure"); + expect(resolved?.metric).toBe("bp"); + expect(resolved?.question).toMatch(/blood pressure/i); + }); + + it("anchors the recovery page on its driver sources + short window", () => { + const resolved = metricScopeFromExplainer("recoveryPage"); + expect(resolved?.metric).toBe("hrv"); + expect(resolved?.also).toEqual(["resting_hr", "sleep"]); + expect(resolved?.window).toBe("last7days"); + }); + + it("returns null for an unmapped / undefined token", () => { + expect(metricScopeFromExplainer("walkingAsymmetry")).toBeNull(); + expect(metricScopeFromExplainer(undefined)).toBeNull(); + }); +}); + +describe("scopeSourceFromMetricKey", () => { + it("maps the model's snapshot-key vocabulary to a scope source", () => { + expect(scopeSourceFromMetricKey("bloodPressure")).toBe("bp"); + expect(scopeSourceFromMetricKey("medications.compliance30")).toBe( + "compliance", + ); + expect(scopeSourceFromMetricKey("WEIGHT")).toBe("weight"); + }); + + it("returns null for an unknown / absent key", () => { + expect(scopeSourceFromMetricKey("vascular_age")).toBeNull(); + expect(scopeSourceFromMetricKey(undefined)).toBeNull(); + }); +}); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index add693bda..84c074f18 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -17,6 +17,8 @@ import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; 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 { CoachDrawerBody } from "./coach-drawer-body"; import { CoachHero } from "./coach-hero"; @@ -64,6 +66,27 @@ import { * `` (required for the dialog's accessible name) while * the page mounts a plain `

` / `

`. */ + +/** + * v1.21.0 (C4 H1/H4) — collapse a UI launch scope ({ metric, also, + * window }) into the chat route's wire scope ({ sources, window }). + * Returns undefined when there is nothing to narrow, so the request + * falls back to the route's default all-source snapshot. Exported for + * the unit test that pins the source-dedup + window contract. + */ +export function launchScopeToCoachScope( + launchScope: CoachLaunchScope | null | undefined, +): CoachScope | undefined { + if (!launchScope?.metric) return undefined; + const sources = Array.from( + new Set([launchScope.metric, ...(launchScope.also ?? [])]), + ); + return { + sources, + ...(launchScope.window ? { window: launchScope.window } : {}), + }; +} + export interface CoachConversationProps { /** * Pre-fill for the composer (suggested-prompt chip click). Resets the @@ -71,6 +94,15 @@ export interface CoachConversationProps { * freely between prop changes. */ prefill?: string | null; + /** + * v1.21.0 (C4 H1/H4) — optional launch scope so a conversation opened + * from a metric surface or insight card narrows its snapshot to the + * relevant source(s) + window. Converted to the chat route's + * `CoachScope` and attached to the FIRST turn of a fresh conversation + * (`currentConversationId === null`); a continued thread keeps its own + * established scope. Null → the route's default all-source snapshot. + */ + launchScope?: CoachLaunchScope | null; /** * Renders the conversation title. The surface passes the resolved * title string; the drawer wraps it in ``, the page in an @@ -148,6 +180,7 @@ export interface CoachConversationProps { export function CoachConversation({ prefill, + launchScope, renderTitle, renderDescription, leadingHeaderActions, @@ -277,12 +310,21 @@ export function CoachConversation({ dismissQuestions.mutate(guidedQuestion); } setInputValue(""); + // v1.21.0 (C4 H1/H4) — attach the launch scope to the FIRST turn of a + // fresh conversation so a chat opened from a metric surface / insight + // card reads a snapshot narrowed to the relevant source(s). A continued + // thread (existing id) keeps its own established scope, so we omit it. + const scope = + currentConversationId === null + ? launchScopeToCoachScope(launchScope) + : undefined; // v1.16.6 — hand the question to the turn so the Coach reaction is // contextual (the question bubble itself is never persisted). const resolvedId = await send.send({ conversationId: currentConversationId ?? undefined, message: trimmed, guidedQuestion: guidedQuestion ?? undefined, + scope, }); if (guidedQuestion !== null && guidedIndex !== null) { setPendingAdopt({ diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index bcbfce7d3..06f2cf7bf 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -12,6 +12,7 @@ import { SheetDescription, SheetTitle, } from "@/components/ui/sheet"; +import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useIsMobile } from "@/hooks/use-is-mobile"; @@ -47,9 +48,20 @@ export interface CoachDrawerProps { onOpenChange: (next: boolean) => void; /** Optional pre-fill for the input box (suggested-prompt chip click). */ prefill?: string | null; + /** + * v1.21.0 (C4 H1/H4) — optional launch scope so a conversation opened + * from a metric surface or insight card is pre-narrowed to the relevant + * source(s) + window. Null → the route's default all-source snapshot. + */ + scope?: CoachLaunchScope | null; } -export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) { +export function CoachDrawer({ + open, + onOpenChange, + prefill, + scope, +}: CoachDrawerProps) { const { t } = useTranslations(); const router = useRouter(); // v1.4.27 R3d MB1 — below the `sm` breakpoint (640 px) the Coach @@ -126,6 +138,7 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) { ); } diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 95e44c0ea..ec21ae39e 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -15,6 +15,8 @@ import { } from "@/components/ui/tooltip"; import { TargetAdjustButton } from "@/components/insights/target-adjust-button"; import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context"; +import { metricScopeFromExplainer } from "@/components/insights/coach-metric-scope"; +import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -110,9 +112,15 @@ export interface SubPageShellProps { * v1.18.6 (CCH-04) — RETIRED as a rendered control. With the Coach FAB * anchored bottom-right on every authenticated page, the per-metric * Coach icon was a redundant second entry point, so the shell no longer - * paints anything for it. The prop stays on the signature (every routed - * category page still passes it) to avoid a churny rename across ~14 - * call sites; it is intentionally inert. + * paints anything for it. + * + * v1.21.0 (C4 H1) — re-purposed without re-adding any control. When set + * (and `explainerMetric` maps to a Coach source), the shell registers + * the page's metric as the launch context's ambient scope while it is + * mounted. The global FAB then opens a conversation pre-scoped to this + * metric with a data-aware seed question — so drilling into BP / weight + * / sleep and tapping the FAB no longer opens a blank chat. Still no + * second header affordance: the FAB stays the single entry point. */ coachLaunch?: boolean; /** @@ -137,14 +145,15 @@ export function SubPageShell({ focusOnMount = false, statStrip, diversityNudge, - // v1.18.6 (CCH-04) — `coachLaunch` is intentionally not destructured: - // the prop is still accepted on the type (call sites pass it) but the - // shell no longer renders a Coach control. The FAB is the single Coach - // entry point app-wide. + // v1.21.0 (C4 H1) — `coachLaunch` no longer renders a control (CCH-04 + // stands) but now gates whether the page registers its metric as the + // Coach launch context's ambient scope, so the FAB opens contextual. + coachLaunch = false, showAllValuesType, children, }: SubPageShellProps) { const { t } = useTranslations(); + const launch = useCoachLaunch(); // v1.10.2 — the "show all readings" link carries the originating metric // page as a `from` param so the values sub-page can offer a back-link to // where the user drilled in from (e.g. `weight → show all values → back to @@ -171,6 +180,28 @@ export function SubPageShell({ return () => window.cancelAnimationFrame(handle); }, [focusOnMount]); + // v1.21.0 (C4 H1) — register the page's metric as the Coach launch + // context's ambient scope while the sub-page is mounted, so the global + // FAB opens a conversation pre-scoped to this metric with a data-aware + // seed question. Only when the page opted in (`coachLaunch`) and its + // `explainerMetric` maps to a snapshot source; mobility / gait micro- + // metrics with no map entry leave the FAB on its default snapshot. The + // cleanup lifts the scope on navigation away. + const registerScope = launch?.registerScope; + useEffect(() => { + if (!coachLaunch || !registerScope) return; + const resolved = metricScopeFromExplainer(explainerMetric); + if (!resolved) return; + return registerScope( + { + metric: resolved.metric, + also: resolved.also, + window: resolved.window, + }, + resolved.question, + ); + }, [coachLaunch, registerScope, explainerMetric]); + return ( // The target-adjust provider bridges the header gear (rendered just // below) to the per-metric `` opened from the diff --git a/src/lib/insights/coach-launch-context.tsx b/src/lib/insights/coach-launch-context.tsx index 1adfa9824..cf7754813 100644 --- a/src/lib/insights/coach-launch-context.tsx +++ b/src/lib/insights/coach-launch-context.tsx @@ -5,11 +5,12 @@ import { useCallback, useContext, useMemo, + useRef, useState, type ReactNode, } from "react"; -import type { CoachScopeSource } from "@/lib/ai/coach/types"; +import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types"; /** * v1.4.27 R3d MB4 — Coach launch context. @@ -35,16 +36,30 @@ import type { CoachScopeSource } from "@/lib/ai/coach/types"; export interface CoachLaunchScope { /** * Optional metric the user is looking at when they open the Coach. - * Reserved for v1.4.28 so the drawer can pre-narrow the source rail - * to the active metric; ignored in v1.4.27. * * v1.4.28 R3c (BK-MED-2 / BK-F-M4) — narrowed from `string` to the * `CoachScopeSource` union so call sites cannot drift to a free-form * label that the sources rail then silently ignores. Adding a new * Apple-Health source to the rail (the union already covers the * v1.4.23 additions) automatically widens the allowed values here. + * + * v1.21.0 (C4 H1/H4) — now LIVE: the scope threads through the drawer + * into the chat request so a conversation opened from a metric surface + * or an insight card is pre-narrowed to the relevant source(s). The + * `void scope` discard is gone. */ metric?: CoachScopeSource; + /** + * Optional extra sources to include alongside `metric` — a correlation + * card spanning two metrics (e.g. weight × pulse) seeds both so the + * snapshot the Coach reads covers the relationship the card describes. + */ + also?: CoachScopeSource[]; + /** + * Optional day-window the conversation should anchor on. Defaults to + * the chat route's own `last30days` when omitted. + */ + window?: CoachScopeWindow; } interface CoachLaunchValue { @@ -52,8 +67,29 @@ interface CoachLaunchValue { open: boolean; /** Current prefill string (or null when the next open should start blank). */ prefill: string | null; + /** + * Scope the next/open conversation is narrowed to (or null for the + * default all-source snapshot). Set by `askCoach(prefill, scope)`, or + * inherited from the page's ambient scope when the FAB opens without an + * explicit one. `` forwards it to the drawer. + */ + scope: CoachLaunchScope | null; /** Open the drawer with an optional prefill + scope hint. */ askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void; + /** + * v1.21.0 (C4 H1) — register the metric surface the user is currently + * looking at so the global FAB (which calls `askCoach()` with no args) + * still opens the Coach pre-scoped to that page. The optional + * `seedPrefill` is the composer opener the FAB seeds when it inherits + * this ambient scope. Returns a cleanup that clears the ambient scope; a + * metric page calls it from an effect so the scope lifts when the user + * navigates away. This keeps the FAB the single app-wide entry point + * (CCH-04) while making it contextual. + */ + registerScope: ( + scope: CoachLaunchScope, + seedPrefill?: string | null, + ) => () => void; /** * Direct setter for the open state — the drawer's `onOpenChange` * consumes it on close. Kept (rather than collapsed into a @@ -74,30 +110,72 @@ export interface CoachLaunchProviderProps { export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { const [open, setOpen] = useState(false); const [prefill, setPrefill] = useState(null); + // Scope the open conversation is narrowed to. `null` → default snapshot. + const [scope, setScope] = useState(null); + // Ambient scope + seed opener of the metric surface currently on screen. + // The FAB's `askCoach()` (no args) falls back to these so opening the + // Coach from a metric page still lands a pre-scoped, pre-seeded + // conversation without re-adding a per-metric header icon (CCH-04). Refs, + // not state — registering must not re-render the provider's subtree. + const ambientScopeRef = useRef(null); + const ambientPrefillRef = useRef(null); const askCoach = useCallback( - (nextPrefill?: string | null, scope?: CoachLaunchScope) => { - // `scope` is currently reserved for v1.4.28 (metric-narrow on the - // sources rail). Accept the parameter today so the call sites - // don't need to change shape when the rail starts honouring it. - void scope; - setPrefill(nextPrefill ?? null); + (nextPrefill?: string | null, nextScope?: CoachLaunchScope) => { + // v1.21.0 (C4 H1/H4) — scope is live. An explicit scope (insight + // card, metric-card affordance) wins; otherwise inherit the metric + // page's ambient scope so the FAB opens contextual to where the user + // is. `null` only when neither is present (true global launch). The + // composer seed follows the same precedence: explicit prefill, else + // the ambient page opener that pairs with the inherited scope. + const usingAmbientScope = nextScope === undefined; + setScope(nextScope ?? ambientScopeRef.current ?? null); + setPrefill( + nextPrefill ?? + (usingAmbientScope ? ambientPrefillRef.current : null) ?? + null, + ); setOpen(true); }, [], ); + const registerScope = useCallback( + (next: CoachLaunchScope, seedPrefill?: string | null) => { + ambientScopeRef.current = next; + ambientPrefillRef.current = seedPrefill ?? null; + return () => { + // Only clear if this registration is still the active one — guards + // against a fast route change where the next page registers before + // the previous page's cleanup runs. + if (ambientScopeRef.current === next) { + ambientScopeRef.current = null; + ambientPrefillRef.current = null; + } + }; + }, + [], + ); + const handleSetOpen = useCallback((next: boolean) => { setOpen(next); if (!next) { - // Drop the prefill on close so the next open starts blank. + // Drop the prefill + scope on close so the next open starts clean. setPrefill(null); + setScope(null); } }, []); const value = useMemo( - () => ({ open, prefill, askCoach, setOpen: handleSetOpen }), - [open, prefill, askCoach, handleSetOpen], + () => ({ + open, + prefill, + scope, + askCoach, + registerScope, + setOpen: handleSetOpen, + }), + [open, prefill, scope, askCoach, registerScope, handleSetOpen], ); return (