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