mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +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
@@ -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