mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -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