From 13b7d0379bf4cdc8d884ac29cd099bc5c1a3fa07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 13 Aug 2026 02:48:43 +0200 Subject: [PATCH] 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 --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + .../coach-stream-lifecycle-guard.test.ts | 29 + .../page-interrupted-resume.test.tsx | 129 +++++ src/app/coach/page-client.tsx | 55 +- .../__tests__/chat-bubble-cancelled.test.tsx | 96 ++++ .../__tests__/use-coach-interrupted.test.ts | 33 ++ .../insights/coach-panel/chat-bubble.tsx | 43 ++ .../insights/coach-panel/use-coach.ts | 534 ++++++++++-------- 13 files changed, 713 insertions(+), 230 deletions(-) create mode 100644 src/app/coach/__tests__/page-interrupted-resume.test.tsx create mode 100644 src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx create mode 100644 src/components/insights/coach-panel/__tests__/use-coach-interrupted.test.ts diff --git a/messages/de.json b/messages/de.json index 1074a011b..c661a9b13 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2934,6 +2934,10 @@ "railBadge": "Dokument-Chat (abgeschottet)", "uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut.", "notFoundError": "Dieser Dokumentlink ist nicht mehr gültig – es wird ein normaler Chat gestartet." + }, + "interrupted": { + "note": "Diese Antwort wurde unterbrochen, bevor sie fertig war.", + "retry": "Erneut fragen" } }, "relativeJustNow": "gerade eben", diff --git a/messages/en.json b/messages/en.json index 41ef420be..df716ff7a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2934,6 +2934,10 @@ "railBadge": "Fenced document chat", "uploadError": "Upload failed. Please try again.", "notFoundError": "That document link is no longer valid — starting a normal chat instead." + }, + "interrupted": { + "note": "This answer was interrupted before it finished.", + "retry": "Ask again" } }, "relativeJustNow": "just now", diff --git a/messages/es.json b/messages/es.json index 3f0b9f45b..55d2cdf45 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2934,6 +2934,10 @@ "railBadge": "Chat de documento aislado", "uploadError": "Error al subir. Inténtalo de nuevo.", "notFoundError": "Ese enlace de documento ya no es válido: se iniciará un chat normal." + }, + "interrupted": { + "note": "Esta respuesta se interrumpió antes de terminar.", + "retry": "Preguntar de nuevo" } }, "relativeJustNow": "ahora mismo", diff --git a/messages/fr.json b/messages/fr.json index 9cfc6862e..a4214cfca 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2934,6 +2934,10 @@ "railBadge": "Chat de document cloisonné", "uploadError": "Échec du téléversement. Veuillez réessayer.", "notFoundError": "Ce lien de document n'est plus valide : une conversation normale démarre à la place." + }, + "interrupted": { + "note": "Cette réponse a été interrompue avant la fin.", + "retry": "Reposer la question" } }, "relativeJustNow": "à l’instant", diff --git a/messages/it.json b/messages/it.json index f97446385..7d8790c76 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2934,6 +2934,10 @@ "railBadge": "Chat documento isolata", "uploadError": "Caricamento non riuscito. Riprova.", "notFoundError": "Quel link al documento non è più valido: verrà avviata una chat normale." + }, + "interrupted": { + "note": "Questa risposta è stata interrotta prima di essere completata.", + "retry": "Chiedi di nuovo" } }, "relativeJustNow": "proprio ora", diff --git a/messages/pl.json b/messages/pl.json index e2f598d4d..f1c62f51c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2934,6 +2934,10 @@ "railBadge": "Odizolowany czat dokumentu", "uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie.", "notFoundError": "Ten link do dokumentu jest już nieprawidłowy – zamiast tego rozpoczynamy zwykły czat." + }, + "interrupted": { + "note": "Ta odpowiedź została przerwana przed ukończeniem.", + "retry": "Zapytaj ponownie" } }, "relativeJustNow": "przed chwilą", diff --git a/src/__tests__/coach-stream-lifecycle-guard.test.ts b/src/__tests__/coach-stream-lifecycle-guard.test.ts index c014cf97d..466a34f93 100644 --- a/src/__tests__/coach-stream-lifecycle-guard.test.ts +++ b/src/__tests__/coach-stream-lifecycle-guard.test.ts @@ -77,6 +77,35 @@ describe("Coach streaming lifecycle", () => { ).toBe(true); }); + it("the unmount cleanup records the interrupted conversation before the abort (#781)", () => { + const source = read("components/insights/coach-panel/use-coach.ts"); + + // A cleanup that writes the resume value under the storage key. Without + // it the unmount abort is silent again: the user returns to a blank new + // chat with no route back to the interrupted thread. + const recordsInterruption = + /return\s*\(\)\s*=>\s*\{[\s\S]*?interruptedResumeValue\(\s*active\s*\)[\s\S]*?sessionStorage\.setItem\(\s*COACH_INTERRUPTED_STORAGE_KEY/.test( + source, + ); + expect( + recordsInterruption, + "use-coach.ts must record the interrupted conversation " + + "(COACH_INTERRUPTED_STORAGE_KEY) in an unmount cleanup", + ).toBe(true); + + // And the recording effect must be defined BEFORE the abort effect so + // the flag is written while the send is still marked in flight. + const recordIndex = source.indexOf("COACH_INTERRUPTED_STORAGE_KEY,\n"); + const abortIndex = source.indexOf("abortRef.current?.abort();\n };"); + expect(recordIndex).toBeGreaterThan(-1); + expect(abortIndex).toBeGreaterThan(-1); + expect( + recordIndex < abortIndex, + "the interruption-recording cleanup must be defined before the " + + "mount-scoped abort cleanup", + ).toBe(true); + }); + it("handleNewChat still resets too — the two paths must not diverge again", () => { const source = read( "components/insights/coach-panel/coach-conversation.tsx", diff --git a/src/app/coach/__tests__/page-interrupted-resume.test.tsx b/src/app/coach/__tests__/page-interrupted-resume.test.tsx new file mode 100644 index 000000000..c01191e27 --- /dev/null +++ b/src/app/coach/__tests__/page-interrupted-resume.test.tsx @@ -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) => { + conversationProps(props); + return
; + }, +})); + +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(); +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 { + searchParams.current = new URLSearchParams(query); + renderToStaticMarkup(); + const calls = conversationProps.mock.calls; + return calls[calls.length - 1][0] as Record; +} + +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"); + }); +}); diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx index 0b8bb28ce..cef46e764 100644 --- a/src/app/coach/page-client.tsx +++ b/src/app/coach/page-client.tsx @@ -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(() => { + 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() { ` 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} /> ); } diff --git a/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx b/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx new file mode 100644 index 000000000..0d3db1a7e --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * #781 — the persisted cancelled-turn marker (an EMPTY assistant row with + * `providerType: "cancelled"`) renders as a quiet interrupted note with a + * retry, never as an empty assistant bubble. SSR harness mirrors + * `chat-bubble-unverified-notice.test.tsx`. + */ +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "tester", role: "USER", avatarUrl: null }, + isAuthenticated: true, + isLoading: false, + }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined }), + useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }), +})); + +import { MessageThread } from "../message-thread"; +import type { + CoachConversationDetailDTO, + CoachMessageDTO, +} from "@/lib/ai/coach/types"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function conversationWith( + messages: Array>, +): CoachConversationDetailDTO { + return { + id: "c1", + title: "Test", + createdAt: "2026-08-13T10:00:00.000Z", + updatedAt: "2026-08-13T10:00:00.000Z", + messageCount: messages.length, + fenced: false, + attachments: [], + attachmentCount: 0, + documentTitle: null, + messages: messages.map((m, i) => ({ + id: `m${i}`, + role: "user", + content: "", + createdAt: "2026-08-13T10:00:00.000Z", + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + ...m, + })), + }; +} + +const interruptedThread = conversationWith([ + { role: "user", content: "How is my blood pressure trending?" }, + { role: "assistant", content: "", providerType: "cancelled" }, +]); + +describe("chat bubble — cancelled-turn marker (#781)", () => { + it("renders the interrupted note with a retry action, muted, no alarm colour", () => { + const html = render( + undefined} + />, + ); + expect(html).toContain('data-slot="coach-bubble-cancelled"'); + expect(html).toContain("This answer was interrupted before it finished."); + // The retry resubmits the preceding user question — the thread resolved + // one, so the affordance is present. + expect(html).toContain('data-slot="coach-interrupted-retry"'); + expect(html).toContain("Ask again"); + // Muted meta, not an alarm: the marker must not paint destructive/warning. + const markerHtml = html.slice(html.indexOf("coach-bubble-cancelled")); + expect(markerHtml).not.toContain("text-destructive"); + expect(markerHtml).not.toContain("text-warning"); + // No empty assistant prose bubble alongside the marker. + expect(html).not.toContain('data-slot="coach-bubble-assistant"'); + }); + + it("omits the retry when the surface supplies no regenerate handler", () => { + const html = render(); + expect(html).toContain('data-slot="coach-bubble-cancelled"'); + expect(html).not.toContain('data-slot="coach-interrupted-retry"'); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/use-coach-interrupted.test.ts b/src/components/insights/coach-panel/__tests__/use-coach-interrupted.test.ts new file mode 100644 index 000000000..191829bdf --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/use-coach-interrupted.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; + +import { interruptedResumeValue } from "../use-coach"; + +/** + * #781 — the write side of the interrupted-conversation resume. The unmount + * cleanup records `interruptedResumeValue(...)` under + * `COACH_INTERRUPTED_STORAGE_KEY` before aborting an in-flight stream; the + * `/coach` page's consumption side is pinned in + * `src/app/coach/__tests__/page-interrupted-resume.test.tsx`. + */ +describe("interruptedResumeValue (#781)", () => { + it("records the conversation id for an in-flight turn on an existing thread", () => { + expect( + interruptedResumeValue({ conversationId: "conv-42", inProgress: true }), + ).toBe("conv-42"); + }); + + it("records the 'latest' sentinel for an in-flight FIRST turn (id unknown)", () => { + expect( + interruptedResumeValue({ conversationId: null, inProgress: true }), + ).toBe("latest"); + }); + + it("records nothing for a settled turn — unmount after completion is not an interruption", () => { + expect( + interruptedResumeValue({ conversationId: "conv-42", inProgress: false }), + ).toBe(null); + expect( + interruptedResumeValue({ conversationId: null, inProgress: false }), + ).toBe(null); + }); +}); diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 36b85c097..475c537e9 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; @@ -567,6 +568,48 @@ function ChatBubbleImpl({ ); } + // #781 — the persisted cancelled-turn marker (an EMPTY assistant row the + // server writes when the client aborts mid-generation, e.g. navigating + // away). Rendered as a quiet interrupted note — muted meta text per + // UI-STANDARDS §3, never an alarm colour: nothing failed, the user left — + // plus a retry that resubmits the question that produced the turn (the + // regenerate closure, so the resend runs through the normal send path and + // every budget/rate gate). No provenance, feedback, copy, or token footer: + // there is no reply to act on. + if (providerType === "cancelled") { + return ( +
+ +
+

+ {t("insights.coach.interrupted.note")} +

+ {onRegenerate && ( + + )} +
+
+ ); + } + // v1.4.25 W5 — map server-emitted error codes to specific Coach // i18n keys. Distinct user-quota and provider-rate-limit copy so the // user understands daily-cap (resets at UTC midnight) vs. transient diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 67db1df37..532d58f60 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -586,6 +586,33 @@ export interface CoachOptimisticUserMessage { conversationId: string | null; } +/** + * #781 — sessionStorage key recording a turn interrupted by navigation. + * Written by the unmount cleanup in `useSendCoachMessage` when a stream is + * still in flight; consumed (and cleared) by the `/coach` page on its next + * mount so the user lands back IN the interrupted conversation instead of a + * blank new chat. The value is the conversation id, or the sentinel + * `"latest"` when the aborted turn was creating a brand-new conversation — + * its id only travels on the SSE `done` frame, which never arrived, so the + * page resolves it via the server-authoritative most-recent thread instead. + */ +export const COACH_INTERRUPTED_STORAGE_KEY = "coach-interrupted-conversation"; + +/** + * #781 — the resume value the unmount cleanup records, or null when nothing + * should be recorded. Pure so the decision is unit-testable without mounting + * the hook (this repo's tests are SSR-only): a settled turn records nothing, + * an in-flight turn records its conversation id, and an in-flight FIRST turn + * (id not yet known) records the `"latest"` sentinel. + */ +export function interruptedResumeValue(active: { + conversationId: string | null; + inProgress: boolean; +}): string | null { + if (!active.inProgress) return null; + return active.conversationId ?? "latest"; +} + const EMPTY_STREAMING: CoachStreamingMessage = { content: "", metricSource: null, @@ -742,6 +769,48 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { optsRef.current = opts; }, [opts]); + // #781 — the send in flight at this moment, for the unmount cleanup below. + // A ref (not state): the cleanup closure must read the live value, and the + // bookkeeping must never re-render the surface. + const activeSendRef = useRef<{ + conversationId: string | null; + inProgress: boolean; + }>({ conversationId: null, inProgress: false }); + + // #781 — the unmount abort below stays (it is the budget guard), but it + // must not be SILENT any more: record which conversation was interrupted so + // the next `/coach` mount reopens it (the server closes the turn with a + // persisted `cancelled` marker on its side), and drop the cached + // conversation + rail copies so the return visit refetches the + // just-advanced server state instead of serving a pre-abort cache entry. + // Its own effect, defined BEFORE the abort effect: within one component + // React runs unmount cleanups in definition order, and the bookkeeping + // must read `activeSendRef` while the send is still marked in flight + // (the abort's `finally` only clears the flag on a later microtask + // regardless, so either order is safe — this one is merely honest). + useEffect(() => { + return () => { + const active = activeSendRef.current; + const resumeValue = interruptedResumeValue(active); + if (resumeValue === null) return; + try { + window.sessionStorage.setItem( + COACH_INTERRUPTED_STORAGE_KEY, + resumeValue, + ); + } catch { + // Storage can throw (private mode / quota) — the persisted + // cancelled marker still surfaces the interruption in history. + } + if (active.conversationId) { + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.one(active.conversationId), + }); + } + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() }); + }; + }, [queryClient]); + // Abort any in-flight stream when the hook unmounts. The drawer works around // this via `handleOpenChange`, but the full-page `/coach` surface passes no // `registerReset`, so navigating away mid-stream left the SSE reader loop @@ -764,254 +833,269 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; - - // v1.4.25 W5 — surface the user's message in the thread BEFORE - // the "Thinking…" placeholder paints. Without this the send hook - // only exposed the streaming assistant bubble, so the first - // thing the user saw was the placeholder followed by their own - // message landing on the next refetch — the maintainer flagged the - // order as confusing on the suggested-prompt chips. - setOptimisticUser({ - localId: `local-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - content: params.message, + // #781 — mark the send in flight for the unmount cleanup. The whole + // body runs inside try/finally so EVERY exit (offline pre-check, fetch + // failure, error envelope, stream end, abort) clears the flag — an + // unmount after a settled turn must never record an interruption. The + // marker object is captured locally so a superseding send (which + // replaces `activeSendRef.current` with its own marker) is never + // cleared by the superseded send's finally. + const sendMarker = { conversationId: params.conversationId ?? null, - }); - setStreaming({ - content: "", - metricSource: null, - suggestion: null, - suggestedAction: null, inProgress: true, - messageId: null, - errorCode: null, - usage: null, - }); - - // v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode - // user gets the offline-specific copy immediately rather than - // waiting for the fetch to fail with a generic network error. - if (typeof navigator !== "undefined" && navigator.onLine === false) { - setStreaming({ - content: "", - metricSource: null, - suggestion: null, - suggestedAction: null, - inProgress: false, - messageId: null, - errorCode: "coach.network", - usage: null, - }); - return null; - } - - // v1.28.51 — resolve the target BEFORE the fetch. A doc-scoped turn - // resolves to the hardened fenced document endpoint; everything else to - // the Coach tool route. This is the prompt-injection fence's client edge. - const target = resolveCoachSendTarget(params); - - // apiFetchRaw: the chat POST streams SSE — the envelope helpers - // would buffer and unwrap a body that never carries the envelope. - let response: Response; + }; + activeSendRef.current = sendMarker; try { - response = await apiFetchRaw(target.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "text/event-stream", - }, - body: target.body, - signal: controller.signal, + // v1.4.25 W5 — surface the user's message in the thread BEFORE + // the "Thinking…" placeholder paints. Without this the send hook + // only exposed the streaming assistant bubble, so the first + // thing the user saw was the placeholder followed by their own + // message landing on the next refetch — the maintainer flagged the + // order as confusing on the suggested-prompt chips. + setOptimisticUser({ + localId: `local-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + content: params.message, + conversationId: params.conversationId ?? null, }); - } catch (err) { - if ((err as Error).name === "AbortError") return null; setStreaming({ content: "", metricSource: null, suggestion: null, suggestedAction: null, - inProgress: false, + inProgress: true, messageId: null, - errorCode: "coach.network", + errorCode: null, usage: null, }); - return null; - } - if (!response.body) { - setStreaming({ - content: "", - metricSource: null, - suggestion: null, - suggestedAction: null, - inProgress: false, - messageId: null, - errorCode: `coach.http.${response.status}`, - usage: null, - }); - return null; - } - - // Even on `!response.ok`, the route may have emitted a structured - // `error` frame in an SSE body (e.g. 4xx/5xx + text/event-stream). - // Falling back to `coach.http.` would discard the richer - // `errorCode`. Read the stream and let the parser route the frame - // through the same path as the success case. - const contentType = response.headers.get("Content-Type") ?? ""; - const isEventStream = contentType - .toLowerCase() - .startsWith("text/event-stream"); - if (!response.ok && !isEventStream) { - // v1.4.25 W5 — for JSON errors the apiHandler envelope carries - // a structured `error` code (e.g. `coach.budget.exceeded`). - // Surface the structured code directly so the drawer can show - // the right copy + toast variant; fall back to the generic - // `coach.http.` only when parsing the envelope fails. - let structured: string | null = null; - try { - const envelope = (await response.clone().json()) as { - error?: unknown; - }; - if (typeof envelope?.error === "string") { - structured = envelope.error; - } - } catch { - // body was not JSON; fall through to the http-status fallback + // v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode + // user gets the offline-specific copy immediately rather than + // waiting for the fetch to fail with a generic network error. + if (typeof navigator !== "undefined" && navigator.onLine === false) { + setStreaming({ + content: "", + metricSource: null, + suggestion: null, + suggestedAction: null, + inProgress: false, + messageId: null, + errorCode: "coach.network", + usage: null, + }); + return null; } - setStreaming({ - content: "", - metricSource: null, - suggestion: null, - suggestedAction: null, - inProgress: false, - messageId: null, - errorCode: structured ?? `coach.http.${response.status}`, - usage: null, - }); - // v1.16.4 — KEEP the optimistic user bubble: a rejected turn - // (budget gate, 4xx) has no persisted twin coming, and dropping - // the bubble left the error standing next to an empty thread. - // The content-equality dedupe in still - // suppresses the optimistic copy if a persisted twin lands. - return null; - } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let resolvedConversationId: string | null = null; - let collectedContent = ""; - let collectedProvenance: CoachProvenance | null = null; - let collectedSuggestion: CoachSuggestion | null = null; - let collectedSuggestedAction: CoachSuggestedAction | null = null; - let lastError: string | null = null; - let messageId: string | null = null; - let collectedUsage: CoachUsage | null = null; + // v1.28.51 — resolve the target BEFORE the fetch. A doc-scoped turn + // resolves to the hardened fenced document endpoint; everything else to + // the Coach tool route. This is the prompt-injection fence's client edge. + const target = resolveCoachSendTarget(params); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const text = decoder.decode(value, { stream: true }); - const { events, rest } = parseSseChunk(buffer, text); - buffer = rest; - for (const evt of events) { - switch (evt.type) { - case "token": - collectedContent += evt.token; - setStreaming((prev) => ({ - ...prev, - content: prev.content + evt.token, - })); - break; - case "provenance": - collectedProvenance = evt.metricSource; - setStreaming((prev) => ({ - ...prev, - metricSource: evt.metricSource, - })); - break; - case "suggestion": - collectedSuggestion = evt.suggestion; - setStreaming((prev) => ({ - ...prev, - suggestion: evt.suggestion, - })); - break; - case "suggestedAction": - collectedSuggestedAction = evt.suggestedAction; - setStreaming((prev) => ({ - ...prev, - suggestedAction: evt.suggestedAction, - })); - break; - case "reasoning": - // v1.19.1 — the thinking disclosure was removed, so the - // additive `reasoning` frame is accepted off the wire and - // dropped. Kept as an explicit no-op case so the wire frame - // stays a recognised type rather than slipping to a default. - break; - case "done": - resolvedConversationId = evt.conversationId; - messageId = evt.messageId; - // v1.18.9 — additive per-turn usage envelope. - collectedUsage = evt.usage ?? null; - break; - case "error": - lastError = evt.code; - break; + // apiFetchRaw: the chat POST streams SSE — the envelope helpers + // would buffer and unwrap a body that never carries the envelope. + let response: Response; + try { + response = await apiFetchRaw(target.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: target.body, + signal: controller.signal, + }); + } catch (err) { + if ((err as Error).name === "AbortError") return null; + setStreaming({ + content: "", + metricSource: null, + suggestion: null, + suggestedAction: null, + inProgress: false, + messageId: null, + errorCode: "coach.network", + usage: null, + }); + return null; + } + + if (!response.body) { + setStreaming({ + content: "", + metricSource: null, + suggestion: null, + suggestedAction: null, + inProgress: false, + messageId: null, + errorCode: `coach.http.${response.status}`, + usage: null, + }); + return null; + } + + // Even on `!response.ok`, the route may have emitted a structured + // `error` frame in an SSE body (e.g. 4xx/5xx + text/event-stream). + // Falling back to `coach.http.` would discard the richer + // `errorCode`. Read the stream and let the parser route the frame + // through the same path as the success case. + const contentType = response.headers.get("Content-Type") ?? ""; + const isEventStream = contentType + .toLowerCase() + .startsWith("text/event-stream"); + if (!response.ok && !isEventStream) { + // v1.4.25 W5 — for JSON errors the apiHandler envelope carries + // a structured `error` code (e.g. `coach.budget.exceeded`). + // Surface the structured code directly so the drawer can show + // the right copy + toast variant; fall back to the generic + // `coach.http.` only when parsing the envelope fails. + let structured: string | null = null; + try { + const envelope = (await response.clone().json()) as { + error?: unknown; + }; + if (typeof envelope?.error === "string") { + structured = envelope.error; + } + } catch { + // body was not JSON; fall through to the http-status fallback + } + setStreaming({ + content: "", + metricSource: null, + suggestion: null, + suggestedAction: null, + inProgress: false, + messageId: null, + errorCode: structured ?? `coach.http.${response.status}`, + usage: null, + }); + // v1.16.4 — KEEP the optimistic user bubble: a rejected turn + // (budget gate, 4xx) has no persisted twin coming, and dropping + // the bubble left the error standing next to an empty thread. + // The content-equality dedupe in still + // suppresses the optimistic copy if a persisted twin lands. + return null; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let resolvedConversationId: string | null = null; + let collectedContent = ""; + let collectedProvenance: CoachProvenance | null = null; + let collectedSuggestion: CoachSuggestion | null = null; + let collectedSuggestedAction: CoachSuggestedAction | null = null; + let lastError: string | null = null; + let messageId: string | null = null; + let collectedUsage: CoachUsage | null = null; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + const { events, rest } = parseSseChunk(buffer, text); + buffer = rest; + for (const evt of events) { + switch (evt.type) { + case "token": + collectedContent += evt.token; + setStreaming((prev) => ({ + ...prev, + content: prev.content + evt.token, + })); + break; + case "provenance": + collectedProvenance = evt.metricSource; + setStreaming((prev) => ({ + ...prev, + metricSource: evt.metricSource, + })); + break; + case "suggestion": + collectedSuggestion = evt.suggestion; + setStreaming((prev) => ({ + ...prev, + suggestion: evt.suggestion, + })); + break; + case "suggestedAction": + collectedSuggestedAction = evt.suggestedAction; + setStreaming((prev) => ({ + ...prev, + suggestedAction: evt.suggestedAction, + })); + break; + case "reasoning": + // v1.19.1 — the thinking disclosure was removed, so the + // additive `reasoning` frame is accepted off the wire and + // dropped. Kept as an explicit no-op case so the wire frame + // stays a recognised type rather than slipping to a default. + break; + case "done": + resolvedConversationId = evt.conversationId; + messageId = evt.messageId; + // v1.18.9 — additive per-turn usage envelope. + collectedUsage = evt.usage ?? null; + break; + case "error": + lastError = evt.code; + break; + } } } - } - // Final flush of any trailing frame in the residual buffer. - const tail = parseSseChunk(buffer, "\n\n"); - for (const evt of tail.events) { - if (evt.type === "done") { - resolvedConversationId = evt.conversationId; - messageId = evt.messageId; - collectedUsage = evt.usage ?? null; - } else if (evt.type === "error") { - lastError = evt.code; + // Final flush of any trailing frame in the residual buffer. + const tail = parseSseChunk(buffer, "\n\n"); + for (const evt of tail.events) { + if (evt.type === "done") { + resolvedConversationId = evt.conversationId; + messageId = evt.messageId; + collectedUsage = evt.usage ?? null; + } else if (evt.type === "error") { + lastError = evt.code; + } + } + } catch (err) { + if ((err as Error).name !== "AbortError") { + lastError = "coach.stream"; } } - } catch (err) { - if ((err as Error).name !== "AbortError") { - lastError = "coach.stream"; - } - } - setStreaming({ - content: collectedContent, - metricSource: collectedProvenance, - suggestion: collectedSuggestion, - suggestedAction: collectedSuggestedAction, - inProgress: false, - messageId, - errorCode: lastError, - usage: collectedUsage, - }); - - if (resolvedConversationId) { - // Invalidate the freshly-persisted conversation + the rail so - // the drawer's next mount picks up the encrypted-on-disk shape - // and the rail orders the thread to the top. - queryClient.invalidateQueries({ - queryKey: QUERY_KEYS.one(resolvedConversationId), + setStreaming({ + content: collectedContent, + metricSource: collectedProvenance, + suggestion: collectedSuggestion, + suggestedAction: collectedSuggestedAction, + inProgress: false, + messageId, + errorCode: lastError, + usage: collectedUsage, }); - queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() }); - optsRef.current.onDone?.(resolvedConversationId); + + if (resolvedConversationId) { + // Invalidate the freshly-persisted conversation + the rail so + // the drawer's next mount picks up the encrypted-on-disk shape + // and the rail orders the thread to the top. + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.one(resolvedConversationId), + }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() }); + optsRef.current.onDone?.(resolvedConversationId); + } + // v1.4.25 W5 — drop the optimistic user bubble; the persisted + // twin is on its way via the invalidate-refetch above. + // v1.16.4 — but only when a twin IS coming (the turn resolved a + // conversation) or the turn succeeded outright. An errored turn + // without a conversation id persisted nothing; dropping the + // bubble then erased the user's message next to the error bubble. + // The content-equality dedupe in still suppresses + // the optimistic copy if a persisted twin does land later. + if (resolvedConversationId || !lastError) { + setOptimisticUser(null); + } + return resolvedConversationId; + } finally { + sendMarker.inProgress = false; } - // v1.4.25 W5 — drop the optimistic user bubble; the persisted - // twin is on its way via the invalidate-refetch above. - // v1.16.4 — but only when a twin IS coming (the turn resolved a - // conversation) or the turn succeeded outright. An errored turn - // without a conversation id persisted nothing; dropping the - // bubble then erased the user's message next to the error bubble. - // The content-equality dedupe in still suppresses - // the optimistic copy if a persisted twin does land later. - if (resolvedConversationId || !lastError) { - setOptimisticUser(null); - } - return resolvedConversationId; }, [queryClient], );