mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user