feat(coach): deep-link the Coach page into a conversation

The Coach page opened a blank new chat regardless of where the user came
from. Make it a deep-link target: a `?c=<id>` 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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 00:25:54 +02:00
1 parent 6975849bec
commit ead44e79d9
3 files changed
+224 -5

No files matched your search

+37 -3
View File
@@ -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 `<CoachConversation>`'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=<id>` 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 `<CoachConversation>`'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 (
<CoachConversation
surface="page"
autoFocusComposer
initialConversationId={deepLinkedId}
// Only auto-resolve most-recent when the entry did not pin a thread
// and did not ask for a fresh chat.
autoOpenMostRecent={deepLinkedId === null && rawC !== "new"}
/>
);
}
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)]"
>
<CoachConversation surface="page" autoFocusComposer />
{/* `useSearchParams` requires a Suspense boundary so the client-search
bailout never opts the whole route out of static optimisation. */}
<Suspense fallback={null}>
<CoachPageBody />
</Suspense>
</div>
);
}
@@ -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
* `<CoachConversation>` 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(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
describe("<CoachConversation> page deep-link (#67)", () => {
it("opens a specific thread when initialConversationId is set", () => {
const html = render(
<CoachConversation surface="page" initialConversationId="older" />,
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(
<CoachConversation surface="page" autoOpenMostRecent />,
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(
<CoachConversation surface="page" autoOpenMostRecent />,
client,
);
expect(html).toContain('data-slot="coach-hero"');
});
it("does not auto-open when autoOpenMostRecent is off (new chat)", () => {
const html = render(<CoachConversation surface="page" />, 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"');
});
});
@@ -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