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

+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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ą",
@@ -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",
@@ -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}
/>
);
}
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
function conversationWith(
messages: Array<Partial<CoachMessageDTO>>,
): 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(
<MessageThread
conversation={interruptedThread}
onRegenerate={() => 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(<MessageThread conversation={interruptedThread} />);
expect(html).toContain('data-slot="coach-bubble-cancelled"');
expect(html).not.toContain('data-slot="coach-interrupted-retry"');
});
});
@@ -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);
});
});
@@ -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 (
<div
data-slot="coach-bubble-cancelled"
className="flex items-start gap-2.5"
>
<div
aria-hidden="true"
className="from-primary to-brand-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-3.5" />
</div>
<div className="flex max-w-[calc(80%-2.625rem)] flex-col items-start gap-2">
<p className="text-muted-foreground text-xs leading-relaxed">
{t("insights.coach.interrupted.note")}
</p>
{onRegenerate && (
<Button
type="button"
variant="outline"
size="sm"
data-slot="coach-interrupted-retry"
className="min-h-11 sm:min-h-9"
onClick={onRegenerate}
>
<RotateCcw className="size-3.5" aria-hidden="true" />
{t("insights.coach.interrupted.retry")}
</Button>
)}
</div>
</div>
);
}
// 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
+309 -225
View File
@@ -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.<status>` 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.<status>` 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 <MessageThread> 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.<status>` 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.<status>` 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 <MessageThread> 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 <MessageThread> 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 <MessageThread> still suppresses
// the optimistic copy if a persisted twin does land later.
if (resolvedConversationId || !lastError) {
setOptimisticUser(null);
}
return resolvedConversationId;
},
[queryClient],
);