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:
Marc-André Bombeck committed 2026-07-16 22:34:17 +02:00
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();
});
});
+86 -20
View File
@@ -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 (