From ead44e79d9fdb382c0f2c17dce05444a35360bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 00:25:54 +0200 Subject: [PATCH] feat(coach): deep-link the Coach page into a conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Coach page opened a blank new chat regardless of where the user came from. Make it a deep-link target: a `?c=` query param opens that thread on mount, and absent it the page auto-opens the user's most-recent conversation. "Most recent" is the `updatedAt desc` head of the rail list, so the same thread opens on web and mobile. The shared conversation surface grows two additive props (`initialConversationId`, `autoOpenMostRecent`) that feed its existing selection state; the history drawer's selection stays the single source of truth. The drawer surface is unchanged — both props default off and the rail list is only fetched when auto-open is requested. --- src/app/coach/page.tsx | 40 ++++- .../coach-conversation-deeplink.test.tsx | 138 ++++++++++++++++++ .../coach-panel/coach-conversation.tsx | 51 ++++++- 3 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 6ff9d5c03..0274097d2 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { Suspense, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; @@ -40,7 +40,37 @@ import { useDisableCoach } from "@/hooks/use-disable-coach"; * constant, centred, max-width-capped column across the new-chat hero and * the active conversation. All of that lives in ``'s * page branch, so the page stays a thin gating + sizing wrapper. + * + * v1.18.11 (W11, #67) — the page is now a deep-link target INTO a + * conversation. A `?c=` query param opens that specific thread on + * mount; absent it, the page auto-opens the user's most-recent thread + * (server-authoritative `updatedAt desc` head of the rail, so it lands the + * same thread on web + mobile). Both feed ``'s existing + * selection state — the dashboard Coach entry navigates straight into the + * live conversation instead of a blank landing. `?c=new` (or no thread at + * all) keeps the new-chat hero. The search-param read sits in a Suspense + * child so the client-bailout never de-opts the route. */ +function CoachPageBody() { + const searchParams = useSearchParams(); + // `?c=new` is an explicit "start a fresh chat" escape hatch; any other + // value is treated as a conversation id to open. A blank/absent param + // falls through to most-recent auto-open. + const rawC = searchParams.get("c"); + const deepLinkedId = rawC && rawC !== "new" ? rawC : null; + + return ( + + ); +} + export default function CoachPage() { const router = useRouter(); const launch = useCoachLaunch(); @@ -88,7 +118,11 @@ export default function CoachPage() { // is hidden, so the page reclaims the full height below the TopBar. className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" > - + {/* `useSearchParams` requires a Suspense boundary so the client-search + bailout never opts the whole route out of static optimisation. */} + + + ); } diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx new file mode 100644 index 000000000..fd9894a48 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { CoachConversation } from "../coach-conversation"; +import type { + CoachConversationsPage, + CoachConversationDetailDTO, +} from "@/lib/ai/coach/types"; + +/** + * v1.18.11 (W11, #67) — the Coach page is a deep-link target INTO a + * conversation. These tests pin the selection contract on the shared + * `` page surface: + * + * - `initialConversationId` opens that exact thread on mount (the `?c=` + * deep-link the dashboard Coach entry carries). + * - `autoOpenMostRecent` opens the server-authoritative most-recent thread + * (the `updatedAt desc` head of the rail list) when no id is pinned. + * - With neither, the surface keeps the new-chat hero. + * + * Selection is observed through the rendered output: an open thread paints + * the docked page composer (`coach-page-composer`) and NOT the new-chat hero + * (`coach-hero`). The cache is seeded so SSR renders the resolved state in a + * single pass — no real network call. + */ + +const RAIL: CoachConversationsPage = { + conversations: [ + { + id: "recent", + title: "Most recent thread", + createdAt: "2026-06-20T08:00:00.000Z", + updatedAt: "2026-06-20T09:00:00.000Z", + messageCount: 3, + }, + { + id: "older", + title: "Older thread", + createdAt: "2026-06-18T08:00:00.000Z", + updatedAt: "2026-06-18T08:05:00.000Z", + messageCount: 2, + }, + ], + nextCursor: null, +}; + +function detail(id: string, title: string): CoachConversationDetailDTO { + return { + id, + title, + createdAt: "2026-06-20T08:00:00.000Z", + updatedAt: "2026-06-20T09:00:00.000Z", + messages: [ + { + id: `${id}-m1`, + role: "user", + content: `question in ${title}`, + createdAt: "2026-06-20T08:00:00.000Z", + metricSource: null, + tokensUsed: null, + }, + ], + } as unknown as CoachConversationDetailDTO; +} + +function makeClient(): QueryClient { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(["coachConversations"], RAIL); + client.setQueryData( + ["coachConversation", "recent"], + detail("recent", "Most recent thread"), + ); + client.setQueryData( + ["coachConversation", "older"], + detail("older", "Older thread"), + ); + // The composer's pending-questions query — keep it empty so the hero gate + // is decided purely by the active conversation id. + client.setQueryData(["coach-about-me", "questions"], { questions: [] }); + return client; +} + +function render(node: React.ReactNode, client: QueryClient): string { + return renderToStaticMarkup( + + {node} + , + ); +} + +describe(" page deep-link (#67)", () => { + it("opens a specific thread when initialConversationId is set", () => { + const html = render( + , + makeClient(), + ); + // The pinned thread is open: docked composer, no new-chat hero. + expect(html).toContain('data-slot="coach-page-composer"'); + expect(html).not.toContain('data-slot="coach-hero"'); + }); + + it("auto-opens the most-recent thread when none is pinned", () => { + const html = render( + , + makeClient(), + ); + // Resolves to the rail's updatedAt-desc head — thread open, hero gone. + expect(html).toContain('data-slot="coach-page-composer"'); + expect(html).not.toContain('data-slot="coach-hero"'); + }); + + it("keeps the new-chat hero when there is nothing to open", () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(["coachConversations"], { + conversations: [], + nextCursor: null, + }); + client.setQueryData(["coach-about-me", "questions"], { questions: [] }); + const html = render( + , + client, + ); + expect(html).toContain('data-slot="coach-hero"'); + }); + + it("does not auto-open when autoOpenMostRecent is off (new chat)", () => { + const html = render(, makeClient()); + // No pin, no auto-open → the surface stays on the new-chat hero even + // though recent conversations exist in the cache. + expect(html).toContain('data-slot="coach-hero"'); + }); +}); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 425da3635..b166d6334 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -37,7 +37,11 @@ import { MobileRailTray } from "./mobile-rail-tray"; import { SelfContextAdoptOffer } from "./self-context-adopt-offer"; import { SourcesRail } from "./sources-rail"; import { useResettableValue } from "./use-resettable-value"; -import { useCoachConversation, useSendCoachMessage } from "./use-coach"; +import { + useCoachConversation, + useCoachConversations, + useSendCoachMessage, +} from "./use-coach"; /** * v1.12.0 (Coach v2 #6) — shared chat surface for both the Coach drawer @@ -112,6 +116,27 @@ export interface CoachConversationProps { className?: string; /** data-variant on the root, surfaced for e2e + styling hooks. */ surface: "drawer" | "page"; + /** + * v1.18.11 (W11, #67) — open a specific conversation on mount. The page + * surface reads it from the `?c=` deep-link the dashboard Coach entry + * carries, so a tap lands the user directly in that thread instead of a + * blank new chat. Seeds the same `currentConversationId` the history + * drawer drives, so the drawer's selection stays the single source of + * truth; the URL param is just one more way to set it. Null/undefined + * leaves the selection alone (new-chat, or auto-most-recent below). + */ + initialConversationId?: string | null; + /** + * v1.18.11 (W11, #67) — when no `initialConversationId` is given, open + * the user's MOST-RECENT conversation once the shared rail list resolves. + * This is what makes the dashboard Coach entry land the user IN their + * conversation cross-device: "most recent" is the server-authoritative + * `updatedAt desc` head of `GET /api/insights/chat`, identical on web and + * mobile — no client-only state. An account with no conversations falls + * through to the new-chat hero. Resolves exactly once per mount so a later + * "new chat" or thread switch is never overridden. + */ + autoOpenMostRecent?: boolean; } export function CoachConversation({ @@ -125,12 +150,14 @@ export function CoachConversation({ onRequestFullView, className, surface, + initialConversationId, + autoOpenMostRecent = false, }: CoachConversationProps) { const { t } = useTranslations(); const [currentConversationId, setCurrentConversationId] = useState< string | null - >(null); + >(initialConversationId ?? null); const [historyTrayOpen, setHistoryTrayOpen] = useState(false); const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false); // v1.18.11 (W11) — page surface only: conversation history is a LEFT @@ -187,6 +214,26 @@ export function CoachConversation({ }, }); + // v1.18.11 (W11, #67) — auto-open the most-recent conversation when the + // surface mounts with no explicit selection. Only fetches the rail list + // when the behaviour is requested AND no deep-linked id was supplied, so + // the drawer's default new-chat mount pays nothing for it. + const autoOpenEnabled = autoOpenMostRecent && !initialConversationId; + const { conversations: railConversations } = + useCoachConversations(autoOpenEnabled); + // Resolve once per mount via the in-render setState pattern (the + // `react-hooks/set-state-in-effect` rule rejects setState inside an effect + // when the source is a query result; see `coach-prefs-section.tsx`). The + // latch flips the first time the rail list arrives so a subsequent "new + // chat" or thread switch is never clobbered by a late list refetch. + const [autoOpenResolved, setAutoOpenResolved] = useState(false); + if (autoOpenEnabled && !autoOpenResolved && railConversations.length > 0) { + setAutoOpenResolved(true); + // Server-authoritative `updatedAt desc` head = the same thread the rail + // shows first, identical on every device. + setCurrentConversationId(railConversations[0].id); + } + // Hand the chrome an imperative reset so the drawer can abort the // in-flight SSE stream + clear the thread on close. Re-registered when // the closed-over state setters / send hook change identity so the