From f46e9d3bb83db1b7ea436e4c5b31f2f295032946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 11:58:58 +0200 Subject: [PATCH 1/3] feat(coach): surface per-turn token usage on the chat stream Carry an additive usage envelope on the SSE done frame ({ totalTokens, model }) and persist tokensUsed + model onto coach_messages (migration 0182, nullable, no backfill) so the per-message token footer survives a conversation reload. Add an optional reasoning frame the client can render inside the thinking disclosure when a reasoning-capable provider emits a summary; providers without reasoning emit none. Extend CoachMessageDTO and regenerate the OpenAPI contract. Server-authoritative: clients display the count, never recompute it. --- docs/api/openapi.yaml | 29 ++++++-- .../migration.sql | 14 ++++ prisma/schema.prisma | 5 ++ src/app/api/insights/chat/route.ts | 13 ++++ .../__tests__/message-thread.test.tsx | 68 ++++++++++++------ .../insights/coach-panel/use-coach.ts | 69 ++++++++++++++++++- src/lib/ai/coach/persistence.ts | 13 ++++ src/lib/ai/coach/types.ts | 45 +++++++++++- src/lib/openapi/routes/coach.ts | 15 +++- 9 files changed, 242 insertions(+), 29 deletions(-) create mode 100644 prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ecb970d7..f0db8ff6a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: \\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: @@ -15702,6 +15705,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 @@ -15710,6 +15727,8 @@ components: - metricSource - providerType - promptVersion + - tokensUsed + - model additionalProperties: false description: One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation. CoachProvenance: diff --git a/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql b/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql new file mode 100644 index 000000000..14d9a4c88 --- /dev/null +++ b/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index deeb77edf..480c5eaa2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 88d04749b..ea8907ad7 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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, + }, }), ); }); 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 cae800634..1e650e5db 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -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 { + 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("", () => { const html = render( , ); // 1 persisted user + 1 persisted assistant + 1 streaming assistant. @@ -168,14 +194,14 @@ describe("", () => { const html = render( , ); expect(html).toContain("Drafting…"); @@ -186,14 +212,14 @@ describe("", () => { const html = render( , ); expect(html).toContain("Thinking"); @@ -203,14 +229,14 @@ describe("", () => { const html = render( , ); // v1.4.33 IW7 — copy rewritten from "AI provider" to "Insights @@ -237,6 +263,8 @@ describe("", () => { metricSource: null, providerType: "admin-openai", promptVersion: "4.20.0", + tokensUsed: null, + model: null, }, ], }; @@ -244,14 +272,14 @@ describe("", () => { const html = render( , ); // 1 persisted user + 2 persisted assistant + 0 streaming → 2. @@ -271,14 +299,14 @@ describe("", () => { const html = render( , ); const assistantBubbles = ( @@ -470,14 +498,14 @@ describe("", () => { 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 +605,14 @@ describe("", () => { const html = render( , ); expect(html).toContain("Daily limit reached; resets at 00:00 UTC."); @@ -598,14 +626,14 @@ describe("", () => { const html = render( , ); expect(html).toContain( @@ -627,14 +655,14 @@ describe("", () => { 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) ?? []) diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 11c462516..7c59eb0e0 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -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, }; diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 827dcc63c..dfe7bef9a 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -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 diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 7d50c42ae..13c19b255 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -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 { diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index efa0a6ed5..8a091018d 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -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 = { 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: \\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: \\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: { From 88ee5bb4f81bc1c25a591db8c7a9cadfa88144f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 12:05:31 +0200 Subject: [PATCH 2/3] feat(coach): word-fade stream, thinking disclosure, token footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream the assistant turn word-by-word with a soft motion-safe fade (StreamedProse), reduced-motion collapses to instant. Replace the dots-plus-word placeholder with a single thinking indicator: a live elapsed-time line that freezes into an auto-collapsed past-tense disclosure, which surfaces a reasoning summary when one is present and a factual fallback otherwise — never both a spinner and a Thinking word at once. Add a quiet per-message token footer (count plus model, no cost) fed by the live done.usage frame and persisted tokensUsed on reload. Add the localised strings across all six bundles. --- messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/pl.json | 10 +- .../__tests__/message-thread.test.tsx | 7 +- .../insights/coach-panel/message-thread.tsx | 142 +++++++++++++----- .../coach-panel/message-token-footer.tsx | 37 +++++ .../insights/coach-panel/streamed-prose.tsx | 96 ++++++++++++ .../coach-panel/thinking-disclosure.tsx | 138 +++++++++++++++++ 11 files changed, 438 insertions(+), 42 deletions(-) create mode 100644 src/components/insights/coach-panel/message-token-footer.tsx create mode 100644 src/components/insights/coach-panel/streamed-prose.tsx create mode 100644 src/components/insights/coach-panel/thinking-disclosure.tsx diff --git a/messages/de.json b/messages/de.json index 535b44c94..6c931e2e5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2268,7 +2268,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", diff --git a/messages/en.json b/messages/en.json index b2372522b..838014b3b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2268,7 +2268,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", diff --git a/messages/es.json b/messages/es.json index be2580372..f3837a403 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2268,7 +2268,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", diff --git a/messages/fr.json b/messages/fr.json index 4cbe3b34f..dc8e2b96d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2268,7 +2268,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", diff --git a/messages/it.json b/messages/it.json index 5866e29ba..c76899edb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2268,7 +2268,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", diff --git a/messages/pl.json b/messages/pl.json index 1acef823f..5c14f1f0e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2268,7 +2268,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", 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 1e650e5db..aa20016d2 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -187,7 +187,12 @@ describe("", () => { ).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 (``), 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}`); + } }); it("shows the streaming-only bubble when no conversation is loaded yet", () => { diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 65d26a33b..add50de79 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -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} /> ); @@ -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 ; 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" > )} @@ -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 (``); 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({ )}
-
- {/* 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 ? ( - - ) : safeError ? ( - {safeError} - ) : ( - "" - )} -
+ {/* 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 dots-plus-word combo: there is + never a three-dots indicator AND a "Thinking" word at once. */} + {streaming && startedAt != null && ( + + )} + {/* 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 + `` fallback below only fires when there is no live + disclosure (e.g. a defensive null `startedAt`). */} + {(content || + safeError || + (inProgress && !(streaming && startedAt != null))) && ( +
+ {/* 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 (); 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 ? ( + + ) : inProgress ? ( + + ) : safeError ? ( + {safeError} + ) : ( + "" + )} +
+ )} {safeError && content && (

{safeError}

)} @@ -784,6 +846,16 @@ function ChatBubble({ const sug = suggestion ?? metricSource?.suggestion ?? null; return 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" && ( + + )} {/* 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 diff --git a/src/components/insights/coach-panel/message-token-footer.tsx b/src/components/insights/coach-panel/message-token-footer.tsx new file mode 100644 index 000000000..f6b17fe6c --- /dev/null +++ b/src/components/insights/coach-panel/message-token-footer.tsx @@ -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 ( +

+ {label} +

+ ); +} diff --git a/src/components/insights/coach-panel/streamed-prose.tsx b/src/components/insights/coach-panel/streamed-prose.tsx new file mode 100644 index 000000000..370d70a43 --- /dev/null +++ b/src/components/insights/coach-panel/streamed-prose.tsx @@ -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 `` 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 + * `