From d72f078764b4948111b3b1964eec63ce7fa58df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 22:34:17 +0200 Subject: [PATCH] feat(documents): open the doc-scoped Coach in the side drawer, not a full page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Ask the Coach" on a document used to navigate to /coach?doc= — 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= once a thread exists, falling back to /coach?doc= 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. --- e2e/documents-chat.spec.ts | 44 +++++--- .../document-coach-launch-wiring.test.ts | 66 +++++++++++ .../documents/document-detail-sheet.tsx | 25 +++-- .../__tests__/coach-drawer-maximize.test.ts | 33 ++++++ .../coach-panel/coach-conversation.tsx | 23 ++++ .../insights/coach-panel/coach-drawer.tsx | 62 +++++++++- .../insights/layout-coach-mount.tsx | 4 + .../__tests__/coach-launch-context.test.tsx | 78 ++++++++++++- src/lib/insights/coach-launch-context.tsx | 106 ++++++++++++++---- 9 files changed, 390 insertions(+), 51 deletions(-) create mode 100644 src/components/documents/__tests__/document-coach-launch-wiring.test.ts create mode 100644 src/components/insights/coach-panel/__tests__/coach-drawer-maximize.test.ts diff --git a/e2e/documents-chat.spec.ts b/e2e/documents-chat.spec.ts index 4d2cd7c14..e57fc0b78 100644 --- a/e2e/documents-chat.spec.ts +++ b/e2e/documents-chat.spec.ts @@ -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=`, - * 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 ── diff --git a/src/components/documents/__tests__/document-coach-launch-wiring.test.ts b/src/components/documents/__tests__/document-coach-launch-wiring.test.ts new file mode 100644 index 000000000..f0b59d9ce --- /dev/null +++ b/src/components/documents/__tests__/document-coach-launch-wiring.test.ts @@ -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=` (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}", + ); + }); +}); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 7320df94e..4b0515e58 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -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({
{aiEnabled ? ( - // v1.28.51 (Documents R3, Design A) — "Ask the Coach" opens - // the REAL coach view scoped to this document - // (`/coach?doc=`), 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.