mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(documents): open the doc-scoped Coach in the side drawer, not a full page
"Ask the Coach" on a document used to navigate to /coach?doc=<id> — a full-page jump away from the vault. It now opens the real, doc-scoped conversation in the shared side drawer, and the drawer's maximize control expands it to the full /coach page while preserving scope. - Thread an optional documentId through the launch context: askCoach gains a fourth argument, the provider tracks it alongside prefill/scope and clears it on close, and LayoutCoachMount forwards it to the drawer as initialDocumentId. The open-state precedence moves into a pure resolveLaunchState helper. - The vault detail sheet closes itself and calls askCoach(..., doc.id) instead of routing away, so the drawer owns the surface. - Maximize preserves continuity: the drawer reads the live conversation id via a new imperative getter out of the conversation surface (parallel to the reset registration) and hands off to /coach?c=<id> once a thread exists, falling back to /coach?doc=<id> for a chat maximized before its first turn. The send path is untouched: every document turn still routes through the hardened fenced endpoint keyed on documentId — only where the conversation renders (drawer vs page) changed. The documents-chat e2e now asserts the drawer opens in place, doc-scoped, rather than a URL change.
This commit is contained in:
1 parent
7c96abc8ec
commit
d72f078764
9 files changed
+390
-51
No files matched your search
+26
-18
@@ -1,19 +1,21 @@
|
||||
/**
|
||||
* Document vault — the "chat about a document" entry point (v1.28.51, R3).
|
||||
* Document vault — the "chat about a document" entry point (v1.28.52, R3).
|
||||
*
|
||||
* The bespoke in-sheet chat drawer is gone (Documents R3, Design A). Asking the
|
||||
* Coach about a document now hands off to the REAL Coach, scoped to that
|
||||
* document: the detail sheet's Coach affordance navigates to `/coach?doc=<id>`,
|
||||
* where the conversation runs on the same hardened fenced endpoint but inside
|
||||
* the Coach's own surface and history.
|
||||
* The bespoke in-sheet chat drawer is gone (Documents R3). Asking the Coach
|
||||
* about a document now opens the REAL Coach conversation, scoped to that
|
||||
* document, in the shared SIDE DRAWER: the detail sheet's Coach affordance
|
||||
* closes the sheet and opens the coach drawer pre-scoped to the document (its
|
||||
* maximize control expands to the full `/coach` page). The conversation runs on
|
||||
* the same hardened fenced endpoint — only WHERE it renders changed.
|
||||
*
|
||||
* This suite pins the documents-side contract: the affordance is offered only
|
||||
* when a provider is available, and it navigates to the doc-scoped Coach; with
|
||||
* NO provider the AI area collapses to the settings pointer and no Coach entry
|
||||
* renders at all. The Coach-side rendering (the "Chatting about" scope banner +
|
||||
* the not-indexed hint) is covered by the coach-conversation component test; the
|
||||
* fenced-send guarantee (a document turn never reaches the tool route) by the
|
||||
* coach-send-target + persistence-scope unit tests.
|
||||
* when a provider is available, and it OPENS THE DOC-SCOPED DRAWER in place (no
|
||||
* full-page nav); with NO provider the AI area collapses to the settings
|
||||
* pointer and no Coach entry renders at all. The Coach-side rendering (the
|
||||
* "Chatting about" scope banner + the not-indexed hint) is covered by the
|
||||
* coach-conversation component test; the fenced-send guarantee (a document turn
|
||||
* never reaches the tool route) by the coach-send-target + persistence-scope
|
||||
* unit tests.
|
||||
*
|
||||
* v1.27.31 lesson kept: the sheet's AI area depends on the capability probe +
|
||||
* usage endpoints, so both are mocked — otherwise the AI area never renders and
|
||||
@@ -94,9 +96,9 @@ test.describe("document vault — chat about a document", () => {
|
||||
test.slow();
|
||||
});
|
||||
|
||||
// ── (a) Provider available: the Coach entry hands off to the doc-scoped Coach ──
|
||||
// ── (a) Provider available: the Coach entry opens the doc-scoped drawer ──
|
||||
|
||||
test("offers the Coach entry and hands off to the document-scoped Coach", async ({
|
||||
test("offers the Coach entry and opens the document-scoped Coach drawer in place", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAiEnabled(page);
|
||||
@@ -106,14 +108,20 @@ test.describe("document vault — chat about a document", () => {
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// The neutral Coach affordance lives in the detail-sheet AI area; tapping it
|
||||
// navigates to the real Coach seeded with this document.
|
||||
// closes the sheet and opens the REAL coach drawer scoped to this document.
|
||||
const open = sheet.locator('[data-slot="document-chat-open"]');
|
||||
await expect(open).toBeVisible();
|
||||
await open.click();
|
||||
|
||||
// The documents-side contract: the URL hands off to the doc-scoped Coach.
|
||||
await page.waitForURL(new RegExp(`/coach\\?doc=${CONTENT_DOC_ID}`));
|
||||
expect(new URL(page.url()).searchParams.get("doc")).toBe(CONTENT_DOC_ID);
|
||||
// The documents-side contract: the coach drawer opens IN PLACE (no
|
||||
// full-page nav) scoped to this document — its doc-scope banner renders.
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible({ timeout: 10_000 });
|
||||
await expect(drawer.locator('[data-slot="coach-doc-scope"]')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
// Still on /documents — the drawer expands to the page only via maximize.
|
||||
expect(new URL(page.url()).pathname).not.toContain("/coach");
|
||||
});
|
||||
|
||||
// ── (b) No provider: the calm settings pointer, no Coach entry at all ──
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL fenced coach
|
||||
* conversation in the shared SIDE DRAWER, scoped to the document, instead of a
|
||||
* full-page nav to `/coach?doc=<id>` (the v1.28.51 behaviour Marc corrected).
|
||||
*
|
||||
* The wiring is source-structural (the send-path fence itself is covered by the
|
||||
* coach-send-target unit tests — untouched here). These guards pin:
|
||||
* 1. the detail sheet drives the drawer via `askCoach(..., doc.id)`, closing
|
||||
* the sheet first, and no longer navigates to `/coach?doc=`;
|
||||
* 2. the launch context → mount → drawer → conversation `documentId` thread
|
||||
* stays connected end-to-end.
|
||||
*/
|
||||
const ROOT = join(__dirname, "../../../..");
|
||||
const DETAIL_SHEET = join(
|
||||
ROOT,
|
||||
"src/components/documents/document-detail-sheet.tsx",
|
||||
);
|
||||
const MOUNT = join(ROOT, "src/components/insights/layout-coach-mount.tsx");
|
||||
const DRAWER = join(
|
||||
ROOT,
|
||||
"src/components/insights/coach-panel/coach-drawer.tsx",
|
||||
);
|
||||
|
||||
function load(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
describe("document detail sheet — Ask the Coach opens the drawer", () => {
|
||||
const src = load(DETAIL_SHEET);
|
||||
|
||||
it("launches the doc-scoped coach drawer via askCoach(..., doc.id)", () => {
|
||||
expect(src).toContain("useCoachLaunch");
|
||||
expect(src).toMatch(/askCoach\(null,\s*undefined,\s*false,\s*doc\.id\)/);
|
||||
});
|
||||
|
||||
it("closes the detail sheet when handing off to the drawer", () => {
|
||||
// The Ask-the-Coach onClick closes the sheet before opening the drawer so
|
||||
// the drawer owns the surface (no stacked dialogs).
|
||||
expect(src).toMatch(
|
||||
/onClick=\{\(\)\s*=>\s*\{[\s\S]*?onOpenChange\(false\);[\s\S]*?askCoach\(null,\s*undefined,\s*false,\s*doc\.id\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("no longer navigates to the full-page /coach?doc= route", () => {
|
||||
expect(src).not.toContain("/coach?doc=");
|
||||
expect(src).not.toContain('from "next/navigation"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentId threads launch context → mount → drawer", () => {
|
||||
it("the mount forwards launch.documentId to the drawer", () => {
|
||||
expect(load(MOUNT)).toContain("documentId={launch.documentId}");
|
||||
});
|
||||
|
||||
it("the drawer seeds CoachConversation with initialDocumentId + a conversation-id getter", () => {
|
||||
const src = load(DRAWER);
|
||||
expect(src).toContain("initialDocumentId={documentId}");
|
||||
expect(src).toContain(
|
||||
"registerConversationIdGetter={registerConversationIdGetter}",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -59,6 +58,7 @@ import {
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -216,7 +216,11 @@ export function DocumentDetailSheet({
|
||||
const { t, locale } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
// v1.28.52 (Documents R3) — the vault "Ask the Coach" action opens the REAL
|
||||
// fenced coach conversation in the shared side drawer (scoped to this
|
||||
// document) instead of a full-page nav; `askCoach(..., doc.id)` carries the
|
||||
// document scope through the launch context.
|
||||
const launch = useCoachLaunch();
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: queryKeys.inboundDocument(documentId ?? "none"),
|
||||
@@ -472,14 +476,19 @@ export function DocumentDetailSheet({
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{aiEnabled ? (
|
||||
// v1.28.51 (Documents R3, Design A) — "Ask the Coach" opens
|
||||
// the REAL coach view scoped to this document
|
||||
// (`/coach?doc=<id>`), replacing the bespoke doc-chat drawer.
|
||||
// Every doc turn there still runs on the hardened fenced
|
||||
// document endpoint (never the coach tool route).
|
||||
// v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL
|
||||
// coach conversation scoped to this document in the SIDE
|
||||
// DRAWER (the maximize control there expands it to the full
|
||||
// `/coach` page). Every doc turn still runs on the hardened
|
||||
// fenced document endpoint (never the coach tool route) — only
|
||||
// WHERE the conversation renders changed, not the send path.
|
||||
// Close the detail sheet so the drawer owns the surface.
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/coach?doc=${doc.id}`)}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
launch?.askCoach(null, undefined, false, doc.id);
|
||||
}}
|
||||
data-slot="document-chat-open"
|
||||
aria-label={t("documents.chat.openAria")}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { coachMaximizeHref } from "../coach-drawer";
|
||||
|
||||
/**
|
||||
* v1.28.52 (Documents R3) — the drawer's maximize control must preserve scope
|
||||
* when it hands off to the full page. `coachMaximizeHref` pins the continuity
|
||||
* rules so a doc-scoped drawer never expands into a blank `/coach`:
|
||||
*
|
||||
* - a live conversation id (a turn created the thread) wins → `/coach?c=<id>`
|
||||
* so the exact thread re-opens, scope and all;
|
||||
* - else a document scope (maximized before the first turn) → `/coach?doc=<id>`;
|
||||
* - else the plain new-chat surface.
|
||||
*/
|
||||
describe("coachMaximizeHref (Coach drawer maximize continuity)", () => {
|
||||
it("re-opens an existing thread by conversation id", () => {
|
||||
expect(coachMaximizeHref("conv-1", "doc-1")).toBe("/coach?c=conv-1");
|
||||
});
|
||||
|
||||
it("prefers the conversation id even when no document scope exists", () => {
|
||||
expect(coachMaximizeHref("conv-1", null)).toBe("/coach?c=conv-1");
|
||||
expect(coachMaximizeHref("conv-1", undefined)).toBe("/coach?c=conv-1");
|
||||
});
|
||||
|
||||
it("falls back to the document scope for a pre-first-turn doc chat", () => {
|
||||
expect(coachMaximizeHref(null, "doc-1")).toBe("/coach?doc=doc-1");
|
||||
});
|
||||
|
||||
it("lands on the plain new-chat surface with neither thread nor document", () => {
|
||||
expect(coachMaximizeHref(null, null)).toBe("/coach");
|
||||
expect(coachMaximizeHref(null, undefined)).toBe("/coach");
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,14 @@ export interface CoachConversationProps {
|
||||
* authoritatively from the loaded conversation's `documentId` instead.
|
||||
*/
|
||||
initialDocumentId?: string | null;
|
||||
/**
|
||||
* v1.28.52 (Documents R3) — hand the chrome an imperative getter for the
|
||||
* live conversation id (parallel to `registerReset`). The drawer reads it at
|
||||
* maximize time so it can hand off to `/coach?c=<id>` when a thread already
|
||||
* exists, falling back to `/coach?doc=<id>` for a pre-first-turn doc chat —
|
||||
* the surface-toggle preserves scope. The page omits it.
|
||||
*/
|
||||
registerConversationIdGetter?: (getter: () => string | null) => void;
|
||||
}
|
||||
|
||||
export function CoachConversation({
|
||||
@@ -219,6 +227,7 @@ export function CoachConversation({
|
||||
initialConversationId,
|
||||
autoOpenMostRecent = false,
|
||||
initialDocumentId,
|
||||
registerConversationIdGetter,
|
||||
}: CoachConversationProps) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
@@ -347,6 +356,20 @@ export function CoachConversation({
|
||||
});
|
||||
}, [registerReset, send, setInputValue]);
|
||||
|
||||
// v1.28.52 (Documents R3) — expose the live conversation id imperatively so
|
||||
// the drawer chrome can read it at maximize time (it lives inside this
|
||||
// surface, not the chrome). A latest-value ref keeps the registered getter
|
||||
// stable while always returning the current thread id, so maximize hands off
|
||||
// to `/coach?c=<id>` once a turn has created the thread.
|
||||
const conversationIdRef = useRef<string | null>(currentConversationId);
|
||||
useEffect(() => {
|
||||
conversationIdRef.current = currentConversationId;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!registerConversationIdGetter) return;
|
||||
registerConversationIdGetter(() => conversationIdRef.current);
|
||||
}, [registerConversationIdGetter]);
|
||||
|
||||
async function handleSubmit(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || send.isStreaming) return;
|
||||
|
||||
@@ -28,6 +28,26 @@ export {
|
||||
nextResettableValue,
|
||||
} from "./use-resettable-value";
|
||||
|
||||
/**
|
||||
* v1.28.52 (Documents R3) — the full-page target the maximize control hands
|
||||
* off to, kept pure so the continuity rules are unit-testable.
|
||||
*
|
||||
* Precedence:
|
||||
* - a live conversation id (a turn has created the thread) → `/coach?c=<id>`
|
||||
* so the exact thread re-opens on the page, scope and all.
|
||||
* - else a document scope (a doc-scoped drawer maximized before its first
|
||||
* turn) → `/coach?doc=<id>` so the fresh page chat stays scoped.
|
||||
* - else the plain `/coach` new-chat surface.
|
||||
*/
|
||||
export function coachMaximizeHref(
|
||||
conversationId: string | null,
|
||||
documentId?: string | null,
|
||||
): string {
|
||||
if (conversationId) return `/coach?c=${conversationId}`;
|
||||
if (documentId) return `/coach?doc=${documentId}`;
|
||||
return "/coach";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B2b — AI Coach drawer (right-side `<Sheet>` overlay).
|
||||
*
|
||||
@@ -59,6 +79,14 @@ export interface CoachDrawerProps {
|
||||
* exactly once on open. Used by the assessment hand-off.
|
||||
*/
|
||||
autoSend?: boolean;
|
||||
/**
|
||||
* v1.28.52 (Documents R3) — the stored document this drawer chat is scoped
|
||||
* to (the vault "Ask the Coach" action). Forwarded to `<CoachConversation>`
|
||||
* as `initialDocumentId` so the first turn routes through the hardened
|
||||
* fenced endpoint; the maximize control preserves the scope when no thread
|
||||
* exists yet (`/coach?doc=<id>`).
|
||||
*/
|
||||
documentId?: string | null;
|
||||
}
|
||||
|
||||
export function CoachDrawer({
|
||||
@@ -67,6 +95,7 @@ export function CoachDrawer({
|
||||
prefill,
|
||||
scope,
|
||||
autoSend,
|
||||
documentId,
|
||||
}: CoachDrawerProps) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
@@ -85,6 +114,18 @@ export function CoachDrawer({
|
||||
resetRef.current = reset;
|
||||
}, []);
|
||||
|
||||
// v1.28.52 (Documents R3) — the live conversation id lives inside the shared
|
||||
// surface; this imperative getter (registered parallel to `registerReset`)
|
||||
// lets the maximize control read it so it can preserve the thread / doc scope
|
||||
// when handing off to the full page.
|
||||
const conversationIdGetterRef = useRef<(() => string | null) | null>(null);
|
||||
const registerConversationIdGetter = useCallback(
|
||||
(getter: () => string | null) => {
|
||||
conversationIdGetterRef.current = getter;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) {
|
||||
@@ -98,13 +139,20 @@ export function CoachDrawer({
|
||||
);
|
||||
|
||||
const handleMaximize = useCallback(() => {
|
||||
// Hand the conversation off to the full-page surface. Close the
|
||||
// drawer first (aborts any in-flight stream via `handleOpenChange`)
|
||||
// Hand the conversation off to the full-page surface, PRESERVING scope:
|
||||
// read the live thread id (or fall back to the document scope) BEFORE the
|
||||
// reset clears it, so a doc-scoped drawer maximizes into the same doc /
|
||||
// thread instead of a blank `/coach`.
|
||||
const href = coachMaximizeHref(
|
||||
conversationIdGetterRef.current?.() ?? null,
|
||||
documentId,
|
||||
);
|
||||
// Close the drawer (aborts any in-flight stream via `handleOpenChange`)
|
||||
// then route to the dedicated page.
|
||||
onOpenChange(false);
|
||||
resetRef.current?.();
|
||||
router.push("/coach");
|
||||
}, [onOpenChange, router]);
|
||||
router.push(href);
|
||||
}, [onOpenChange, router, documentId]);
|
||||
|
||||
// v1.21.4 (Coach-UI B) — the drawer's "Conversations" affordance now hands
|
||||
// off to the dedicated conversation-history page (`/coach/conversations`),
|
||||
@@ -146,8 +194,14 @@ export function CoachDrawer({
|
||||
prefill={prefill}
|
||||
launchScope={scope}
|
||||
autoSend={autoSend}
|
||||
// v1.28.52 (Documents R3) — scope a fresh drawer chat to a stored
|
||||
// document (vault "Ask the Coach"); the send path is unchanged (the
|
||||
// fenced document endpoint), only WHERE it renders (drawer vs page).
|
||||
initialDocumentId={documentId}
|
||||
autoFocusComposer
|
||||
registerReset={registerReset}
|
||||
// Lets the maximize control read the live thread id at hand-off time.
|
||||
registerConversationIdGetter={registerConversationIdGetter}
|
||||
// v1.16.1 — the "Conversations" affordance hands off to a
|
||||
// dedicated route instead of opening the broken in-panel left
|
||||
// tray. v1.21.4 (Coach-UI B) — it now navigates to
|
||||
|
||||
@@ -59,6 +59,10 @@ export function LayoutCoachMount() {
|
||||
// Auto-send the prefill as the first turn when the launch requested it
|
||||
// (assessment hand-off), so the answer lands without a manual send.
|
||||
autoSend={launch.autoSend}
|
||||
// v1.28.52 (Documents R3) — carry the stored-document scope so the vault
|
||||
// "Ask the Coach" action opens the REAL fenced conversation in the drawer
|
||||
// scoped to that document (maximizing then preserves the scope).
|
||||
documentId={launch.documentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CoachLaunchProvider, useCoachLaunch } from "../coach-launch-context";
|
||||
import {
|
||||
CoachLaunchProvider,
|
||||
resolveLaunchState,
|
||||
useCoachLaunch,
|
||||
} from "../coach-launch-context";
|
||||
|
||||
/**
|
||||
* v1.4.27 R3d MB4 — Coach launch context smoke contract.
|
||||
@@ -29,6 +33,7 @@ function Probe({ output }: { output: string[] }) {
|
||||
hasSetOpen: typeof launch.setOpen,
|
||||
open: launch.open,
|
||||
prefill: launch.prefill,
|
||||
documentId: launch.documentId,
|
||||
}),
|
||||
);
|
||||
return <span data-slot="probe-output">{output.join(",")}</span>;
|
||||
@@ -54,6 +59,8 @@ describe("CoachLaunchProvider", () => {
|
||||
expect(parsed.hasSetOpen).toBe("function");
|
||||
expect(parsed.open).toBe(false);
|
||||
expect(parsed.prefill).toBeNull();
|
||||
// v1.28.52 — the document scope defaults to null (health chat).
|
||||
expect(parsed.documentId).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the children inside the provider mount", () => {
|
||||
@@ -66,3 +73,72 @@ describe("CoachLaunchProvider", () => {
|
||||
expect(html).toContain("child");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.28.52 (Documents R3) — the pure open-state derivation the provider's
|
||||
* `askCoach(...)` applies. Testing it directly pins the precedence rules
|
||||
* (document scope, ambient inheritance, auto-send gating) without simulating
|
||||
* React state.
|
||||
*/
|
||||
describe("resolveLaunchState", () => {
|
||||
const NO_AMBIENT = { ambientScope: null, ambientPrefill: null } as const;
|
||||
|
||||
it("threads an explicit documentId through so the drawer opens doc-scoped", () => {
|
||||
const result = resolveLaunchState({
|
||||
nextPrefill: null,
|
||||
nextAutoSend: false,
|
||||
nextDocumentId: "doc-123",
|
||||
...NO_AMBIENT,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
prefill: null,
|
||||
scope: null,
|
||||
documentId: "doc-123",
|
||||
autoSend: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults documentId to null for a plain (health) launch", () => {
|
||||
expect(resolveLaunchState({ ...NO_AMBIENT }).documentId).toBeNull();
|
||||
});
|
||||
|
||||
it("never inherits a document scope from ambient page state", () => {
|
||||
// A document scope is only ever explicit — ambient scope must not leak one.
|
||||
const result = resolveLaunchState({
|
||||
ambientScope: { metric: "weight" },
|
||||
ambientPrefill: "How is my weight?",
|
||||
});
|
||||
expect(result.documentId).toBeNull();
|
||||
// Ambient scope + opener still inherited for the health path.
|
||||
expect(result.scope).toEqual({ metric: "weight" });
|
||||
expect(result.prefill).toBe("How is my weight?");
|
||||
});
|
||||
|
||||
it("gates auto-send on an explicit prefill (a doc/blank open never auto-sends)", () => {
|
||||
expect(
|
||||
resolveLaunchState({
|
||||
nextAutoSend: true,
|
||||
nextDocumentId: "doc-1",
|
||||
...NO_AMBIENT,
|
||||
}).autoSend,
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveLaunchState({
|
||||
nextPrefill: "Explain this",
|
||||
nextAutoSend: true,
|
||||
...NO_AMBIENT,
|
||||
}).autoSend,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("lets an explicit scope win over ambient scope", () => {
|
||||
const result = resolveLaunchState({
|
||||
nextScope: { metric: "pulse" },
|
||||
ambientScope: { metric: "weight" },
|
||||
ambientPrefill: "ambient opener",
|
||||
});
|
||||
expect(result.scope).toEqual({ metric: "pulse" });
|
||||
// Explicit scope means the ambient opener is NOT inherited.
|
||||
expect(result.prefill).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -80,15 +80,27 @@ interface CoachLaunchValue {
|
||||
* explicit one. `<LayoutCoachMount>` forwards it to the drawer.
|
||||
*/
|
||||
scope: CoachLaunchScope | null;
|
||||
/**
|
||||
* v1.28.52 (Documents R3) — the stored document a fresh drawer chat is
|
||||
* scoped to, or null for a health chat. Set by `askCoach(..., documentId)`
|
||||
* from the vault detail sheet's "Ask the Coach" action so the conversation
|
||||
* opens in the SIDE DRAWER (not a full-page nav) pre-scoped to the document.
|
||||
* `<LayoutCoachMount>` forwards it to the drawer as `initialDocumentId`, and
|
||||
* every doc turn still routes through the hardened fenced endpoint inside
|
||||
* `<CoachConversation>`. Cleared on close alongside `prefill` / `scope`.
|
||||
*/
|
||||
documentId: string | null;
|
||||
/**
|
||||
* Open the drawer with an optional prefill + scope hint. When `autoSend`
|
||||
* is true the prefill is dispatched as the conversation's first turn
|
||||
* automatically (used by the assessment hand-off).
|
||||
* automatically (used by the assessment hand-off). `documentId` scopes the
|
||||
* opened conversation to a stored document (vault "Ask the Coach").
|
||||
*/
|
||||
askCoach: (
|
||||
prefill?: string | null,
|
||||
scope?: CoachLaunchScope,
|
||||
autoSend?: boolean,
|
||||
documentId?: string | null,
|
||||
) => void;
|
||||
/**
|
||||
* v1.21.0 (C4 H1) — register the metric surface the user is currently
|
||||
@@ -115,6 +127,46 @@ interface CoachLaunchValue {
|
||||
setOpen: (next: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure derivation of the open-state a single `askCoach(...)` call produces,
|
||||
* factored out of the provider so the precedence rules (explicit arg vs.
|
||||
* ambient scope, auto-send gating, document scoping) are unit-testable without
|
||||
* simulating React state. The provider applies the result field-by-field.
|
||||
*/
|
||||
export interface ResolvedLaunchState {
|
||||
prefill: string | null;
|
||||
scope: CoachLaunchScope | null;
|
||||
documentId: string | null;
|
||||
autoSend: boolean;
|
||||
}
|
||||
|
||||
export function resolveLaunchState(input: {
|
||||
nextPrefill?: string | null;
|
||||
nextScope?: CoachLaunchScope;
|
||||
nextAutoSend?: boolean;
|
||||
nextDocumentId?: string | null;
|
||||
ambientScope: CoachLaunchScope | null;
|
||||
ambientPrefill: string | null;
|
||||
}): ResolvedLaunchState {
|
||||
// An explicit scope (insight card, metric-card affordance) wins; otherwise
|
||||
// inherit the metric page's ambient scope. The composer seed follows the
|
||||
// same precedence: explicit prefill, else the ambient page opener.
|
||||
const usingAmbientScope = input.nextScope === undefined;
|
||||
return {
|
||||
scope: input.nextScope ?? input.ambientScope ?? null,
|
||||
prefill:
|
||||
input.nextPrefill ??
|
||||
(usingAmbientScope ? input.ambientPrefill : null) ??
|
||||
null,
|
||||
// Auto-send only applies when an explicit prefill is given (a card
|
||||
// hand-off), never for an ambient/blank open.
|
||||
autoSend: Boolean(input.nextAutoSend) && Boolean(input.nextPrefill),
|
||||
// A document scope is always explicit (the vault "Ask the Coach" action);
|
||||
// it is never inherited from ambient page state.
|
||||
documentId: input.nextDocumentId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const CoachLaunchContext = createContext<CoachLaunchValue | null>(null);
|
||||
|
||||
export interface CoachLaunchProviderProps {
|
||||
@@ -128,6 +180,8 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
const [autoSend, setAutoSend] = useState<boolean>(false);
|
||||
// Scope the open conversation is narrowed to. `null` → default snapshot.
|
||||
const [scope, setScope] = useState<CoachLaunchScope | null>(null);
|
||||
// Stored-document scope of the open conversation. `null` → health chat.
|
||||
const [documentId, setDocumentId] = useState<string | null>(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
|
||||
@@ -141,23 +195,24 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
nextPrefill?: string | null,
|
||||
nextScope?: CoachLaunchScope,
|
||||
nextAutoSend?: boolean,
|
||||
nextDocumentId?: string | null,
|
||||
) => {
|
||||
// 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,
|
||||
);
|
||||
// Auto-send only applies when an explicit prefill is given (a card
|
||||
// hand-off), never for an ambient/blank open.
|
||||
setAutoSend(Boolean(nextAutoSend) && Boolean(nextPrefill));
|
||||
// v1.21.0 (C4 H1/H4) — scope is live; v1.28.52 — document scope threads
|
||||
// through so the vault "Ask the Coach" action opens the drawer scoped to
|
||||
// the document instead of a full-page nav. Precedence lives in the pure
|
||||
// `resolveLaunchState` helper so the rules stay unit-testable.
|
||||
const resolved = resolveLaunchState({
|
||||
nextPrefill,
|
||||
nextScope,
|
||||
nextAutoSend,
|
||||
nextDocumentId,
|
||||
ambientScope: ambientScopeRef.current,
|
||||
ambientPrefill: ambientPrefillRef.current,
|
||||
});
|
||||
setScope(resolved.scope);
|
||||
setPrefill(resolved.prefill);
|
||||
setAutoSend(resolved.autoSend);
|
||||
setDocumentId(resolved.documentId);
|
||||
setOpen(true);
|
||||
},
|
||||
[],
|
||||
@@ -183,11 +238,12 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
const handleSetOpen = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
// Drop the prefill + scope + auto-send on close so the next open
|
||||
// starts clean.
|
||||
// Drop the prefill + scope + document + auto-send on close so the next
|
||||
// open starts clean.
|
||||
setPrefill(null);
|
||||
setScope(null);
|
||||
setAutoSend(false);
|
||||
setDocumentId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -197,11 +253,21 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
prefill,
|
||||
autoSend,
|
||||
scope,
|
||||
documentId,
|
||||
askCoach,
|
||||
registerScope,
|
||||
setOpen: handleSetOpen,
|
||||
}),
|
||||
[open, prefill, autoSend, scope, askCoach, registerScope, handleSetOpen],
|
||||
[
|
||||
open,
|
||||
prefill,
|
||||
autoSend,
|
||||
scope,
|
||||
documentId,
|
||||
askCoach,
|
||||
registerScope,
|
||||
handleSetOpen,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user