diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
index 114883526..1518762b8 100644
--- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
@@ -77,6 +77,10 @@ function streaming(
metricSource: null,
suggestion: null,
suggestedAction: null,
+ steps: [],
+ results: [],
+ followUps: [],
+ clarification: null,
inProgress: false,
messageId: null,
errorCode: null,
diff --git a/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts
new file mode 100644
index 000000000..add56be66
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts
@@ -0,0 +1,180 @@
+/**
+ * v1.39.4 — the client end of the dialog frames: step upserts, the request
+ * fields a chip or an answered question adds, and which message the chips
+ * and the clarification belong to.
+ */
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/i18n/context", () => ({
+ useTranslations: () => ({ t: (k: string) => k }),
+}));
+
+import type {
+ CoachMessageDTO,
+ CoachStep,
+ CoachStreamEvent,
+} from "@/lib/ai/coach/types";
+
+import {
+ parseSseChunk,
+ resolveCoachSendTarget,
+ upsertStep,
+ type CoachStreamingMessage,
+} from "../use-coach";
+import { latestClarification, latestFollowUps } from "../message-thread";
+
+const STEP: CoachStep = {
+ id: "s1",
+ tool: "get_sleep",
+ labelKey: "coach.step.read",
+ label: "Reading Sleep",
+ domain: "sleep",
+ status: "running",
+};
+
+const CHIP = {
+ id: "f1",
+ kind: "as_table" as const,
+ labelKey: "coach.followUp.asTable",
+ label: "Show as a table",
+ reuse: true,
+ origin: "server" as const,
+};
+
+const CLARIFY = { kind: "window" as const, choices: [], freeText: true };
+
+function streaming(
+ partial: Partial,
+): CoachStreamingMessage {
+ return {
+ content: "",
+ metricSource: null,
+ suggestion: null,
+ suggestedAction: null,
+ steps: [],
+ results: [],
+ followUps: [],
+ clarification: null,
+ inProgress: false,
+ messageId: null,
+ errorCode: null,
+ usage: null,
+ ...partial,
+ };
+}
+
+function message(partial: Partial): CoachMessageDTO {
+ return {
+ id: "m1",
+ role: "assistant",
+ content: "reply",
+ createdAt: "2026-09-26T00:00:00.000Z",
+ metricSource: null,
+ providerType: "anthropic",
+ promptVersion: null,
+ tokensUsed: null,
+ model: null,
+ ...partial,
+ };
+}
+
+describe("dialog frames on the client", () => {
+ it("parses the new frames like any other", () => {
+ const frames: CoachStreamEvent[] = [
+ { type: "step", step: STEP },
+ { type: "followUps", followUps: [CHIP] },
+ { type: "clarification", clarification: CLARIFY },
+ ];
+ const { events } = parseSseChunk(
+ "",
+ frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join(""),
+ );
+ expect(events).toEqual(frames);
+ });
+
+ it("upserts a step by id, keeping first-seen order", () => {
+ const second = { ...STEP, id: "s2" };
+ let steps = upsertStep([], STEP);
+ steps = upsertStep(steps, second);
+ steps = upsertStep(steps, { ...STEP, status: "done", count: 12 });
+ expect(steps.map((s) => [s.id, s.status])).toEqual([
+ ["s1", "done"],
+ ["s2", "running"],
+ ]);
+ });
+});
+
+describe("resolveCoachSendTarget — chips and answers", () => {
+ it("carries followUp and clarification on the tool route", () => {
+ const target = resolveCoachSendTarget({
+ conversationId: "c1",
+ message: "Show as a table",
+ followUp: { messageId: "m1", id: "f1" },
+ clarification: { messageId: "m0", choiceId: "c2" },
+ });
+ expect(target.url).toBe("/api/insights/chat");
+ expect(JSON.parse(target.body)).toMatchObject({
+ followUp: { messageId: "m1", id: "f1" },
+ clarification: { messageId: "m0", choiceId: "c2" },
+ });
+ });
+
+ it("never carries them to the fenced route", () => {
+ const target = resolveCoachSendTarget({
+ conversationId: "c1",
+ message: "x",
+ fenced: true,
+ followUp: { messageId: "m1", id: "f1" },
+ clarification: { messageId: "m0" },
+ });
+ expect(target.url).toBe("/api/insights/chat/fenced");
+ const body = JSON.parse(target.body);
+ expect(body).not.toHaveProperty("followUp");
+ expect(body).not.toHaveProperty("clarification");
+ });
+});
+
+describe("which message the chips and the question belong to", () => {
+ const withChips = message({
+ metricSource: {
+ windows: [],
+ metrics: [],
+ followUps: [CHIP],
+ clarification: CLARIFY,
+ },
+ });
+
+ it("offers the latest persisted assistant message's chips", () => {
+ expect(latestFollowUps([withChips], undefined)).toEqual({
+ messageId: "m1",
+ followUps: [CHIP],
+ });
+ expect(latestClarification([withChips], undefined)).toEqual({
+ messageId: "m1",
+ clarification: CLARIFY,
+ });
+ });
+
+ it("offers nothing once the person replied, and nothing mid-turn", () => {
+ const answered = [withChips, message({ id: "m2", role: "user" })];
+ expect(latestFollowUps(answered, undefined)).toBeNull();
+ expect(latestClarification(answered, undefined)).toBeNull();
+ expect(
+ latestFollowUps([withChips], streaming({ inProgress: true })),
+ ).toBeNull();
+ });
+
+ it("prefers the just-settled streamed turn over an older message", () => {
+ const live = streaming({
+ messageId: "m9",
+ followUps: [{ ...CHIP, id: "f2" }],
+ clarification: CLARIFY,
+ });
+ expect(latestFollowUps([withChips], live)?.messageId).toBe("m9");
+ expect(latestClarification([withChips], live)?.messageId).toBe("m9");
+ // A settled streamed turn without chips hides an older message's.
+ expect(
+ latestFollowUps([withChips], streaming({ messageId: "m9" })),
+ ).toBeNull();
+ });
+});
diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx
index 02057310a..7cd012496 100644
--- a/src/components/insights/coach-panel/chat-bubble.tsx
+++ b/src/components/insights/coach-panel/chat-bubble.tsx
@@ -41,6 +41,9 @@ import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
import { MessageTokenFooter } from "./message-token-footer";
+import { CoachTurnSteps } from "./turn-steps";
+import { CoachResults } from "./coach-results";
+import { CoachMethodLine } from "./method-line";
import {
COACH_ICON_BUTTON,
ReadAloudButton,
@@ -387,6 +390,18 @@ interface ChatBubbleProps {
*/
suggestedAction?:
import("@/lib/ai/coach/suggest-action").CoachSuggestedAction | null;
+ /**
+ * v1.39.4 — live steps from the streaming hook. Persisted messages carry
+ * them on `metricSource.steps`; the bubble falls back to that.
+ */
+ steps?: import("@/lib/ai/coach/types").CoachStep[];
+ /**
+ * v1.39.4 — live tables from the streaming hook. A persisted message
+ * lists their metadata on `metricSource.results` and the values are
+ * fetched lazily, which needs the conversation id.
+ */
+ results?: import("@/lib/ai/coach/types").CoachResultTable[];
+ conversationId?: string | null;
providerType?: string | null;
inProgress?: boolean;
errorCode?: string | null;
@@ -464,6 +479,9 @@ export function areChatBubblePropsEqual(
prev.metricSource === next.metricSource &&
prev.suggestion === next.suggestion &&
prev.suggestedAction === next.suggestedAction &&
+ prev.steps === next.steps &&
+ prev.results === next.results &&
+ prev.conversationId === next.conversationId &&
prev.usage === next.usage &&
// onRegenerate is a per-render closure — compare only whether it is present.
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
@@ -476,6 +494,9 @@ function ChatBubbleImpl({
metricSource,
suggestion,
suggestedAction,
+ steps,
+ results,
+ conversationId,
providerType,
inProgress,
errorCode,
@@ -654,7 +675,8 @@ function ChatBubbleImpl({
!!metricSource &&
((metricSource.metrics?.length ?? 0) > 0 ||
(metricSource.windows?.length ?? 0) > 0);
- const hasProvenance = hasChips || keyValues.length > 0;
+ const method = metricSource?.method ?? null;
+ const hasProvenance = hasChips || keyValues.length > 0 || method !== null;
// v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance-
// grounded `metric:` chart under a SETTLED assistant turn. Skipped
@@ -693,6 +715,12 @@ function ChatBubbleImpl({
)}
+ {/* v1.39.4 — what the Coach read on this turn: live from the step
+ frames, restored from the provenance on reload. */}
+
{/* v1.19.1 (C3) — the live turn shows the classic typing animation
(three pulsing dots) while it is still thinking with no prose
yet, restoring the writing/typing indicator the maintainer
@@ -768,6 +796,17 @@ function ChatBubbleImpl({
})}
)}
+ {/* v1.39.4 — the tables the turn read, on a settled turn only: live
+ from the result frames, or fetched lazily for a persisted
+ message whose provenance lists them. */}
+ {!inProgress && !errorCode && (
+
+ )}
{/* v1.18.6 — a "no provider configured anywhere" turn is a
setup gap, not a transient failure: surface a direct link to
Settings → AI so the Coach guides the user into BYOK / local
@@ -874,6 +913,9 @@ function ChatBubbleImpl({
))}
)}
+ {/* v1.39.4 — how the answer was worked out, beside the key
+ values it explains. */}
+
)}
diff --git a/src/components/insights/coach-panel/clarification-card.tsx b/src/components/insights/coach-panel/clarification-card.tsx
new file mode 100644
index 000000000..ca8aac2a9
--- /dev/null
+++ b/src/components/insights/coach-panel/clarification-card.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+/**
+ * v1.39.4 — the choices for a clarifying question, above the composer.
+ * The question itself is the assistant reply; a tap on a choice sends its
+ * label with `clarification: { messageId, choiceId }`, and a typed answer
+ * goes out with `clarification: { messageId }`.
+ *
+ * Not built yet: renders nothing.
+ */
+import type {
+ CoachClarification,
+ CoachClarificationChoice,
+} from "@/lib/ai/coach/types";
+
+export interface CoachClarificationCardProps {
+ clarification: CoachClarification;
+ /** The assistant message that asked. */
+ messageId: string;
+ /** True while a turn is in flight. */
+ disabled: boolean;
+ onChoose: (choice: CoachClarificationChoice) => void;
+}
+
+export function CoachClarificationCard(_props: CoachClarificationCardProps) {
+ return null;
+}
diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx
index d4cb5b493..7f2d30cf9 100644
--- a/src/components/insights/coach-panel/coach-conversation.tsx
+++ b/src/components/insights/coach-panel/coach-conversation.tsx
@@ -18,7 +18,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { randomId } from "@/lib/random-id";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
-import type { CoachScope } from "@/lib/ai/coach/types";
+import type {
+ CoachClarificationChoice,
+ CoachFollowUp,
+ CoachScope,
+} from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
@@ -45,7 +49,12 @@ import {
guidedReducer,
} from "./guided-questions-machine";
import { HistoryRail } from "./history-rail";
-import { MessageThread, type InterleavedThreadItem } from "./message-thread";
+import {
+ MessageThread,
+ latestClarification,
+ type InterleavedThreadItem,
+} from "./message-thread";
+import { CoachClarificationCard } from "./clarification-card";
import { MobileRailTray } from "./mobile-rail-tray";
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
import { SourcesRail } from "./sources-rail";
@@ -606,6 +615,37 @@ export function CoachConversation({
registerConversationIdGetter(() => conversationIdRef.current);
}, [registerConversationIdGetter]);
+ // v1.39.4 — the open clarifying question, if the latest reply asked one.
+ // A typed message answers it; a chip on the card answers it with a choice.
+ const openClarification = fenced
+ ? null
+ : latestClarification(conversation?.messages ?? [], send.streaming);
+
+ // v1.39.4 — a follow-up chip: its label is the message, and the server
+ // resolves what it asks for from the message that offered it.
+ function handleFollowUp(followUp: CoachFollowUp, messageId: string) {
+ if (send.isStreaming || fenced || currentConversationId === null) return;
+ void send.send({
+ conversationId: currentConversationId,
+ message: followUp.label,
+ followUp: { messageId, id: followUp.id },
+ });
+ }
+
+ // v1.39.4 — a choice on the clarification card.
+ function handleClarificationChoice(choice: CoachClarificationChoice) {
+ if (send.isStreaming || !openClarification) return;
+ if (currentConversationId === null) return;
+ void send.send({
+ conversationId: currentConversationId,
+ message: choice.label,
+ clarification: {
+ messageId: openClarification.messageId,
+ choiceId: choice.id,
+ },
+ });
+ }
+
async function handleSubmit(value: string) {
const trimmed = value.trim();
if (!trimmed || send.isStreaming) return;
@@ -654,6 +694,10 @@ export function CoachConversation({
guidedQuestion: fenced ? undefined : (guidedQuestion ?? undefined),
scope: fenced ? undefined : scope,
workoutId: fenced ? undefined : workoutId,
+ // v1.39.4 — a typed message answers an open clarifying question.
+ clarification: openClarification
+ ? { messageId: openClarification.messageId }
+ : undefined,
fenced,
pendingAttachmentIds:
currentConversationId === null ? pendingAttachmentIds : undefined,
@@ -817,6 +861,14 @@ export function CoachConversation({
{t("insights.coach.attach.indexingHint")}
) : null}
+ {openClarification ? (
+
+ ) : null}
{/* Docked composer — the SAME centred, capped column as the
@@ -1220,6 +1273,7 @@ export function CoachConversation({
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
+ onFollowUp={handleFollowUp}
/>
}
composer={composerStack}
diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx
new file mode 100644
index 000000000..8342434ff
--- /dev/null
+++ b/src/components/insights/coach-panel/coach-results.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+/**
+ * v1.39.4 — the tables under an assistant reply. A live turn hands its
+ * tables in from the `result` frames; a persisted message lists only their
+ * metadata, and the values are fetched when the message scrolls into view
+ * (`useCoachMessageResults`). A withheld table is skipped here; the notice
+ * for it belongs to the table view.
+ */
+import { useCoachMessageResults } from "@/hooks/use-coach-message-results";
+import type {
+ CoachResultMeta,
+ CoachResultTable as CoachResultTableData,
+} from "@/lib/ai/coach/types";
+
+import { CoachResultTable } from "./result-table";
+
+export interface CoachResultsProps {
+ conversationId: string | null;
+ /** The persisted message, once it has an id. */
+ messageId: string | null;
+ /** `metricSource.results` of the message. */
+ metas: CoachResultMeta[];
+ /** The tables a live turn streamed; absent on a persisted message. */
+ live?: CoachResultTableData[];
+}
+
+export function CoachResults({
+ conversationId,
+ messageId,
+ metas,
+ live,
+}: CoachResultsProps) {
+ const hasLive = (live?.length ?? 0) > 0;
+ const { ref, results } = useCoachMessageResults({
+ conversationId,
+ messageId,
+ enabled: !hasLive && metas.length > 0,
+ });
+ if (!hasLive && metas.length === 0) return null;
+ const tables: CoachResultTableData[] = hasLive
+ ? (live ?? [])
+ : (results ?? []).filter(
+ (entry): entry is CoachResultTableData => !("withheld" in entry),
+ );
+ return (
+
+ {tables.map((table) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/insights/coach-panel/follow-up-chips.tsx b/src/components/insights/coach-panel/follow-up-chips.tsx
new file mode 100644
index 000000000..8d14cf772
--- /dev/null
+++ b/src/components/insights/coach-panel/follow-up-chips.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+/**
+ * v1.39.4 — up to three follow-up chips under the latest assistant reply.
+ * A tap sends the chip's label as the message with
+ * `followUp: { messageId, id }`; the server resolves what the chip asks for.
+ *
+ * Not built yet: renders nothing.
+ */
+import type { CoachFollowUp } from "@/lib/ai/coach/types";
+
+export interface CoachFollowUpChipsProps {
+ followUps: CoachFollowUp[];
+ /** The assistant message that offered the chips. */
+ messageId: string;
+ /** True while a turn is in flight. */
+ disabled: boolean;
+ onSelect: (followUp: CoachFollowUp, messageId: string) => void;
+}
+
+export function CoachFollowUpChips(_props: CoachFollowUpChipsProps) {
+ return null;
+}
diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx
index feda6a3cb..90d6a6763 100644
--- a/src/components/insights/coach-panel/message-thread.tsx
+++ b/src/components/insights/coach-panel/message-thread.tsx
@@ -9,12 +9,17 @@ import { useTranslations } from "@/lib/i18n/context";
import { PlanProposalCards } from "./plan-proposal-card";
import { ChatBubble } from "./chat-bubble";
+import { CoachFollowUpChips } from "./follow-up-chips";
import type {
CoachConversationDetailDTO,
CoachOptimisticUserMessage,
CoachStreamingMessage,
} from "./use-coach";
-import type { CoachMessageDTO } from "@/lib/ai/coach/types";
+import type {
+ CoachClarification,
+ CoachFollowUp,
+ CoachMessageDTO,
+} from "@/lib/ai/coach/types";
// v1.28.26 file-size split (pure code motion): the bubble renderer +
// per-message actions live in `chat-bubble.tsx`, the read-aloud stack in
@@ -72,6 +77,54 @@ export interface MessageThreadProps {
* resubmits it as a fresh turn. Omitted → the regenerate action is hidden.
*/
onRegenerate?: (userText: string) => void;
+ /**
+ * v1.39.4 — a follow-up chip was tapped. Omitted → no chips are shown.
+ */
+ onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
+}
+
+/**
+ * v1.39.4 — the open clarifying question: the latest assistant turn's
+ * choices, whether it is the just-settled streamed turn or the last
+ * persisted message. None while a turn is in flight, and none once the
+ * person has answered (their message is then the last one).
+ */
+export function latestClarification(
+ messages: CoachMessageDTO[],
+ streaming: CoachStreamingMessage | undefined,
+): { messageId: string; clarification: CoachClarification } | null {
+ if (streaming?.inProgress) return null;
+ if (streaming?.messageId && streaming.clarification) {
+ return {
+ messageId: streaming.messageId,
+ clarification: streaming.clarification,
+ };
+ }
+ const last = messages[messages.length - 1];
+ if (streaming?.messageId && streaming.messageId !== last?.id) return null;
+ if (last?.role !== "assistant") return null;
+ const clarification = last.metricSource?.clarification;
+ return clarification ? { messageId: last.id, clarification } : null;
+}
+
+/**
+ * v1.39.4 — the chips to offer, and the message that offered them: the
+ * latest assistant turn only, whether it is the just-settled streamed turn
+ * or the last persisted message. None while a turn is in flight.
+ */
+export function latestFollowUps(
+ messages: CoachMessageDTO[],
+ streaming: CoachStreamingMessage | undefined,
+): { messageId: string; followUps: CoachFollowUp[] } | null {
+ if (streaming?.inProgress) return null;
+ if (streaming?.messageId && streaming.followUps.length > 0) {
+ return { messageId: streaming.messageId, followUps: streaming.followUps };
+ }
+ const last = messages[messages.length - 1];
+ if (streaming?.messageId && streaming.messageId !== last?.id) return null;
+ if (last?.role !== "assistant") return null;
+ const followUps = last.metricSource?.followUps ?? [];
+ return followUps.length > 0 ? { messageId: last.id, followUps } : null;
}
/**
@@ -167,6 +220,7 @@ export function MessageThread({
emptyHint,
interleaved,
onRegenerate,
+ onFollowUp,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef(null);
@@ -253,6 +307,8 @@ export function MessageThread({
optimisticActive && optimisticUser ? optimisticUser.content : null,
);
+ const chips = latestFollowUps(messages, streaming);
+
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
useEffect(() => {
@@ -385,6 +441,7 @@ export function MessageThread({
role={m.role}
content={m.content}
metricSource={m.metricSource}
+ conversationId={conversation?.id ?? null}
providerType={m.providerType}
messageId={m.id}
tokensUsed={m.tokensUsed}
@@ -437,6 +494,8 @@ export function MessageThread({
metricSource={streaming.metricSource}
suggestion={streaming.suggestion}
suggestedAction={streaming.suggestedAction}
+ steps={streaming.steps}
+ results={streaming.results}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
@@ -446,6 +505,15 @@ export function MessageThread({
/>
)}
+ {/* v1.39.4 — follow-up chips under the latest assistant turn only. */}
+ {onFollowUp && chips ? (
+
+ ) : null}
{/* v1.16.5 — thread tail: the current guided question and/or the
closing summary follow the last completed turn. */}
{placement.tail.map((i) => (
diff --git a/src/components/insights/coach-panel/method-line.tsx b/src/components/insights/coach-panel/method-line.tsx
new file mode 100644
index 000000000..e1f135fe0
--- /dev/null
+++ b/src/components/insights/coach-panel/method-line.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+/**
+ * v1.39.4 — how an answer was worked out, as one muted line inside the
+ * evidence disclosure: sources, windows, counts and aggregation, rendered
+ * on the server. Never a health value.
+ *
+ * Not built yet: renders nothing.
+ */
+import type { CoachMethod } from "@/lib/ai/coach/types";
+
+export interface CoachMethodLineProps {
+ method: CoachMethod | null;
+}
+
+export function CoachMethodLine(_props: CoachMethodLineProps) {
+ return null;
+}
diff --git a/src/components/insights/coach-panel/result-table.tsx b/src/components/insights/coach-panel/result-table.tsx
new file mode 100644
index 000000000..591d5b378
--- /dev/null
+++ b/src/components/insights/coach-panel/result-table.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+/**
+ * v1.39.4 — one table of values a Coach turn read: a captioned table with
+ * the first rows shown, the rest behind "Show all", and copy for a
+ * spreadsheet or as text.
+ *
+ * Not built yet: renders nothing.
+ */
+import type { CoachResultTable as CoachResultTableData } from "@/lib/ai/coach/types";
+
+export interface CoachResultTableProps {
+ result: CoachResultTableData;
+}
+
+export function CoachResultTable(_props: CoachResultTableProps) {
+ return null;
+}
diff --git a/src/components/insights/coach-panel/turn-steps.tsx b/src/components/insights/coach-panel/turn-steps.tsx
new file mode 100644
index 000000000..e066d4245
--- /dev/null
+++ b/src/components/insights/coach-panel/turn-steps.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+/**
+ * v1.39.4 — what the Coach read on a turn: a compact list of steps under
+ * the assistant avatar, live while the turn runs (from the `step` frames)
+ * and restored from `metricSource.steps` on reload.
+ *
+ * Not built yet: renders nothing.
+ */
+import type { CoachStep } from "@/lib/ai/coach/types";
+
+export interface CoachTurnStepsProps {
+ steps: CoachStep[];
+ /** True while the turn is still running. */
+ active: boolean;
+}
+
+export function CoachTurnSteps(_props: CoachTurnStepsProps) {
+ return null;
+}
diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts
index a46aff9ef..75df5b9a0 100644
--- a/src/components/insights/coach-panel/use-coach.ts
+++ b/src/components/insights/coach-panel/use-coach.ts
@@ -11,11 +11,15 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
+ CoachClarification,
CoachConversationAttachmentDTO,
CoachConversationDetailDTO,
CoachConversationsPage,
+ CoachFollowUp,
CoachProvenance,
+ CoachResultTable,
CoachScope,
+ CoachStep,
CoachStreamEvent,
CoachSuggestion,
CoachUsage,
@@ -539,6 +543,21 @@ export interface CoachStreamingMessage {
* on `metricSource.suggestedAction` instead. Null otherwise.
*/
suggestedAction: CoachSuggestedAction | null;
+ /**
+ * v1.39.4 — the live steps from the additive `step` frames, upserted by
+ * id in first-seen order. Persisted messages carry them on
+ * `metricSource.steps`.
+ */
+ steps: CoachStep[];
+ /**
+ * v1.39.4 — the tables from the additive `result` frames. Persisted
+ * messages carry only their metadata; the values are fetched lazily.
+ */
+ results: CoachResultTable[];
+ /** v1.39.4 — the chips from the additive `followUps` frame. */
+ followUps: CoachFollowUp[];
+ /** v1.39.4 — the choices from the additive `clarification` frame. */
+ clarification: CoachClarification | null;
/** True until the `done` frame closes the stream. */
inProgress: boolean;
/** Final messageId once `done` lands; null otherwise. */
@@ -617,12 +636,25 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
metricSource: null,
suggestion: null,
suggestedAction: null,
+ steps: [],
+ results: [],
+ followUps: [],
+ clarification: null,
inProgress: false,
messageId: null,
errorCode: null,
usage: null,
};
+/** v1.39.4 — insert a step, or replace the one with the same id in place. */
+export function upsertStep(steps: CoachStep[], step: CoachStep): CoachStep[] {
+ const at = steps.findIndex((s) => s.id === step.id);
+ if (at === -1) return [...steps, step];
+ const next = [...steps];
+ next[at] = step;
+ return next;
+}
+
export interface SendCoachMessageParams {
conversationId?: string;
message: string;
@@ -665,6 +697,18 @@ export interface SendCoachMessageParams {
* client re-sent it.
*/
workoutId?: string;
+ /**
+ * v1.39.4 — the follow-up chip this message came from: the assistant
+ * message that offered it and the chip's id. The server resolves the chip
+ * from what it persisted; the client never sends what the chip asks for.
+ * Tool route only.
+ */
+ followUp?: { messageId: string; id: string };
+ /**
+ * v1.39.4 — the clarifying question this message answers, and the chip
+ * tapped (absent when the person typed their own answer). Tool route only.
+ */
+ clarification?: { messageId: string; choiceId?: string };
}
/**
@@ -712,6 +756,8 @@ export function resolveCoachSendTarget(params: SendCoachMessageParams): {
scope: params.scope,
guidedQuestion: params.guidedQuestion,
workoutId: params.workoutId,
+ followUp: params.followUp,
+ clarification: params.clarification,
}),
};
}
@@ -857,14 +903,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
conversationId: params.conversationId ?? null,
});
setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
+ ...EMPTY_STREAMING,
inProgress: true,
- messageId: null,
- errorCode: null,
- usage: null,
});
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
@@ -872,14 +912,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
// 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,
+ ...EMPTY_STREAMING,
errorCode: "coach.network",
- usage: null,
});
return null;
}
@@ -905,28 +939,16 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
} catch (err) {
if ((err as Error).name === "AbortError") return null;
setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
- inProgress: false,
- messageId: null,
+ ...EMPTY_STREAMING,
errorCode: "coach.network",
- usage: null,
});
return null;
}
if (!response.body) {
setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
- inProgress: false,
- messageId: null,
+ ...EMPTY_STREAMING,
errorCode: `coach.http.${response.status}`,
- usage: null,
});
return null;
}
@@ -963,14 +985,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
// body was not JSON; fall through to the http-status fallback
}
setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
- inProgress: false,
- messageId: null,
+ ...EMPTY_STREAMING,
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
@@ -988,6 +1004,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
let collectedProvenance: CoachProvenance | null = null;
let collectedSuggestion: CoachSuggestion | null = null;
let collectedSuggestedAction: CoachSuggestedAction | null = null;
+ let collectedSteps: CoachStep[] = [];
+ const collectedResults: CoachResultTable[] = [];
+ let collectedFollowUps: CoachFollowUp[] = [];
+ let collectedClarification: CoachClarification | null = null;
let lastError: string | null = null;
let messageId: string | null = null;
let collectedUsage: CoachUsage | null = null;
@@ -1029,6 +1049,39 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
suggestedAction: evt.suggestedAction,
}));
break;
+ case "step": {
+ // v1.39.4 — live progress, upserted by step id.
+ const step = evt.step;
+ collectedSteps = upsertStep(collectedSteps, step);
+ setStreaming((prev) => ({
+ ...prev,
+ steps: upsertStep(prev.steps, step),
+ }));
+ break;
+ }
+ case "result": {
+ const result = evt.result;
+ collectedResults.push(result);
+ setStreaming((prev) => ({
+ ...prev,
+ results: [...prev.results, result],
+ }));
+ break;
+ }
+ case "followUps":
+ collectedFollowUps = evt.followUps;
+ setStreaming((prev) => ({
+ ...prev,
+ followUps: evt.followUps,
+ }));
+ break;
+ case "clarification":
+ collectedClarification = evt.clarification;
+ setStreaming((prev) => ({
+ ...prev,
+ clarification: evt.clarification,
+ }));
+ break;
case "reasoning":
// v1.19.1 — the thinking disclosure was removed, so the
// additive `reasoning` frame is accepted off the wire and
@@ -1069,6 +1122,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
metricSource: collectedProvenance,
suggestion: collectedSuggestion,
suggestedAction: collectedSuggestedAction,
+ steps: collectedSteps,
+ results: collectedResults,
+ followUps: collectedFollowUps,
+ clarification: collectedClarification,
inProgress: false,
messageId,
errorCode: lastError,
@@ -1083,6 +1140,15 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
queryKey: QUERY_KEYS.one(resolvedConversationId),
});
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
+ // v1.39.4 — the tables this turn streamed are exactly what the
+ // persisted message stores, so seed its lazy read rather than
+ // decrypting them again on the server when the twin lands.
+ if (messageId && collectedResults.length > 0) {
+ queryClient.setQueryData(
+ queryKeys.coachMessageResults(resolvedConversationId, messageId),
+ collectedResults,
+ );
+ }
optsRef.current.onDone?.(resolvedConversationId);
}
// v1.4.25 W5 — drop the optimistic user bubble; the persisted
diff --git a/src/hooks/use-coach-message-results.ts b/src/hooks/use-coach-message-results.ts
new file mode 100644
index 000000000..3aeafe801
--- /dev/null
+++ b/src/hooks/use-coach-message-results.ts
@@ -0,0 +1,74 @@
+"use client";
+
+/**
+ * v1.39.4 — the stored tables of one Coach message, fetched lazily.
+ *
+ * A thread can hold many messages with tables, and each table set is
+ * decrypted on the server per request, so nothing is fetched until the
+ * message scrolls near the viewport: mount the returned `ref` on the
+ * element that shows the tables. `enabled` gates the whole read (a message
+ * whose provenance lists no tables never asks). Once fetched, the tables are
+ * the stored snapshot and do not change, so they stay fresh indefinitely.
+ */
+import { useEffect, useState, type RefCallback } from "react";
+import { useQuery } from "@tanstack/react-query";
+
+import type { CoachResultEntry } from "@/lib/ai/coach/types";
+import { apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
+
+export function useCoachMessageResults(args: {
+ conversationId: string | null;
+ messageId: string | null;
+ enabled: boolean;
+}): {
+ ref: RefCallback;
+ results: CoachResultEntry[] | undefined;
+ isLoading: boolean;
+ isError: boolean;
+} {
+ const { conversationId, messageId, enabled } = args;
+ const [node, setNode] = useState(null);
+ const [visible, setVisible] = useState(false);
+
+ // Without an observer (an old engine) there is nothing to wait for.
+ const canObserve = typeof IntersectionObserver !== "undefined";
+
+ useEffect(() => {
+ if (!enabled || visible || !node || !canObserve) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) setVisible(true);
+ },
+ { rootMargin: "200px" },
+ );
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [enabled, visible, node, canObserve]);
+
+ const query = useQuery({
+ queryKey: queryKeys.coachMessageResults(
+ conversationId ?? "",
+ messageId ?? "",
+ ),
+ queryFn: async (): Promise => {
+ const data = await apiGet<{ results: CoachResultEntry[] }>(
+ `/api/insights/chat/${encodeURIComponent(conversationId ?? "")}/messages/${encodeURIComponent(messageId ?? "")}/results`,
+ );
+ return data.results;
+ },
+ enabled:
+ enabled &&
+ (visible || !canObserve) &&
+ conversationId !== null &&
+ messageId !== null,
+ staleTime: Infinity,
+ });
+
+ return {
+ ref: setNode,
+ results: query.data,
+ isLoading: query.isLoading,
+ isError: query.isError,
+ };
+}
diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts
index cf336d738..5e6ba1138 100644
--- a/src/lib/query-keys/coach.ts
+++ b/src/lib/query-keys/coach.ts
@@ -48,6 +48,20 @@ export const coachKeys = {
*/
coachAttachmentMutation: (conversationId: string) =>
["coachConversation", conversationId, "attachments"] as const,
+ /**
+ * v1.39.4 — the stored tables of one assistant message
+ * (`GET /api/insights/chat/{id}/messages/{messageId}/results`). Nested under
+ * the conversation key so deleting or invalidating the conversation reaches
+ * every message's tables with the existing prefix match.
+ */
+ coachMessageResults: (conversationId: string, messageId: string) =>
+ [
+ "coachConversation",
+ conversationId,
+ "messages",
+ messageId,
+ "results",
+ ] as const,
/**
* v1.21.2 (A3) — today's most notable derived signal, resolved into the
* Coach hero's pre-seeded relevance opener