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