Merge v189/coachui into release/v1.18.9

Rebuild the /coach surface as a centred-hero chat with a word-by-word fade, a
single thinking disclosure, and a per-message token footer; emit token usage on
the done frame and persist it via migration 0182; plumb an additive reasoning
frame with an elapsed-time fallback for non-reasoning providers.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 12:14:42 +02:00
commit 3505ffde24
22 files changed
+1013 -159

No files matched your search

+24 -5
View File
@@ -4148,11 +4148,14 @@ paths:
summary: Send a Coach turn (streaming reply)
description: "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is
`text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk
of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{
type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a
provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame
types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and
rate-limited. Auth via cookie or Bearer."
of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion`
(a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{
type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId,
messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?,
completionTokens?, model }`, server-authoritative; clients display it, never recompute), or `error` (`{ type,
code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error`
frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts
a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer."
requestBody:
required: true
content:
@@ -15709,6 +15712,20 @@ components:
- type: string
- type: "null"
description: Coach prompt version that produced the reply; null for user turns.
tokensUsed:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: v1.18.9 — total tokens this assistant turn cost, persisted so the per-message token footer survives a
reload. Null on user turns, refusals, and pre-feature rows.
model:
anyOf:
- type: string
- type: "null"
description: v1.18.9 — the provider model that produced the reply (e.g. gpt-4o). Null when unknown (user turns,
refusals, older rows).
required:
- id
- role
@@ -15717,6 +15734,8 @@ components:
- metricSource
- providerType
- promptVersion
- tokensUsed
- model
additionalProperties: false
description: One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation.
CoachProvenance:
+9 -1
View File
@@ -2269,7 +2269,15 @@
"dictate": "Diktieren",
"dictateStop": "Diktat beenden",
"showConversations": "Unterhaltungen anzeigen",
"hideConversations": "Unterhaltungen ausblenden"
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Wie kann ich dir helfen?",
"heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Nachgedacht für {seconds}s",
"thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat",
"thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.",
"tokensUsed": "{count} Tokens",
"tokensUsedWithModel": "{count} Tokens · {model}"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
+9 -1
View File
@@ -2269,7 +2269,15 @@
"dictate": "Dictate",
"dictateStop": "Stop dictation",
"showConversations": "Show conversations",
"hideConversations": "Hide conversations"
"hideConversations": "Hide conversations",
"heroGreeting": "How can I help you?",
"heroSubline": "Ask about your trends, medications, or readings.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Thought for {seconds}s",
"thinkingExpandAria": "Show what the Coach considered",
"thinkingDetail": "Answered from your health snapshot.",
"tokensUsed": "{count} tokens",
"tokensUsedWithModel": "{count} tokens · {model}"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
+9 -1
View File
@@ -2269,7 +2269,15 @@
"dictate": "Dictar",
"dictateStop": "Detener el dictado",
"showConversations": "Mostrar conversaciones",
"hideConversations": "Ocultar conversaciones"
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "¿En qué puedo ayudarte?",
"heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Pensó durante {seconds}s",
"thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta",
"thinkingDetail": "Respondido a partir de tu resumen de salud.",
"tokensUsed": "{count} tokens",
"tokensUsedWithModel": "{count} tokens · {model}"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
+9 -1
View File
@@ -2269,7 +2269,15 @@
"dictate": "Dicter",
"dictateStop": "Arrêter la dictée",
"showConversations": "Afficher les conversations",
"hideConversations": "Masquer les conversations"
"hideConversations": "Masquer les conversations",
"heroGreeting": "Comment puis-je t'aider ?",
"heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "A réfléchi pendant {seconds}s",
"thinkingExpandAria": "Montrer ce que le Coach a pris en compte",
"thinkingDetail": "Réponse établie à partir de ton aperçu santé.",
"tokensUsed": "{count} jetons",
"tokensUsedWithModel": "{count} jetons · {model}"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
+9 -1
View File
@@ -2269,7 +2269,15 @@
"dictate": "Detta",
"dictateStop": "Interrompi dettatura",
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni"
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Come posso aiutarti?",
"heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Ha riflettuto per {seconds}s",
"thinkingExpandAria": "Mostra cosa ha considerato il Coach",
"thinkingDetail": "Risposta basata sul tuo riepilogo di salute.",
"tokensUsed": "{count} token",
"tokensUsedWithModel": "{count} token · {model}"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
+9 -1
View File
@@ -2269,7 +2269,15 @@
"dictate": "Dyktuj",
"dictateStop": "Zatrzymaj dyktowanie",
"showConversations": "Pokaż rozmowy",
"hideConversations": "Ukryj rozmowy"
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "W czym mogę pomóc?",
"heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Myślał przez {seconds}s",
"thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę",
"thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.",
"tokensUsed": "{count} tokenów",
"tokensUsedWithModel": "{count} tokenów · {model}"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -0,0 +1,14 @@
-- v1.18.9 — per-message Coach token usage + model.
--
-- The streaming chat endpoint already computes the per-turn token count
-- (`result.tokensUsed`) and the model that produced the reply, but until
-- now neither reached the client. Two additive nullable columns let the
-- quiet per-message token footer survive a conversation reload: the live
-- turn paints from the `done.usage` SSE frame, persisted history paints
-- from these columns.
--
-- Both NULL by default. No backfill: existing rows (and every user turn
-- or refusal, which never carries a token count) simply render no footer.
ALTER TABLE "coach_messages"
ADD COLUMN "tokens_used" INTEGER,
ADD COLUMN "model" TEXT;
+5
View File
@@ -3643,6 +3643,11 @@ model CoachMessage {
metricSourceJson String? @map("metric_source_json")
providerType String? @map("provider_type")
promptVersion String? @map("prompt_version")
// v1.18.9 — per-turn token usage + model, persisted so the quiet
// per-message token footer survives a conversation reload. Both
// nullable: user turns, refusals, and pre-feature rows carry NULL.
tokensUsed Int? @map("tokens_used")
model String?
createdAt DateTime @default(now()) @map("created_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
+13
View File
@@ -652,6 +652,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
metricSource: enrichedProvenance,
providerType: workingProviderType,
promptVersion: PROMPT_VERSION,
// v1.18.9 — persist the per-turn token count + model so the quiet
// token footer survives a conversation reload. The live turn paints
// from the `done.usage` SSE frame below; reloads read these columns.
tokensUsed: result.tokensUsed ?? null,
model: result.model ?? null,
});
// v1.18.7 — the day's spend was already reconciled against the
@@ -703,11 +708,19 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }),
);
}
// v1.18.9 — additive `usage` envelope on the `done` frame so the client
// can paint the quiet per-message token footer the instant the stream
// closes. Server-authoritative: the client renders these numbers, never
// recomputes them. Older clients drop the unknown key.
controller.enqueue(
encodeFrame({
type: "done",
conversationId: workingConversationId,
messageId: assistantMessage.id,
usage: {
totalTokens: result.tokensUsed ?? null,
model: result.model ?? null,
},
}),
);
});
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachHero } from "../coach-hero";
import { splitProseSegments } from "../streamed-prose";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<CoachHero>", () => {
it("renders the centred greeting, the composer slot, and the chips", () => {
const html = render(
<CoachHero
composer={<div data-slot="test-composer">composer</div>}
prompts={["What should I tell my doctor?", "Is my medication working?"]}
onPickPrompt={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-hero"');
// Greeting copy from insights.coach.heroGreeting.
expect(html).toContain("How can I help you?");
// The composer is re-parented into the hero, not forked.
expect(html).toContain('data-slot="coach-hero-composer"');
expect(html).toContain('data-slot="test-composer"');
// Both starter-question chips render.
const chips = (html.match(/data-slot="coach-hero-chip"/g) ?? []).length;
expect(chips).toBe(2);
expect(html).toContain("What should I tell my doctor?");
expect(html).toContain("Is my medication working?");
});
it("renders the German greeting under the de locale", () => {
const html = render(
<CoachHero composer={null} prompts={[]} onPickPrompt={() => {}} />,
"de",
);
expect(html).toContain("Wie kann ich dir helfen?");
});
it("omits the chip row when no prompts are supplied", () => {
const html = render(
<CoachHero composer={null} prompts={[]} onPickPrompt={() => {}} />,
);
expect(html).not.toContain('data-slot="coach-hero-chips"');
});
});
describe("splitProseSegments", () => {
it("splits prose into word+trailing-space segments", () => {
expect(splitProseSegments("Looking at your data")).toEqual([
"Looking ",
"at ",
"your ",
"data",
]);
});
it("returns an empty array for empty input", () => {
expect(splitProseSegments("")).toEqual([]);
});
it("keeps a single whitespace-free token intact", () => {
expect(splitProseSegments("Drafting…")).toEqual(["Drafting…"]);
});
});
@@ -60,6 +60,28 @@ import {
type InterleavedThreadItem,
} from "../message-thread";
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
import type { CoachStreamingMessage } from "../use-coach";
// v1.18.9 — `CoachStreamingMessage` gained `usage` / `startedAt` /
// `reasoning` (per-message token footer + thinking disclosure). The
// fixtures below predate those fields; this builder fills the defaults so
// each test only spells out the fields it cares about.
function streaming(
partial: Partial<CoachStreamingMessage>,
): CoachStreamingMessage {
return {
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
usage: null,
startedAt: null,
reasoning: "",
...partial,
};
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
@@ -82,6 +104,8 @@ const baseConversation: CoachConversationDetailDTO = {
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
},
{
id: "m2",
@@ -96,6 +120,8 @@ const baseConversation: CoachConversationDetailDTO = {
},
providerType: "admin-openai",
promptVersion: "4.20.0",
tokensUsed: 312,
model: "gpt-4o",
},
],
};
@@ -143,14 +169,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={baseConversation}
streaming={{
streaming={streaming({
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
// 1 persisted user + 1 persisted assistant + 1 streaming assistant.
@@ -161,21 +187,26 @@ describe("<MessageThread>", () => {
).length;
expect(userBubbles).toBe(1);
expect(assistantBubbles).toBe(2);
expect(html).toContain("Looking at your data");
// v1.18.9 — the live streaming turn renders each word in its own
// fade-in <span> (`<StreamedProse>`), so the prose is no longer one
// contiguous text node; assert each word is present span-wrapped.
for (const word of ["Looking ", "at ", "your ", "data"]) {
expect(html).toContain(`>${word}</span>`);
}
});
it("shows the streaming-only bubble when no conversation is loaded yet", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "Drafting…",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
expect(html).toContain("Drafting…");
@@ -186,14 +217,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
expect(html).toContain("Thinking");
@@ -203,14 +234,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "errorProvider",
}}
})}
/>,
);
// v1.4.33 IW7 — copy rewritten from "AI provider" to "Insights
@@ -237,6 +268,8 @@ describe("<MessageThread>", () => {
metricSource: null,
providerType: "admin-openai",
promptVersion: "4.20.0",
tokensUsed: null,
model: null,
},
],
};
@@ -244,14 +277,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={conv}
streaming={{
streaming={streaming({
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: "m3-streaming",
errorCode: null,
}}
})}
/>,
);
// 1 persisted user + 2 persisted assistant + 0 streaming → 2.
@@ -271,14 +304,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={baseConversation}
streaming={{
streaming={streaming({
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
const assistantBubbles = (
@@ -470,14 +503,14 @@ describe("<MessageThread>", () => {
content: "Wie ist mein Blutdruck letzte Woche?",
conversationId: null,
}}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
expect(html).toContain("Wie ist mein Blutdruck letzte Woche?");
@@ -577,14 +610,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.budget.exceeded",
}}
})}
/>,
);
expect(html).toContain("Daily limit reached; resets at 00:00 UTC.");
@@ -598,14 +631,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.provider.rate_limited",
}}
})}
/>,
);
expect(html).toContain(
@@ -627,14 +660,14 @@ describe("<MessageThread>", () => {
content: "Why was BP higher on Monday?",
conversationId: "conv-1",
}}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
}}
})}
/>,
);
const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? [])
@@ -13,6 +13,7 @@ import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachHero } from "./coach-hero";
import { CoachInput } from "./coach-input";
import {
GuidedQuestionBubble,
@@ -283,6 +284,53 @@ export function CoachConversation({
}),
);
// v1.18.9 — the live composer, lifted so it can mount in the new-chat
// hero (centred) OR docked at the bottom of the conversation without
// forking any composer logic (dictation, auto-grow, send/stop, the
// guided-question placeholder all behave identically in both spots).
const composerNode = (
<CoachInput
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
onCancel={send.cancel}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
autoFocusOnOpen={autoFocusComposer}
placeholder={
guided.phase === "asking"
? t("insights.coach.guided.answerPlaceholder")
: undefined
}
/>
);
// v1.18.9 — the centred new-chat hero replaces the cramped empty thread
// on the PAGE surface only. It shows exactly when the surface is a fresh,
// untouched new chat: no selected conversation, nothing streaming or
// optimistic, no error, and none of the guided / adopt / pending-question
// affordances in play (those belong in the docked conversation flow). The
// first send flips `send.isStreaming` / `send.optimisticUser`, so the hero
// unmounts and the docked thread + bottom composer take over.
const heroActive =
surface === "page" &&
currentConversationId === null &&
!send.isStreaming &&
!send.streaming.content &&
!send.streaming.errorCode &&
!send.optimisticUser &&
guided.phase === "idle" &&
pendingQuestions.length === 0 &&
!pendingAdopt;
// The Coach's own starter questions seed the hero chips (NOT generic
// "create an image" prompts). Same two openers the suggested-prompt strip
// uses elsewhere, resolved here so the hero stays locale-agnostic.
const heroPrompts = [
t("insights.suggestedPrompts.tellMyDoctor"),
t("insights.suggestedPrompts.medicationWorking"),
];
return (
<div
data-slot="coach-conversation"
@@ -358,100 +406,98 @@ export function CoachConversation({
{trailingHeaderActions}
</header>
<CoachDrawerBody
historyOpen={historyRailOpen}
onToggleHistory={
surface === "page"
? () => setHistoryRailOpen((open) => !open)
: undefined
}
historyRail={
surface === "page" ? (
<HistoryRail
activeId={currentConversationId}
// v1.18.1 — the `<aside>` band already renders the
// "Conversations" <h2>; suppress the rail's own <h3> so the
// heading is not stacked twice on the page surface.
hideHeading
onSelect={(id) => {
// v1.16.5 — switching conversations drops the guided
// session; unanswered questions stay pending.
setCurrentConversationId(id);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
{heroActive ? (
<CoachHero
composer={composerNode}
prompts={heroPrompts}
onPickPrompt={(prompt) => handleSubmit(prompt)}
/>
) : (
<CoachDrawerBody
historyOpen={historyRailOpen}
onToggleHistory={
surface === "page"
? () => setHistoryRailOpen((open) => !open)
: undefined
}
historyRail={
surface === "page" ? (
<HistoryRail
activeId={currentConversationId}
// v1.18.1 — the `<aside>` band already renders the
// "Conversations" <h2>; suppress the rail's own <h3> so the
// heading is not stacked twice on the page surface.
hideHeading
onSelect={(id) => {
// v1.16.5 — switching conversations drops the guided
// session; unanswered questions stay pending.
setCurrentConversationId(id);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
/>
) : undefined
}
thread={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
) : undefined
}
thread={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
}
composer={
<div>
{/* v1.16.4 — quiet adopt-into-self-context offer once a
}
composer={
<div>
{/* v1.16.4 — quiet adopt-into-self-context offer once a
clarifying question has been answered. Self-removes
after settle; v1.16.5 reports the outcome back into the
guided machine for the closing summary. */}
{pendingAdopt && !send.isStreaming ? (
<SelfContextAdoptOffer
question={pendingAdopt.question}
answer={pendingAdopt.answer}
onDismiss={() => setPendingAdopt(null)}
onSettled={(adoption) =>
dispatchGuided({
type: "ADOPTION_SETTLED",
index: pendingAdopt.guidedIndex,
adoption,
})
}
/>
) : null}
{/* v1.16.5 — guided clarifying-questions entry card (V2 of
{pendingAdopt && !send.isStreaming ? (
<SelfContextAdoptOffer
question={pendingAdopt.question}
answer={pendingAdopt.answer}
onDismiss={() => setPendingAdopt(null)}
onSettled={(adoption) =>
dispatchGuided({
type: "ADOPTION_SETTLED",
index: pendingAdopt.guidedIndex,
adoption,
})
}
/>
) : null}
{/* v1.16.5 — guided clarifying-questions entry card (V2 of
the v1.16.0 chips). Offers the in-chat sequence while
questions pend and the flow hasn't started. */}
{guided.phase === "idle" && pendingQuestions.length > 0 ? (
<GuidedQuestionsCard
count={pendingQuestions.length}
disabled={send.isStreaming || dismissQuestions.isPending}
onStart={() =>
dispatchGuided({ type: "START", questions: pendingQuestions })
}
onLater={() => dispatchGuided({ type: "LATER" })}
onDismissAll={() =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "LATER" }),
})
}
/>
) : null}
<CoachInput
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
onCancel={send.cancel}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
autoFocusOnOpen={autoFocusComposer}
placeholder={
guided.phase === "asking"
? t("insights.coach.guided.answerPlaceholder")
: undefined
}
/>
</div>
}
onHistoryClick={
surface === "drawer" && onRequestFullView
? onRequestFullView
: () => setHistoryTrayOpen(true)
}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
{guided.phase === "idle" && pendingQuestions.length > 0 ? (
<GuidedQuestionsCard
count={pendingQuestions.length}
disabled={send.isStreaming || dismissQuestions.isPending}
onStart={() =>
dispatchGuided({
type: "START",
questions: pendingQuestions,
})
}
onLater={() => dispatchGuided({ type: "LATER" })}
onDismissAll={() =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "LATER" }),
})
}
/>
) : null}
{composerNode}
</div>
}
onHistoryClick={
surface === "drawer" && onRequestFullView
? onRequestFullView
: () => setHistoryTrayOpen(true)
}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
)}
<MobileRailTray
historyOpen={historyTrayOpen}
@@ -0,0 +1,130 @@
"use client";
import { Quote, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.9 — the Coach new-chat hero (page surface only).
*
* A centred greeting with the composer directly beneath it and a row of
* HealthLog's own starter questions, floated in generous negative space
* over a faint purple atmospheric backdrop. This replaces the cramped
* bottom-pinned empty thread with a calm, ChatGPT/Claude-grade prompt
* surface. On first send the conversation branch takes over and the
* composer docks to the bottom (handled by `<CoachConversation>`).
*
* The hero does NOT fork composer logic: it RECEIVES the live
* `<CoachInput>` as the `composer` slot, so dictation, auto-grow, the
* send/stop control, and the guided-question placeholder all behave
* exactly as in the docked composer. The suggestion chips seed the same
* composer via `onPickPrompt`.
*
* Motion: one orchestrated reveal — sparkle → greeting → composer → chips
* stagger in via `motion-safe` `animate-in` with capped delays;
* reduced-motion users get the layout instantly.
*/
export interface CoachHeroProps {
/** The live `<CoachInput>` composer, re-parented into the hero. */
composer: React.ReactNode;
/** Localised starter prompts shown as chips below the composer. */
prompts: string[];
/** Seeds the composer with a chip's text (the parent submits on pick). */
onPickPrompt: (prompt: string) => void;
}
export function CoachHero({ composer, prompts, onPickPrompt }: CoachHeroProps) {
const { t } = useTranslations();
return (
<div
data-slot="coach-hero"
className="relative flex min-h-0 flex-1 flex-col items-center justify-center overflow-hidden px-4 py-8 sm:px-6"
>
{/* Atmospheric backdrop — a faint purple radial glow behind the
hero, never busy. Pointer-events-none + aria-hidden so it is
purely decorative; it sits behind the content via -z-10. */}
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 -z-10",
"bg-[radial-gradient(60%_50%_at_50%_38%,color-mix(in_srgb,var(--dracula-purple)_14%,transparent),transparent_70%)]",
)}
/>
<div className="flex w-full max-w-2xl flex-col items-center gap-6 text-center">
{/* Sparkle mark */}
<div
aria-hidden="true"
className={cn(
"from-dracula-purple to-dracula-pink flex size-12 items-center justify-center rounded-2xl bg-gradient-to-br shadow-sm",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:duration-500",
)}
>
<Sparkles className="text-background size-6" />
</div>
{/* Greeting — a slightly larger, tighter display treatment of the
existing font (weight/tracking/size only, no new font). */}
<div
className={cn(
"flex flex-col gap-1.5",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
)}
style={{ animationDelay: "80ms", animationFillMode: "both" }}
>
<h1 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{t("insights.coach.heroGreeting")}
</h1>
<p className="text-muted-foreground text-sm">
{t("insights.coach.heroSubline")}
</p>
</div>
{/* Composer — the live <CoachInput>, centred. */}
<div
data-slot="coach-hero-composer"
className={cn(
"w-full",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
)}
style={{ animationDelay: "160ms", animationFillMode: "both" }}
>
{composer}
</div>
{/* Starter-question chips, seeded with the Coach's own prompts. */}
{prompts.length > 0 && (
<div
data-slot="coach-hero-chips"
className={cn(
"flex flex-wrap items-center justify-center gap-2",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-500",
)}
style={{ animationDelay: "240ms", animationFillMode: "both" }}
>
{prompts.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onPickPrompt(prompt)}
data-slot="coach-hero-chip"
className={cn(
"border-dracula-purple/20 hover:border-dracula-purple/45 hover:text-foreground",
"text-muted-foreground inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
"rounded-full border bg-transparent px-3.5 py-1.5 text-xs sm:text-sm",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-dracula-purple/50",
)}
>
<Quote className="size-3 shrink-0" aria-hidden="true" />
<span>{prompt}</span>
</button>
))}
</div>
)}
</div>
</div>
);
}
@@ -20,12 +20,14 @@ import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { useAuth } from "@/hooks/use-auth";
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import { SourceChips } from "./source-chips";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { StreamedProse } from "./streamed-prose";
import { ThinkingDisclosure } from "./thinking-disclosure";
import { MessageTokenFooter } from "./message-token-footer";
import type {
CoachConversationDetailDTO,
CoachOptimisticUserMessage,
@@ -422,6 +424,8 @@ export function MessageThread({
metricSource={m.metricSource}
providerType={m.providerType}
messageId={m.id}
tokensUsed={m.tokensUsed}
model={m.model}
/>
</Fragment>
);
@@ -452,8 +456,10 @@ export function MessageThread({
aria-live="polite"
aria-relevant="additions text"
// v1.18.7 — a soft fade/slide-in on the assistant turn so the
// hand-off from the "Thinking…" beat to the first streamed
// tokens reads as one continuous motion, not a hard swap.
// hand-off from the thinking beat to the first streamed tokens
// reads as one continuous motion, not a hard swap. v1.18.9 — the
// per-word fade now lives in <StreamedProse>; this stays a light
// container fade for the disclosure → prose transition.
className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300"
>
<ChatBubble
@@ -464,6 +470,12 @@ export function MessageThread({
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
// v1.18.9 — live word-fade + the elapsed-time thinking
// disclosure + the just-landed token footer.
streaming
startedAt={streaming.startedAt}
reasoning={streaming.reasoning}
usage={streaming.usage}
/>
</div>
)}
@@ -495,6 +507,35 @@ interface ChatBubbleProps {
* user can't rate before the message lands on disk.
*/
messageId?: string;
/**
* v1.18.9 — true for the live streaming assistant turn. Drives the
* word-by-word prose fade (`<StreamedProse>`); persisted bubbles render
* settled plain text.
*/
streaming?: boolean;
/**
* v1.18.9 — send-anchored timestamp (epoch ms) for the streaming turn,
* feeding the thinking disclosure's elapsed timer. Null on persisted
* bubbles.
*/
startedAt?: number | null;
/**
* v1.18.9 — reasoning-summary text from the additive `reasoning` SSE
* frame, shown inside the thinking disclosure when present.
*/
reasoning?: string;
/**
* v1.18.9 — per-turn token usage for the just-finished streaming bubble
* (from `done.usage`). Persisted bubbles read `tokensUsed` / `model`
* instead.
*/
usage?: import("./use-coach").CoachUsage | null;
/**
* v1.18.9 — persisted per-message token count + model, for the quiet
* token footer on reload. Null on user turns, refusals, and older rows.
*/
tokensUsed?: number | null;
model?: string | null;
}
function ChatBubble({
@@ -506,6 +547,12 @@ function ChatBubble({
inProgress,
errorCode,
messageId,
streaming,
startedAt,
reasoning,
usage,
tokensUsed,
model,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
@@ -631,38 +678,53 @@ function ChatBubble({
)}
</div>
<div className="flex max-w-[calc(80%-2.625rem)] flex-col gap-2">
<div
className={cn(
"border-border/60 bg-muted/40 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed whitespace-pre-wrap",
)}
>
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from
the assistant prose before it lands in the bubble. The
raw `content` is the streamed Coach reply (or the
persisted twin after `done` fires); both paths are AI-
authored so they share the same leak surface as the
insight prose elsewhere.
v1.16.1 — before the first token lands, the bubble shows
a classic typing indicator (three pulsing dots) instead
of the old whole-bubble pulse + "Thinking…" prose. The
prose stays as the screen-reader announcement. */}
{/* v1.16.4 — a failed turn with no streamed prose renders the
error copy INSIDE the bubble instead of an empty bubble
with a caption below it. With partial prose the prose
keeps the bubble and the error stays a caption. */}
{content ? (
stripChartTokens(content)
) : inProgress ? (
<TypingDots label={t("insights.coach.thinking")} />
) : safeError ? (
<span className="text-warning/90">{safeError}</span>
) : (
""
)}
</div>
{/* v1.18.9 — the single thinking indicator (live elapsed-time line,
then an auto-collapsed past-tense disclosure). Renders only for
the live streaming turn — persisted bubbles pass no `startedAt`,
so the disclosure returns null and history stays settled. This
replaces the old <TypingDots> dots-plus-word combo: there is
never a three-dots indicator AND a "Thinking" word at once. */}
{streaming && startedAt != null && (
<ThinkingDisclosure
startedAt={startedAt}
inProgress={!!inProgress}
hasContent={!!content}
reasoning={reasoning}
/>
)}
{/* The prose bubble. Skipped while the live turn is still thinking
with no prose (the disclosure carries the live state above), so
the user never sees an empty bubble under the indicator. The
`<TypingDots>` fallback below only fires when there is no live
disclosure (e.g. a defensive null `startedAt`). */}
{(content ||
safeError ||
(inProgress && !(streaming && startedAt != null))) && (
<div
className={cn(
"border-border/60 bg-muted/40 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed whitespace-pre-wrap",
)}
>
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the
assistant prose. v1.18.9 — the live turn streams in
word-by-word with a soft fade (<StreamedProse>); a settled
/ persisted turn renders as plain text. */}
{/* v1.16.4 — a failed turn with no streamed prose renders the
error copy INSIDE the bubble; with partial prose the prose
keeps the bubble and the error stays a caption. */}
{content ? (
<StreamedProse content={content} streaming={!!streaming} />
) : inProgress ? (
<TypingDots label={t("insights.coach.thinking")} />
) : safeError ? (
<span className="text-warning/90">{safeError}</span>
) : (
""
)}
</div>
)}
{safeError && content && (
<p className="text-warning/90 text-xs">{safeError}</p>
)}
@@ -784,6 +846,16 @@ function ChatBubble({
const sug = suggestion ?? metricSource?.suggestion ?? null;
return sug ? <ReminderSuggestionCard suggestion={sug} /> : null;
})()}
{/* v1.18.9 — quiet per-message token footer. The just-finished
streaming turn reads the `done.usage` envelope; a persisted /
reloaded turn reads the message's own `tokensUsed` + `model`.
Skipped on in-flight, errored, and refusal turns. */}
{!inProgress && !errorCode && providerType !== "refusal" && (
<MessageTokenFooter
tokens={usage?.totalTokens ?? tokensUsed}
model={usage?.model ?? model}
/>
)}
{/* v1.4.23 H7 — per-message thumbs feedback. Only persisted
assistant messages get the row (skipped for refusals,
errors, in-flight stream bubbles). The aggregator buckets
@@ -0,0 +1,37 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.9 — quiet per-message token-usage caption under an assistant
* bubble. Server-authoritative: it renders the count the server reported
* (`done.usage.totalTokens` for the live turn, `CoachMessageDTO.tokensUsed`
* on reload), never a client-side recomputation. No cost — BYOK users pay
* the provider directly and a price table would only drift.
*
* Renders nothing when there is no count (user turns, refusals, in-flight
* bubbles, and pre-feature persisted rows all carry null).
*/
export interface MessageTokenFooterProps {
tokens: number | null | undefined;
/** Provider model name (e.g. `gpt-4o`); appended when present. */
model?: string | null;
}
export function MessageTokenFooter({ tokens, model }: MessageTokenFooterProps) {
const { t } = useTranslations();
if (tokens == null) return null;
const label = model
? t("insights.coach.tokensUsedWithModel", { count: tokens, model })
: t("insights.coach.tokensUsed", { count: tokens });
return (
<p
data-slot="coach-token-footer"
className="text-muted-foreground/80 text-[11px] tabular-nums"
>
{label}
</p>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
/**
* v1.18.9 — Claude-style word-by-word fade-in for the LIVE Coach turn.
*
* The provider clients return the whole reply in one shot; the chat route
* re-chunks it into per-word `token` SSE frames (`tokeniseForStreaming`),
* so `content` grows as frames land. This component renders each
* whitespace segment as its own `<span>` that fades + drifts up on mount,
* giving the reply a "thinking out loud" cadence rather than a hard dump
* of word groups. Once the turn settles (`streaming === false`) the spans
* collapse to a single plain-text node — zero residual animation DOM, and
* the feedback / evidence layout underneath stays stable.
*
* Why no "only animate the new words" bookkeeping: each segment is keyed
* by its index, so when a fresh SSE frame appends words, React keeps the
* already-mounted spans (their CSS entrance already ran and does not
* replay on reconcile) and only MOUNTS the new trailing spans — which is
* exactly when the `animate-in` entrance fires. The browser does the
* "new words only" gating for us; no render-time ref is needed.
*
* CSP-safe: the entrance is Tailwind's `animate-in fade-in-0
* slide-in-from-bottom-1` (compiled utilities served from `'self'`) plus a
* per-span inline `animationDelay` / `animationDuration`. Inline `style`
* attributes are already permitted under the strict CSP
* (`style-src 'self' 'unsafe-inline'`); there is no runtime-injected
* `<style>` tag, no `dangerouslySetInnerHTML`, and no markdown library —
* the text renders as plain React children, the XSS-safe path the rest of
* the Coach uses.
*
* `prefers-reduced-motion`: the animation classes are `motion-safe:`
* gated, so reduced-motion users get the words instantly with no fade and
* no translate.
*/
export interface StreamedProseProps {
/** Raw assistant prose (may contain stray chart-token leaks). */
content: string;
/**
* True while the turn is still streaming. When false the prose renders
* settled as one plain-text node (persisted history + finished turns).
*/
streaming: boolean;
}
/**
* Split prose into word+trailing-whitespace segments. Mirrors the route's
* server-side `tokeniseForStreaming` split (`\S+\s*`) so the client-side
* re-split lines up with how the SSE frames arrived. Exported for tests.
*/
export function splitProseSegments(text: string): string[] {
return text.match(/\S+\s*/g) ?? (text ? [text] : []);
}
export function StreamedProse({ content, streaming }: StreamedProseProps) {
const clean = stripChartTokens(content);
const segments = useMemo(
() => (streaming ? splitProseSegments(clean) : []),
[streaming, clean],
);
if (!streaming) {
// Settled turn: one plain text node, no spans, no animation.
return <>{clean}</>;
}
return (
<>
{segments.map((segment, index) => (
// New trailing spans mount + animate; already-mounted spans keep
// their stable index key and do not replay the entrance.
<span
key={index}
className={cn(
"motion-safe:animate-in motion-safe:fade-in-0",
"motion-safe:slide-in-from-bottom-1",
)}
style={{
animationDuration: "340ms",
// A gentle, capped stagger keyed off the in-batch position so a
// burst of words reads as a soft cascade rather than a single
// flash, without lagging on a long frame.
animationDelay: `${(index % 6) * 26}ms`,
animationFillMode: "both",
}}
>
{segment}
</span>
))}
</>
);
}
@@ -0,0 +1,138 @@
"use client";
import { useEffect, useId, useState } from "react";
import { ChevronRight, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.9 — the ONE Coach thinking indicator on the page surface.
*
* Replaces the old dots-plus-word combo (`<TypingDots>`), which showed
* three bouncing dots AND the word "Thinking…" at the same time — the
* exact pairing the maintainer disliked. This is a single, calm control:
*
* - While the turn is thinking (streaming, no prose yet): a live
* "Thinking… N s" line with a small spinner. `role="status"` so a
* screen reader announces "thinking" once, not on every tick.
* - Once the first token lands OR the turn settles: it relabels to the
* past tense ("Thought for N s") and auto-collapses into a quiet
* `<details>` disclosure. Expanding reveals the reasoning summary when
* a reasoning-capable provider supplied one (the additive `reasoning`
* SSE frame), otherwise a one-line factual fallback — never fabricated
* chain-of-thought.
*
* The elapsed time is computed from the send-anchored `startedAt`
* timestamp: it ticks live while thinking and freezes the instant the
* first token (or the settled state) arrives.
*/
export interface ThinkingDisclosureProps {
/**
* Epoch-ms timestamp the turn started (the moment the send fired). Null
* disables the elapsed timer (the component renders nothing).
*/
startedAt: number | null;
/** True until the turn settles (the `done`/`error` frame closed it). */
inProgress: boolean;
/**
* Whether any assistant prose has streamed in yet. Drives the live →
* past-tense relabel + auto-collapse: the moment the answer starts, the
* "Thinking…" line freezes and folds away.
*/
hasContent: boolean;
/**
* Optional reasoning-summary text from the additive `reasoning` SSE
* frame, rendered inside the expanded disclosure when present.
*/
reasoning?: string;
}
export function ThinkingDisclosure({
startedAt,
inProgress,
hasContent,
reasoning,
}: ThinkingDisclosureProps) {
const { t } = useTranslations();
const panelId = useId();
const [open, setOpen] = useState(false);
// The turn is "thinking" only while it is in progress with no prose yet.
const thinking = inProgress && hasContent === false && startedAt !== null;
// `elapsed` ticks once a second while thinking; the interval stops the
// moment `thinking` flips false, so the last value it set is the frozen
// "thought for N s" figure — no render-time ref, no sync setState in the
// effect body (the first tick lands on the interval, not inline).
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!thinking || startedAt === null) return;
const tick = () =>
setElapsed(Math.max(0, Math.round((Date.now() - startedAt) / 1000)));
tick();
const handle = window.setInterval(tick, 1000);
return () => window.clearInterval(handle);
}, [thinking, startedAt]);
if (startedAt === null) return null;
// ── Live "thinking" line (single indicator, no dots-plus-word) ──
if (thinking) {
return (
<div
data-slot="coach-thinking-live"
role="status"
aria-live="polite"
className="text-muted-foreground inline-flex items-center gap-2 py-0.5 text-xs"
>
<Loader2
aria-hidden="true"
className="text-dracula-purple/70 size-3.5 animate-spin motion-reduce:animate-none"
/>
<span>{t("insights.coach.thinking")}</span>
<span aria-hidden="true" className="tabular-nums opacity-70">
{t("insights.coach.thinkingElapsed", { seconds: elapsed })}
</span>
</div>
);
}
// ── Settled: past-tense collapsible disclosure (auto-collapsed) ──
const reasoningText = reasoning?.trim() ?? "";
return (
<details
data-slot="coach-thinking-disclosure"
open={open}
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
className="group inline-block"
>
<summary
data-slot="coach-thinking-summary"
aria-controls={panelId}
aria-expanded={open}
aria-label={t("insights.coach.thinkingExpandAria")}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex cursor-pointer items-center gap-1 text-xs",
"marker:hidden [&::-webkit-details-marker]:hidden",
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
)}
>
<ChevronRight
aria-hidden="true"
className="size-3 transition-transform group-open:rotate-90"
/>
<span>
{t("insights.coach.thinkingDuration", { seconds: elapsed })}
</span>
</summary>
<p
id={panelId}
data-slot="coach-thinking-detail"
className="text-muted-foreground mt-1.5 pl-4 text-xs leading-relaxed"
>
{reasoningText || t("insights.coach.thinkingDetail")}
</p>
</details>
);
}
@@ -11,6 +11,7 @@ import type {
CoachScope,
CoachStreamEvent,
CoachSuggestion,
CoachUsage,
} from "@/lib/ai/coach/types";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
@@ -191,6 +192,26 @@ export interface CoachStreamingMessage {
messageId: string | null;
/** Server-side error code from an `error` frame, if any. */
errorCode: string | null;
/**
* v1.18.9 — per-turn token usage from the additive `done.usage` frame.
* Null until `done` lands (or when the provider returned no count). The
* thread's token footer reads this for the just-finished streaming
* bubble; persisted bubbles read `CoachMessageDTO.tokensUsed` instead.
*/
usage: CoachUsage | null;
/**
* v1.18.9 — epoch-ms timestamp the send fired, so the thinking
* disclosure can show a live "thinking for N s" counter that freezes
* to a past-tense summary once the first token lands. Null before a
* turn starts.
*/
startedAt: number | null;
/**
* v1.18.9 — optional reasoning-summary text from the additive
* `reasoning` frame, rendered inside the thinking disclosure when a
* reasoning-capable provider emits it. Empty string when none.
*/
reasoning: string;
}
/**
@@ -231,6 +252,9 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
inProgress: false,
messageId: null,
errorCode: null,
usage: null,
startedAt: null,
reasoning: "",
};
export interface SendCoachMessageParams {
@@ -324,6 +348,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: true,
messageId: null,
errorCode: null,
usage: null,
// v1.18.9 — anchor the thinking-disclosure elapsed timer to the
// moment the send fired.
startedAt: Date.now(),
reasoning: "",
});
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
@@ -337,6 +366,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: "coach.network",
usage: null,
startedAt: null,
reasoning: "",
});
return null;
}
@@ -370,6 +402,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: "coach.network",
usage: null,
startedAt: null,
reasoning: "",
});
return null;
}
@@ -382,6 +417,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: `coach.http.${response.status}`,
usage: null,
startedAt: null,
reasoning: "",
});
return null;
}
@@ -419,6 +457,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: structured ?? `coach.http.${response.status}`,
usage: null,
startedAt: null,
reasoning: "",
});
// v1.16.4 — KEEP the optimistic user bubble: a rejected turn
// (budget gate, 4xx) has no persisted twin coming, and dropping
@@ -437,6 +478,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
let collectedSuggestion: CoachSuggestion | null = null;
let lastError: string | null = null;
let messageId: string | null = null;
let collectedUsage: CoachUsage | null = null;
let collectedReasoning = "";
try {
while (true) {
@@ -468,9 +511,21 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
suggestion: evt.suggestion,
}));
break;
case "reasoning":
// v1.18.9 — additive reasoning-summary frame. Append so
// multiple chunks accumulate; the disclosure renders it
// when present, else falls back to the elapsed-time label.
collectedReasoning += evt.text;
setStreaming((prev) => ({
...prev,
reasoning: prev.reasoning + evt.text,
}));
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;
@@ -484,8 +539,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
if (evt.type === "done") {
resolvedConversationId = evt.conversationId;
messageId = evt.messageId;
collectedUsage = evt.usage ?? null;
} else if (evt.type === "error") {
lastError = evt.code;
} else if (evt.type === "reasoning") {
collectedReasoning += evt.text;
}
}
} catch (err) {
@@ -494,14 +552,20 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
}
}
setStreaming({
setStreaming((prev) => ({
content: collectedContent,
metricSource: collectedProvenance,
suggestion: collectedSuggestion,
inProgress: false,
messageId,
errorCode: lastError,
});
usage: collectedUsage,
// v1.18.9 — keep the send-anchored timestamp so the disclosure can
// freeze the elapsed time to a past-tense "thought for N s" once
// the turn settles.
startedAt: prev.startedAt,
reasoning: collectedReasoning,
}));
if (resolvedConversationId) {
// Invalidate the freshly-persisted conversation + the rail so
@@ -550,4 +614,5 @@ export type {
CoachConversationDetailDTO,
CoachProvenance,
CoachSuggestion,
CoachUsage,
};
+13
View File
@@ -128,6 +128,13 @@ export interface AppendMessageParams {
metricSource?: CoachProvenance | null;
providerType?: string | null;
promptVersion?: string | null;
/**
* v1.18.9 — per-turn token count + model the reply was produced with,
* persisted so the quiet token footer survives a reload. Omitted on
* user turns and refusals (no token count to record).
*/
tokensUsed?: number | null;
model?: string | null;
}
/**
@@ -168,6 +175,8 @@ export async function appendMessage(
metricSourceJson: provenanceToJson(params.metricSource ?? null),
providerType: params.providerType ?? null,
promptVersion: params.promptVersion ?? null,
tokensUsed: params.tokensUsed ?? null,
model: params.model ?? null,
},
});
await tx.coachConversation.update({
@@ -185,6 +194,8 @@ export async function appendMessage(
metricSource: provenanceFromJson(result.metricSourceJson),
providerType: result.providerType,
promptVersion: result.promptVersion,
tokensUsed: result.tokensUsed,
model: result.model,
};
}
@@ -270,6 +281,8 @@ export async function fetchConversationWithMessages(
metricSource: provenanceFromJson(m.metricSourceJson),
providerType: m.providerType,
promptVersion: m.promptVersion,
tokensUsed: m.tokensUsed,
model: m.model,
}));
// v1.11.1 — decrypt the rolling conversation summary (fail-closed: an
+44 -1
View File
@@ -165,6 +165,21 @@ export interface CoachSuggestion {
label: string;
}
/**
* v1.18.9 — per-turn token-usage envelope carried on the `done` frame
* (and persisted onto the assistant message). Server-authoritative: the
* client renders these numbers, never recomputes them. `totalTokens` is
* the headline count surfaced in the quiet per-message footer; the
* prompt / completion split is optional (not every provider returns it)
* and `model` names the provider model that produced the reply.
*/
export interface CoachUsage {
totalTokens: number | null;
promptTokens?: number | null;
completionTokens?: number | null;
model?: string | null;
}
/**
* SSE event shapes emitted by the streaming endpoint.
*
@@ -173,6 +188,15 @@ export interface CoachSuggestion {
* The `suggestion` frame (v1.18.1) is additive: older web + iOS clients
* that don't know it drop it on the floor (the parser keeps only frames
* whose `type` it handles), so the chat stays backwards-compatible.
*
* v1.18.9 — two additive shapes:
* - `done.usage` carries the per-turn `CoachUsage` (tokens + model) so
* the client can paint the quiet per-message footer the instant the
* stream closes; older clients ignore the extra key.
* - `reasoning` is an optional frame whose `text` the client renders
* inside the thinking disclosure when a reasoning-capable provider
* emits a cheap reasoning summary. Providers without reasoning emit
* none, and the disclosure falls back to its elapsed-time label.
*/
export type CoachStreamEvent =
| { type: "token"; token: string }
@@ -181,7 +205,13 @@ export type CoachStreamEvent =
metricSource: CoachProvenance;
}
| { type: "suggestion"; suggestion: CoachSuggestion }
| { type: "done"; conversationId: string; messageId: string }
| { type: "reasoning"; text: string }
| {
type: "done";
conversationId: string;
messageId: string;
usage?: CoachUsage;
}
| { type: "error"; code: string; message: string };
/**
@@ -335,6 +365,19 @@ export interface CoachMessageDTO {
metricSource: CoachProvenance | null;
providerType: string | null;
promptVersion: string | null;
/**
* v1.18.9 — total tokens the assistant turn cost, persisted so the
* quiet per-message footer survives a conversation reload. Null on
* older messages (pre-feature) and on user turns / refusals where no
* token count was recorded.
*/
tokensUsed: number | null;
/**
* v1.18.9 — the provider model that produced the reply (e.g.
* `gpt-4o`), persisted alongside `tokensUsed` for the footer. Null
* when unknown (user turns, refusals, older rows).
*/
model: string | null;
}
export interface CoachConversationDetailDTO extends CoachConversationDTO {
+14 -1
View File
@@ -307,6 +307,19 @@ const coachMessageSchema = z
.describe(
"Coach prompt version that produced the reply; null for user turns.",
),
tokensUsed: z
.number()
.int()
.nullable()
.describe(
"v1.18.9 — total tokens this assistant turn cost, persisted so the per-message token footer survives a reload. Null on user turns, refusals, and pre-feature rows.",
),
model: z
.string()
.nullable()
.describe(
"v1.18.9 — the provider model that produced the reply (e.g. gpt-4o). Null when unknown (user turns, refusals, older rows).",
),
})
.meta({
id: "CoachMessage",
@@ -425,7 +438,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Send a Coach turn (streaming reply)",
description:
"v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{ type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.",
"v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.",
requestBody: {
required: true,
content: {