diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 08e540df6..c04a3b54e 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: @@ -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: diff --git a/messages/de.json b/messages/de.json index 4f8a119fb..b00f8c9fb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 9114b7e6a..c8691a6b4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index d7c7572ed..1d3941800 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 0b90df231..f68ca0281 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 1fc9b1b3b..b3eff405c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 7769f7cb0..81cdac560 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", 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__/coach-hero.test.tsx b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx new file mode 100644 index 000000000..98b2dd6af --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx @@ -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( + {node}, + ); +} + +describe("", () => { + it("renders the centred greeting, the composer slot, and the chips", () => { + const html = render( + composer} + 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( + {}} />, + "de", + ); + expect(html).toContain("Wie kann ich dir helfen?"); + }); + + it("omits the chip row when no prompts are supplied", () => { + const html = render( + {}} />, + ); + 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…"]); + }); +}); 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..aa20016d2 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. @@ -161,21 +187,26 @@ 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", () => { const html = render( , ); expect(html).toContain("Drafting…"); @@ -186,14 +217,14 @@ describe("", () => { const html = render( , ); expect(html).toContain("Thinking"); @@ -203,14 +234,14 @@ describe("", () => { const html = render( , ); // v1.4.33 IW7 — copy rewritten from "AI provider" to "Insights @@ -237,6 +268,8 @@ describe("", () => { metricSource: null, providerType: "admin-openai", promptVersion: "4.20.0", + tokensUsed: null, + model: null, }, ], }; @@ -244,14 +277,14 @@ describe("", () => { const html = render( , ); // 1 persisted user + 2 persisted assistant + 0 streaming → 2. @@ -271,14 +304,14 @@ describe("", () => { const html = render( , ); const assistantBubbles = ( @@ -470,14 +503,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 +610,14 @@ describe("", () => { const html = render( , ); expect(html).toContain("Daily limit reached; resets at 00:00 UTC."); @@ -598,14 +631,14 @@ describe("", () => { const html = render( , ); expect(html).toContain( @@ -627,14 +660,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/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index a6f785ab6..f5107c9f0 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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 = ( + 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 (
- setHistoryRailOpen((open) => !open) - : undefined - } - historyRail={ - surface === "page" ? ( - ` band already renders the - // "Conversations"

; suppress the rail's own

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 ? ( + handleSubmit(prompt)} + /> + ) : ( + setHistoryRailOpen((open) => !open) + : undefined + } + historyRail={ + surface === "page" ? ( + ` band already renders the + // "Conversations"

; suppress the rail's own

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={ + - ) : undefined - } - thread={ - - } - composer={ -
- {/* v1.16.4 — quiet adopt-into-self-context offer once a + } + composer={ +
+ {/* 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 ? ( - 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 ? ( + 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 ? ( - - dispatchGuided({ type: "START", questions: pendingQuestions }) - } - onLater={() => dispatchGuided({ type: "LATER" })} - onDismissAll={() => - dismissQuestions.mutate(undefined, { - onSuccess: () => dispatchGuided({ type: "LATER" }), - }) - } - /> - ) : null} - handleSubmit(inputValue)} - onCancel={send.cancel} - disabled={send.isStreaming} - isStreaming={send.isStreaming} - autoFocusOnOpen={autoFocusComposer} - placeholder={ - guided.phase === "asking" - ? t("insights.coach.guided.answerPlaceholder") - : undefined - } - /> -
- } - onHistoryClick={ - surface === "drawer" && onRequestFullView - ? onRequestFullView - : () => setHistoryTrayOpen(true) - } - onOpenSourcesTray={() => setSourcesTrayOpen(true)} - /> + {guided.phase === "idle" && pendingQuestions.length > 0 ? ( + + dispatchGuided({ + type: "START", + questions: pendingQuestions, + }) + } + onLater={() => dispatchGuided({ type: "LATER" })} + onDismissAll={() => + dismissQuestions.mutate(undefined, { + onSuccess: () => dispatchGuided({ type: "LATER" }), + }) + } + /> + ) : null} + {composerNode} +
+ } + onHistoryClick={ + surface === "drawer" && onRequestFullView + ? onRequestFullView + : () => setHistoryTrayOpen(true) + } + onOpenSourcesTray={() => setSourcesTrayOpen(true)} + /> + )} `). + * + * The hero does NOT fork composer logic: it RECEIVES the live + * `` 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 `` 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 ( +
+ {/* 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. */} + + ); +} 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 + * `