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

@@ -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}
/>
);
}