mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
6975849bec
commit
ead44e79d9
3 files changed
+224
-5
No files matched your search
+37
-3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user