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

@@ -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