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:
Marc-André Bombeck committed 2026-08-13 06:16:45 +02:00
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");
});
});
+50 -5
View File
@@ -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}
/>
);
}