mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
coach: reopen an interrupted conversation with a visible retry
An aborted stream now leaves a trace on both ends. The unmount cleanup records the interrupted conversation (sessionStorage, one-shot) before the abort fires, and invalidates the cached thread + rail so the return visit refetches the advanced server state. The /coach page consumes the record on its next mount: a known id reopens that thread, a first-turn abort resolves via most-recent; every explicit entry intent (?c=, ?doc=, ?workout=, ?scope=, ?ask=) wins over the resume. The persisted cancelled marker renders as a muted interrupted note with an Ask-again action that resubmits the question through the normal send path and its gates. Localised in all six languages. Refs #781
This commit is contained in:
1 parent
9a631c8f8b
commit
13b7d0379b
13 files changed
+713
-230
No files matched your search
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* #781 — returning to `/coach` after a navigation aborted a streaming reply
|
||||
* must land the user back IN the interrupted conversation, not on a blank
|
||||
* new chat. The unmount cleanup in `use-coach.ts` records the interrupted
|
||||
* conversation under `COACH_INTERRUPTED_STORAGE_KEY`; this pins the page's
|
||||
* consumption side:
|
||||
*
|
||||
* - a stored conversation id seeds `initialConversationId` (same prop the
|
||||
* `?c=` deep-link drives);
|
||||
* - the `"latest"` sentinel (a first-turn abort whose id never reached the
|
||||
* client) flips `autoOpenMostRecent` instead;
|
||||
* - every EXPLICIT entry intent (`?c=`, `?c=new`, `?doc=`, `?ask=`) wins
|
||||
* over the resume.
|
||||
*
|
||||
* Same probe pattern as `page-launch-params.test.tsx`: the conversation
|
||||
* surface is stubbed and the resolved props are asserted.
|
||||
*/
|
||||
|
||||
const conversationProps = vi.fn();
|
||||
|
||||
vi.mock("@/components/insights/coach-panel/coach-conversation", () => ({
|
||||
CoachConversation: (props: Record<string, unknown>) => {
|
||||
conversationProps(props);
|
||||
return <div data-slot="coach-conversation-probe" />;
|
||||
},
|
||||
}));
|
||||
|
||||
const searchParams = { current: new URLSearchParams() };
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => searchParams.current,
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/coach-launch-context", () => ({
|
||||
useCoachLaunch: () => ({ askCoach: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-feature-flags", () => ({
|
||||
useFeatureFlags: () => ({ coach: true }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-disable-coach", () => ({
|
||||
useDisableCoach: () => false,
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: {}, isLoading: false }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: false }),
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
vi.mock("@/lib/api/api-fetch", () => ({ apiGet: vi.fn() }));
|
||||
|
||||
import CoachPageClient from "../page-client";
|
||||
import { COACH_INTERRUPTED_STORAGE_KEY } from "@/components/insights/coach-panel/use-coach";
|
||||
|
||||
// The vitest environment is `node` (SSR-only tests) — there is no `window`.
|
||||
// The page reads the flag through `window.sessionStorage` behind a
|
||||
// `typeof window` guard, so stub the minimal surface it touches.
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal("window", {
|
||||
sessionStorage: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
});
|
||||
|
||||
function renderWith(query: string): Record<string, unknown> {
|
||||
searchParams.current = new URLSearchParams(query);
|
||||
renderToStaticMarkup(<CoachPageClient />);
|
||||
const calls = conversationProps.mock.calls;
|
||||
return calls[calls.length - 1][0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store.clear();
|
||||
});
|
||||
|
||||
describe("/coach — interrupted-conversation resume (#781)", () => {
|
||||
it("reopens the interrupted conversation recorded by the aborted stream", () => {
|
||||
store.set(COACH_INTERRUPTED_STORAGE_KEY, "conv-42");
|
||||
const props = renderWith("");
|
||||
expect(props.initialConversationId).toBe("conv-42");
|
||||
});
|
||||
|
||||
it("resolves a first-turn abort (sentinel 'latest') via most-recent", () => {
|
||||
store.set(COACH_INTERRUPTED_STORAGE_KEY, "latest");
|
||||
const props = renderWith("");
|
||||
expect(props.initialConversationId).toBe(null);
|
||||
expect(props.autoOpenMostRecent).toBe(true);
|
||||
});
|
||||
|
||||
it("without a recorded interruption the default new-chat hero stands", () => {
|
||||
const props = renderWith("");
|
||||
expect(props.initialConversationId).toBe(null);
|
||||
expect(props.autoOpenMostRecent).toBe(false);
|
||||
});
|
||||
|
||||
it("an explicit ?c= deep-link wins over the resume", () => {
|
||||
store.set(COACH_INTERRUPTED_STORAGE_KEY, "conv-42");
|
||||
const props = renderWith("c=conv-7");
|
||||
expect(props.initialConversationId).toBe("conv-7");
|
||||
});
|
||||
|
||||
it("an explicit ?c=new (fresh chat) wins over the resume", () => {
|
||||
store.set(COACH_INTERRUPTED_STORAGE_KEY, "conv-42");
|
||||
const props = renderWith("c=new");
|
||||
expect(props.initialConversationId).toBe(null);
|
||||
expect(props.autoOpenMostRecent).toBe(false);
|
||||
});
|
||||
|
||||
it("an explicit ?ask= hand-off wins over the resume", () => {
|
||||
store.set(COACH_INTERRUPTED_STORAGE_KEY, "conv-42");
|
||||
const props = renderWith("ask=How%20did%20I%20sleep");
|
||||
expect(props.initialConversationId).toBe(null);
|
||||
});
|
||||
|
||||
it("a ?doc= scoped chat wins over the resume", () => {
|
||||
store.set(COACH_INTERRUPTED_STORAGE_KEY, "conv-42");
|
||||
const props = renderWith("doc=doc-1");
|
||||
expect(props.initialConversationId).toBe(null);
|
||||
expect(props.initialDocumentId).toBe("doc-1");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
|
||||
import { COACH_INTERRUPTED_STORAGE_KEY } from "@/components/insights/coach-panel/use-coach";
|
||||
import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
@@ -118,6 +119,47 @@ function CoachPageBody() {
|
||||
: null;
|
||||
const seedPrefill = freshChat ? searchParams.get("ask") : null;
|
||||
|
||||
// #781 — a navigation away mid-reply recorded the interrupted conversation
|
||||
// (see `COACH_INTERRUPTED_STORAGE_KEY` in `use-coach.ts`). Returning to the
|
||||
// Coach reopens that thread — where the persisted `cancelled` marker shows
|
||||
// the turn as interrupted and offers the retry — instead of landing on a
|
||||
// blank new chat. Read once per mount (lazy state, SSR-guarded), cleared in
|
||||
// an effect so it is consumed exactly once. Every EXPLICIT entry intent
|
||||
// wins over the resume: a `?c=` thread or `?c=new`, a `?doc=`/`?workout=`
|
||||
// scoped chat, and a `?scope=`/`?ask=` hand-off all carry their own
|
||||
// destination.
|
||||
const [interruptedResume] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.sessionStorage.getItem(COACH_INTERRUPTED_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (interruptedResume === null) return;
|
||||
try {
|
||||
window.sessionStorage.removeItem(COACH_INTERRUPTED_STORAGE_KEY);
|
||||
} catch {
|
||||
// Best-effort clear; a stale flag only re-opens a conversation once.
|
||||
}
|
||||
}, [interruptedResume]);
|
||||
const interruptedEligible =
|
||||
rawC === null &&
|
||||
rawDoc === null &&
|
||||
rawWorkout === null &&
|
||||
launchScope === null &&
|
||||
!seedPrefill;
|
||||
const interruptedId =
|
||||
interruptedEligible && interruptedResume && interruptedResume !== "latest"
|
||||
? interruptedResume
|
||||
: null;
|
||||
// A first-turn abort never learned its conversation id (it rides the SSE
|
||||
// `done` frame) — resolve via the server-authoritative most-recent thread,
|
||||
// which the aborted turn's server-side persistence has just bumped to the top.
|
||||
const resumeInterruptedLatest =
|
||||
interruptedEligible && interruptedResume === "latest";
|
||||
|
||||
// C1 exception — an unread coach-initiated message opens the most-recent
|
||||
// conversation (which holds that proactive turn). Only consulted when the
|
||||
// entry did not pin a specific thread or ask for a fresh chat.
|
||||
@@ -144,7 +186,9 @@ function CoachPageBody() {
|
||||
<CoachConversation
|
||||
surface="page"
|
||||
autoFocusComposer
|
||||
initialConversationId={deepLinkedId}
|
||||
// #781 — the interrupted-conversation resume seeds the same prop the
|
||||
// `?c=` deep-link drives; the explicit deep-link always wins.
|
||||
initialConversationId={deepLinkedId ?? interruptedId}
|
||||
// v1.28.51 — seed the document scope for a `?doc=<id>` open so the first
|
||||
// turn is created + sent through the hardened fenced document endpoint.
|
||||
initialDocumentId={seedDocumentId}
|
||||
@@ -153,9 +197,10 @@ function CoachPageBody() {
|
||||
// cross-surface hand-off carried in the URL.
|
||||
launchScope={launchScope}
|
||||
prefill={seedPrefill}
|
||||
// Default is the new-chat hero; only resume most-recent for the
|
||||
// unread coach-initiated exception.
|
||||
autoOpenMostRecent={hasUnreadCoachMessage}
|
||||
// Default is the new-chat hero; resume most-recent only for the
|
||||
// unread coach-initiated exception and the interrupted first-turn
|
||||
// resume (#781, whose conversation id never reached the client).
|
||||
autoOpenMostRecent={hasUnreadCoachMessage || resumeInterruptedLatest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user