mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
merge: W5 coach panel UI (paragraphs, TTS, charts, deep-link) into v1.22
This commit is contained in:
commit
5594d67900
14 files changed
+980
-61
No files matched your search
@@ -16654,9 +16654,16 @@ components:
|
||||
unread:
|
||||
type: boolean
|
||||
description: True when that message is newer than the last time the caller opened the Coach.
|
||||
conversationId:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Conversation holding the newest assistant message; null when none exists. The client deep-links into it
|
||||
(`/coach?c=<id>`) on the unread path.
|
||||
required:
|
||||
- nudgedAt
|
||||
- unread
|
||||
- conversationId
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
|
||||
@@ -2099,6 +2099,9 @@
|
||||
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
|
||||
"nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen",
|
||||
"fabLabel": "Coach öffnen",
|
||||
"readAloud": "Vorlesen",
|
||||
"readAloudStop": "Vorlesen beenden",
|
||||
"messageTimeLabel": "Nachricht gesendet {time}",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Antwort in deine Selbstauskunft übernehmen?",
|
||||
"confirm": "Übernehmen",
|
||||
|
||||
@@ -2099,6 +2099,9 @@
|
||||
"composerPlaceholder": "Ask anything about your data…",
|
||||
"nudgeBubbleLabel": "New message from the Coach — open chat",
|
||||
"fabLabel": "Open the Coach",
|
||||
"readAloud": "Read aloud",
|
||||
"readAloudStop": "Stop reading",
|
||||
"messageTimeLabel": "Message sent {time}",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Add this answer to your self-context?",
|
||||
"confirm": "Add",
|
||||
|
||||
@@ -39,7 +39,11 @@ export const GET = apiHandler(async () => {
|
||||
prisma.coachMessage.findFirst({
|
||||
where: { role: "assistant", conversation: { userId: user.id } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
// v1.22 (W5) — also return the owning conversation id so the FAB can
|
||||
// deep-link straight into the proactive thread (`/coach?c=<id>`)
|
||||
// instead of opening a blank new chat and relying on a most-recent
|
||||
// heuristic to find it.
|
||||
select: { createdAt: true, conversationId: true },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
@@ -55,6 +59,10 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess({
|
||||
nudgedAt: nudgedAt ? nudgedAt.toISOString() : null,
|
||||
unread,
|
||||
// The conversation holding the newest assistant message. Present
|
||||
// whenever a Coach reply exists; the FAB only consumes it on the
|
||||
// unread path.
|
||||
conversationId: lastAssistant?.conversationId ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -171,20 +171,42 @@ describe("isNudgeUnread", () => {
|
||||
|
||||
it("is false without status or without a nudge", () => {
|
||||
expect(isNudgeUnread(undefined, null)).toBe(false);
|
||||
expect(isNudgeUnread({ nudgedAt: null, unread: false }, null)).toBe(false);
|
||||
expect(
|
||||
isNudgeUnread(
|
||||
{ nudgedAt: null, unread: false, conversationId: null },
|
||||
null,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true for a server-unread nudge the device has not seen", () => {
|
||||
expect(isNudgeUnread({ nudgedAt, unread: true }, null)).toBe(true);
|
||||
expect(isNudgeUnread({ nudgedAt, unread: true }, "2026-01-01")).toBe(true);
|
||||
expect(
|
||||
isNudgeUnread({ nudgedAt, unread: true, conversationId: "conv-1" }, null),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isNudgeUnread(
|
||||
{ nudgedAt, unread: true, conversationId: "conv-1" },
|
||||
"2026-01-01",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false once the server counts the nudge as read", () => {
|
||||
// The user sent a Coach message after the nudge.
|
||||
expect(isNudgeUnread({ nudgedAt, unread: false }, null)).toBe(false);
|
||||
expect(
|
||||
isNudgeUnread(
|
||||
{ nudgedAt, unread: false, conversationId: "conv-1" },
|
||||
null,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false once this device stored the matching seen stamp", () => {
|
||||
expect(isNudgeUnread({ nudgedAt, unread: true }, nudgedAt)).toBe(false);
|
||||
expect(
|
||||
isNudgeUnread(
|
||||
{ nudgedAt, unread: true, conversationId: "conv-1" },
|
||||
nudgedAt,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
ProseBlocks,
|
||||
splitParagraphs,
|
||||
} from "@/components/insights/prose-blocks";
|
||||
import {
|
||||
StreamedProse,
|
||||
splitProseSegments,
|
||||
} from "@/components/insights/coach-panel/streamed-prose";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("splitParagraphs", () => {
|
||||
it("splits on blank lines and trims, dropping empties", () => {
|
||||
expect(splitParagraphs("one\n\ntwo")).toEqual(["one", "two"]);
|
||||
expect(splitParagraphs(" a \n\n\n b ")).toEqual(["a", "b"]);
|
||||
expect(splitParagraphs("solo")).toEqual(["solo"]);
|
||||
expect(splitParagraphs("\n\n \n\n")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a single newline inside one paragraph", () => {
|
||||
expect(splitParagraphs("line1\nline2")).toEqual(["line1\nline2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProseBlocks", () => {
|
||||
it("renders blank-line-separated text as real <p> blocks", () => {
|
||||
const html = render(<ProseBlocks text={"First idea.\n\nSecond idea."} />);
|
||||
const paras = (html.match(/<p\b/g) ?? []).length;
|
||||
expect(paras).toBe(2);
|
||||
expect(html).toContain("First idea.");
|
||||
expect(html).toContain("Second idea.");
|
||||
});
|
||||
|
||||
it("turns a single newline into a <br/>", () => {
|
||||
const html = render(<ProseBlocks text={"line a\nline b"} />);
|
||||
expect(html).toContain("<br/>");
|
||||
expect((html.match(/<p\b/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("strips stray chart tokens from the prose by default", () => {
|
||||
const html = render(
|
||||
<ProseBlocks text={"Your weight metric:WEIGHT held."} />,
|
||||
);
|
||||
expect(html).not.toContain("metric:WEIGHT");
|
||||
expect(html).toContain("Your weight");
|
||||
});
|
||||
|
||||
it("linkifies a catalog-known /learn/<slug> reference as a safe anchor", () => {
|
||||
const html = render(
|
||||
<ProseBlocks
|
||||
text={"More on this: https://healthlog.dev/learn/resting-heart-rate"}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="inline-learn-link"');
|
||||
expect(html).toContain("/learn/resting-heart-rate");
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
|
||||
it("leaves an unknown /learn slug as plain text (fail-closed)", () => {
|
||||
const html = render(
|
||||
<ProseBlocks text={"see https://healthlog.dev/learn/totally-made-up"} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="inline-learn-link"');
|
||||
expect(html).toContain("/learn/totally-made-up");
|
||||
});
|
||||
|
||||
it("does not strip or linkify when both are disabled (user text)", () => {
|
||||
const html = render(
|
||||
<ProseBlocks
|
||||
text={"keep metric:WEIGHT and /learn/resting-heart-rate verbatim"}
|
||||
strip={false}
|
||||
linkify={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("metric:WEIGHT");
|
||||
expect(html).not.toContain('data-slot="inline-learn-link"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("StreamedProse", () => {
|
||||
it("settles to real paragraph blocks when not streaming", () => {
|
||||
const html = render(
|
||||
<StreamedProse
|
||||
content={"Where things stand.\n\nOne thing to try."}
|
||||
streaming={false}
|
||||
/>,
|
||||
);
|
||||
expect((html.match(/<p\b/g) ?? []).length).toBe(2);
|
||||
expect(html).toContain("Where things stand.");
|
||||
expect(html).toContain("One thing to try.");
|
||||
});
|
||||
|
||||
it("animates only the growing tail paragraph while streaming", () => {
|
||||
const html = render(
|
||||
<StreamedProse
|
||||
content={"Settled paragraph.\n\nGrowing tail words"}
|
||||
streaming
|
||||
/>,
|
||||
);
|
||||
// The completed first paragraph settles as plain text (no spans).
|
||||
expect(html).toContain("Settled paragraph.");
|
||||
// The tail renders each word in its own fade-in span.
|
||||
for (const word of ["Growing ", "tail ", "words"]) {
|
||||
expect(html).toContain(`>${word}</span>`);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not leak a chart token into streamed prose", () => {
|
||||
const html = render(
|
||||
<StreamedProse content={"Trending up metric:WEIGHT"} streaming />,
|
||||
);
|
||||
expect(html).not.toContain("metric:WEIGHT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitProseSegments", () => {
|
||||
it("keeps each word with its trailing whitespace", () => {
|
||||
expect(splitProseSegments("a b c")).toEqual(["a ", "b ", "c"]);
|
||||
expect(splitProseSegments("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// Mirror the message-thread test harness: SSR render with no providers, so
|
||||
// stub the hooks the bubble reaches for.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "tester", role: "USER", avatarUrl: null },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: undefined }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
|
||||
}));
|
||||
|
||||
import { MessageThread, selectCoachChartTokens } from "../message-thread";
|
||||
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("selectCoachChartTokens", () => {
|
||||
it("returns an allowlisted token grounded in the turn's provenance", () => {
|
||||
expect(
|
||||
selectCoachChartTokens("The trend metric:WEIGHT is up", ["weight"]),
|
||||
).toEqual(["metric:WEIGHT"]);
|
||||
});
|
||||
|
||||
it("drops a token whose metric is not in the grounded provenance", () => {
|
||||
expect(selectCoachChartTokens("metric:WEIGHT", [])).toEqual([]);
|
||||
expect(selectCoachChartTokens("metric:WEIGHT", ["pulse"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a hallucinated / non-allowlisted token", () => {
|
||||
expect(selectCoachChartTokens("metric:NUKE", ["weight"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits synthetic tokens without a self-fetching chart (MOOD)", () => {
|
||||
expect(selectCoachChartTokens("metric:MOOD", ["mood"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("de-duplicates by metric and caps the number rendered", () => {
|
||||
const tokens = selectCoachChartTokens(
|
||||
"metric:WEIGHT metric:WEIGHT metric:PULSE metric:BLOOD_GLUCOSE",
|
||||
["weight", "pulse", "glucose"],
|
||||
);
|
||||
expect(tokens).toEqual(["metric:WEIGHT", "metric:PULSE"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coach chart rendering", () => {
|
||||
const conversation: CoachConversationDetailDTO = {
|
||||
id: "c1",
|
||||
title: "Test",
|
||||
createdAt: "2026-06-01T10:00:00.000Z",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
messageCount: 1,
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
content: "Your weight is trending down. metric:WEIGHT",
|
||||
createdAt: "2026-06-01T10:00:00.000Z",
|
||||
metricSource: { windows: ["last30days"], metrics: ["weight"] },
|
||||
providerType: "openai",
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("mounts a chart for a grounded token and strips it from the prose", () => {
|
||||
const html = render(<MessageThread conversation={conversation} />);
|
||||
expect(html).toContain('data-slot="coach-charts"');
|
||||
expect(html).toContain('data-slot="coach-chart"');
|
||||
// The literal token must never surface in the rendered prose.
|
||||
expect(html).not.toContain("metric:WEIGHT");
|
||||
expect(html).toContain("Your weight is trending down");
|
||||
});
|
||||
|
||||
it("renders no chart when the token is not grounded in provenance", () => {
|
||||
const ungrounded: CoachConversationDetailDTO = {
|
||||
...conversation,
|
||||
messages: [
|
||||
{
|
||||
...conversation.messages[0],
|
||||
metricSource: { windows: [], metrics: ["pulse"] },
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(<MessageThread conversation={ungrounded} />);
|
||||
expect(html).not.toContain('data-slot="coach-chart"');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -9,19 +18,30 @@ import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
User,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
|
||||
import {
|
||||
parseChartTokens,
|
||||
stripChartTokens,
|
||||
tokenToMetric,
|
||||
type ChartToken,
|
||||
} from "@/lib/insights/chart-tokens";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
|
||||
import { SourceChips } from "./source-chips";
|
||||
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
|
||||
@@ -32,7 +52,10 @@ import type {
|
||||
CoachOptimisticUserMessage,
|
||||
CoachStreamingMessage,
|
||||
} from "./use-coach";
|
||||
import type { CoachMessageDTO } from "@/lib/ai/coach/types";
|
||||
import type {
|
||||
CoachMessageDTO,
|
||||
CoachProvenanceMetric,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B2b — message-thread renderer.
|
||||
@@ -209,6 +232,282 @@ export function errorCodeToI18nKey(code: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — Coach accompanying charts, Phase 1: the renderer half.
|
||||
*
|
||||
* The chart-token mechanism (`chart-tokens.ts`) was prepared for the
|
||||
* Insights prose but its render path was never wired for the Coach —
|
||||
* `stripChartTokens` cleaned tokens out of the prose, but `parseChartTokens`
|
||||
* had no caller, so a `metric:<TYPE>` token the model emitted rendered no
|
||||
* chart. This map activates the render half for the Coach.
|
||||
*
|
||||
* Each entry pairs an allowlisted chart token with the Coach provenance
|
||||
* TOPIC that the snapshot stamps when it actually drew on that metric. A
|
||||
* chart only renders when its topic is present in `metricSource.metrics`, so
|
||||
* the series is grounded twice over: the closed allowlist drops a
|
||||
* hallucinated token, and the provenance intersect drops a metric the turn
|
||||
* never saw (and which therefore has no data). The chart itself self-fetches
|
||||
* the user's real series from `/api/measurements` — the model never emits a
|
||||
* data point.
|
||||
*
|
||||
* Only MeasurementType-backed tokens that render through the generic,
|
||||
* self-fetching `<HealthChart>` are listed. The synthetic `metric:MOOD`
|
||||
* token (served by a separate `<MoodChart>`) and the allowlist's reserved
|
||||
* score classes are intentionally omitted from Phase 1.
|
||||
*
|
||||
* The prompt clause that tells the model it MAY emit one such token is a
|
||||
* separate concern (the narrative/prompt workstream); this is render-only,
|
||||
* provider-agnostic (it reads the plain inline token, so it works for every
|
||||
* provider including the codex inline-text path), and a graceful no-op when
|
||||
* no grounded token is present.
|
||||
*/
|
||||
const CHART_TOKEN_PROVENANCE: Partial<
|
||||
Record<ChartToken, CoachProvenanceMetric>
|
||||
> = {
|
||||
"metric:WEIGHT": "weight",
|
||||
"metric:BLOOD_PRESSURE_SYS": "bp",
|
||||
"metric:BLOOD_PRESSURE_DIA": "bp",
|
||||
"metric:PULSE": "pulse",
|
||||
"metric:BODY_FAT": "body_fat",
|
||||
"metric:SLEEP_DURATION": "sleep",
|
||||
"metric:ACTIVITY_STEPS": "steps",
|
||||
"metric:BLOOD_GLUCOSE": "glucose",
|
||||
"metric:TOTAL_BODY_WATER": "total_body_water",
|
||||
"metric:BONE_MASS": "bone_mass",
|
||||
"metric:OXYGEN_SATURATION": "spo2",
|
||||
"metric:HEART_RATE_VARIABILITY": "hrv",
|
||||
"metric:RESTING_HEART_RATE": "resting_hr",
|
||||
"metric:ACTIVE_ENERGY_BURNED": "active_energy",
|
||||
"metric:FLIGHTS_CLIMBED": "flights",
|
||||
"metric:WALKING_RUNNING_DISTANCE": "distance",
|
||||
"metric:VO2_MAX": "vo2_max",
|
||||
"metric:BODY_TEMPERATURE": "body_temp",
|
||||
"metric:FAT_FREE_MASS": "fat_free_mass",
|
||||
"metric:FAT_MASS": "fat_mass",
|
||||
"metric:MUSCLE_MASS": "muscle_mass",
|
||||
"metric:LEAN_BODY_MASS": "lean_body_mass",
|
||||
"metric:BODY_MASS_INDEX": "bmi",
|
||||
"metric:VISCERAL_FAT": "visceral_fat",
|
||||
"metric:SKIN_TEMPERATURE": "skin_temp",
|
||||
"metric:RESPIRATORY_RATE": "respiratory_rate",
|
||||
"metric:PULSE_WAVE_VELOCITY": "pulse_wave_velocity",
|
||||
"metric:VASCULAR_AGE": "vascular_age",
|
||||
"metric:WALKING_HEART_RATE_AVERAGE": "walking_hr",
|
||||
"metric:WALKING_ASYMMETRY": "walking_asymmetry",
|
||||
"metric:WALKING_DOUBLE_SUPPORT": "walking_double_support",
|
||||
"metric:WALKING_STEP_LENGTH": "walking_step_length",
|
||||
"metric:WALKING_SPEED": "walking_speed",
|
||||
"metric:AUDIO_EXPOSURE_ENV": "audio_env",
|
||||
"metric:AUDIO_EXPOSURE_HEADPHONE": "audio_headphone",
|
||||
"metric:AUDIO_EXPOSURE_EVENT": "audio_event",
|
||||
"metric:TIME_IN_DAYLIGHT": "daylight",
|
||||
};
|
||||
|
||||
/** Max charts rendered under a single Coach turn (keeps the reply scannable). */
|
||||
const MAX_COACH_CHARTS = 2;
|
||||
|
||||
/**
|
||||
* Pure selection of the chart tokens to render under an assistant turn:
|
||||
* allowlist-parsed, intersected with the turn's grounded provenance topics,
|
||||
* de-duplicated by metric, and capped. Exported for unit tests so the
|
||||
* grounding contract is pinned without standing up the chart component.
|
||||
*/
|
||||
export function selectCoachChartTokens(
|
||||
content: string,
|
||||
metrics: readonly CoachProvenanceMetric[] | undefined,
|
||||
): ChartToken[] {
|
||||
const grounded = new Set(metrics ?? []);
|
||||
const out: ChartToken[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const token of parseChartTokens(content)) {
|
||||
const topic = CHART_TOKEN_PROVENANCE[token];
|
||||
if (!topic || !grounded.has(topic)) continue;
|
||||
const metric = tokenToMetric(token);
|
||||
if (seen.has(metric)) continue;
|
||||
seen.add(metric);
|
||||
out.push(token);
|
||||
if (out.length >= MAX_COACH_CHARTS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart tokens whose canonical measurement-type label has no dedicated
|
||||
* `measurements.type*` key — route them to the closest existing key so the
|
||||
* chart header reads cleanly rather than echoing the raw enum.
|
||||
*/
|
||||
const CHART_TITLE_KEY_OVERRIDE: Record<string, string> = {
|
||||
BLOOD_PRESSURE_SYS: "measurements.typeBloodPressure",
|
||||
BLOOD_PRESSURE_DIA: "measurements.typeBloodPressure",
|
||||
};
|
||||
|
||||
/** Localised chart header for a MeasurementType, mirroring the snapshot's
|
||||
* `measurements.type<Camel>` convention with a readable fallback. */
|
||||
function coachChartTitle(
|
||||
metric: string,
|
||||
t: (key: string, vars?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const override = CHART_TITLE_KEY_OVERRIDE[metric];
|
||||
if (override) {
|
||||
const resolved = t(override);
|
||||
if (resolved !== override) return resolved;
|
||||
}
|
||||
const camel = metric
|
||||
.toLowerCase()
|
||||
.split("_")
|
||||
.map((part, i) =>
|
||||
i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1),
|
||||
)
|
||||
.join("");
|
||||
const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
|
||||
const resolved = t(key);
|
||||
return resolved === key ? metric.replace(/_/g, " ").toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — feature-detect the browser Speech Synthesis API SSR-safe.
|
||||
* `useSyncExternalStore` returns false on the server + first client paint
|
||||
* (so the read-aloud button is absent in markup) and resolves to the real
|
||||
* capability after hydration, mirroring the mic button's detection pattern
|
||||
* — no hydration mismatch warning, no `connect-src` (it runs in-browser).
|
||||
*/
|
||||
function useSpeechSynthesisSupported(): boolean {
|
||||
return useSyncExternalStore(
|
||||
() => () => {},
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
"speechSynthesis" in window &&
|
||||
"SpeechSynthesisUtterance" in window,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — read the assistant prose aloud via the browser's
|
||||
* `SpeechSynthesis`. Client-only, no dependency, no egress, CSP-clean. A
|
||||
* single in-flight utterance: a new `speak` cancels any prior one; `toggle`
|
||||
* stops while speaking. The utterance language follows the active UI locale
|
||||
* so the browser picks the closest installed voice (best-effort by OS).
|
||||
*/
|
||||
function useReadAloud(): {
|
||||
supported: boolean;
|
||||
speaking: boolean;
|
||||
toggle: (text: string, lang: string) => void;
|
||||
} {
|
||||
const supported = useSpeechSynthesisSupported();
|
||||
const [speaking, setSpeaking] = useState(false);
|
||||
|
||||
// Stop any in-flight utterance if the bubble unmounts (thread reset, nav).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window !== "undefined" && "speechSynthesis" in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(
|
||||
(text: string, lang: string) => {
|
||||
if (!supported) return;
|
||||
const synth = window.speechSynthesis;
|
||||
if (speaking) {
|
||||
synth.cancel();
|
||||
setSpeaking(false);
|
||||
return;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
synth.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(trimmed);
|
||||
utterance.lang = lang;
|
||||
utterance.onend = () => setSpeaking(false);
|
||||
utterance.onerror = () => setSpeaking(false);
|
||||
setSpeaking(true);
|
||||
synth.speak(utterance);
|
||||
},
|
||||
[supported, speaking],
|
||||
);
|
||||
|
||||
return { supported, speaking, toggle };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — read-aloud toggle for a settled assistant turn. Hidden when
|
||||
* the browser has no Speech Synthesis support. Speaks `stripChartTokens`
|
||||
* (the same text the prose shows), so stray tokens are never voiced.
|
||||
*/
|
||||
function ReadAloudButton({ content }: { content: string }) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { supported, speaking, toggle } = useReadAloud();
|
||||
if (!supported) return null;
|
||||
const label = speaking
|
||||
? t("insights.coach.readAloudStop")
|
||||
: t("insights.coach.readAloud");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-read-aloud"
|
||||
onClick={() => toggle(stripChartTokens(content), locale)}
|
||||
aria-label={label}
|
||||
aria-pressed={speaking}
|
||||
title={label}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-8 items-center gap-1 rounded px-1.5 py-1 text-xs outline-none focus-visible:ring-2"
|
||||
>
|
||||
{speaking ? (
|
||||
<VolumeX className="size-3" aria-hidden="true" />
|
||||
) : (
|
||||
<Volume2 className="size-3" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — hover/tap timestamp for a persisted message bubble. A small,
|
||||
* calm clock affordance; the locale-aware date + time surfaces in a tooltip
|
||||
* on hover or keyboard focus (desktop) and on tap (mobile, toggled state).
|
||||
* Pure CSS + one boolean — no portal, no Radix dependency, SSR-safe.
|
||||
*/
|
||||
function BubbleTimestamp({
|
||||
iso,
|
||||
align = "start",
|
||||
}: {
|
||||
iso: string;
|
||||
align?: "start" | "end";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const formatters = useFormatters();
|
||||
const [open, setOpen] = useState(false);
|
||||
const label = formatters.dateTime(iso);
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-bubble-timestamp"
|
||||
aria-label={t("insights.coach.messageTimeLabel", { time: label })}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onBlur={() => setOpen(false)}
|
||||
className="peer text-muted-foreground/50 hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex size-6 items-center justify-center rounded outline-none focus-visible:ring-2"
|
||||
>
|
||||
<Clock className="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground border-border pointer-events-none absolute bottom-full z-50 mb-1",
|
||||
"rounded-md border px-2 py-1 text-xs whitespace-nowrap shadow-md",
|
||||
"opacity-0 transition-opacity duration-100 peer-hover:opacity-100 peer-focus-visible:opacity-100",
|
||||
"motion-reduce:transition-none",
|
||||
open && "opacity-100",
|
||||
align === "end" ? "right-0" : "left-0",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageThread({
|
||||
conversation,
|
||||
streaming,
|
||||
@@ -425,6 +724,7 @@ export function MessageThread({
|
||||
messageId={m.id}
|
||||
tokensUsed={m.tokensUsed}
|
||||
model={m.model}
|
||||
createdAt={m.createdAt}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
@@ -521,6 +821,12 @@ interface ChatBubbleProps {
|
||||
*/
|
||||
tokensUsed?: number | null;
|
||||
model?: string | null;
|
||||
/**
|
||||
* v1.22 (W5) — ISO creation timestamp for the persisted bubble's
|
||||
* hover/tap timestamp tooltip. Absent on optimistic + streaming bubbles
|
||||
* (no persisted time yet), so those render no timestamp.
|
||||
*/
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
function ChatBubble({
|
||||
@@ -536,6 +842,7 @@ function ChatBubble({
|
||||
usage,
|
||||
tokensUsed,
|
||||
model,
|
||||
createdAt,
|
||||
}: ChatBubbleProps) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -580,8 +887,12 @@ function ChatBubble({
|
||||
"text-sm leading-relaxed",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
{/* v1.22 (W5) — render real paragraph blocks so a multi-line
|
||||
message reads as paragraphs, not one run-on block. User text
|
||||
is verbatim: no chart-token strip, no Learn linkify. */}
|
||||
<ProseBlocks text={content} strip={false} linkify={false} />
|
||||
</div>
|
||||
{createdAt && <BubbleTimestamp iso={createdAt} align="end" />}
|
||||
{/* v1.16.8 — explicit remember control. Stating an allergy in
|
||||
chat used to leave no durable trace unless a narrow
|
||||
pattern pass happened to match the phrasing; this stores
|
||||
@@ -645,6 +956,15 @@ function ChatBubble({
|
||||
(metricSource.windows?.length ?? 0) > 0);
|
||||
const hasProvenance = hasChips || keyValues.length > 0;
|
||||
|
||||
// v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance-
|
||||
// grounded `metric:<TYPE>` chart under a SETTLED assistant turn. Skipped
|
||||
// while streaming / in-flight / errored / on a refusal; a no-op when no
|
||||
// grounded token is present (provider-agnostic — reads the inline token).
|
||||
const chartTokens =
|
||||
!streaming && !inProgress && !errorCode && providerType !== "refusal"
|
||||
? selectCoachChartTokens(content, metricSource?.metrics)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-bubble-assistant"
|
||||
@@ -699,6 +1019,30 @@ function ChatBubble({
|
||||
{safeError && content && (
|
||||
<p className="text-warning/90 text-xs">{safeError}</p>
|
||||
)}
|
||||
{/* v1.22 (W5) — accompanying chart(s). The token was already
|
||||
stripped from the prose by <StreamedProse>; here it mounts the
|
||||
real, self-fetching Recharts chart for the user's own series.
|
||||
The model never emits data — only a metric identifier from a
|
||||
closed allowlist, intersected with the turn's grounded metrics. */}
|
||||
{chartTokens.length > 0 && (
|
||||
<div data-slot="coach-charts" className="flex w-full flex-col gap-3">
|
||||
{chartTokens.map((token) => {
|
||||
const metric = tokenToMetric(token);
|
||||
return (
|
||||
<div
|
||||
key={token}
|
||||
data-slot="coach-chart"
|
||||
className="border-border/60 bg-muted/20 rounded-xl border p-2"
|
||||
>
|
||||
<HealthChartDynamic
|
||||
types={[metric]}
|
||||
title={coachChartTitle(metric, t)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* 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
|
||||
@@ -829,14 +1173,19 @@ function ChatBubble({
|
||||
)}
|
||||
{/* 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
|
||||
the rating by (promptVersion, tone, verbosity). */}
|
||||
{messageId &&
|
||||
!inProgress &&
|
||||
!errorCode &&
|
||||
providerType !== "refusal" && (
|
||||
<CoachMessageFeedback messageId={messageId} />
|
||||
)}
|
||||
errors, in-flight stream bubbles). v1.22 (W5) — the
|
||||
read-aloud toggle joins the same action row on a settled turn;
|
||||
the bubble timestamp trails it. */}
|
||||
{!inProgress && !errorCode && providerType !== "refusal" && content && (
|
||||
<div
|
||||
data-slot="coach-bubble-actions"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{!streaming && <ReadAloudButton content={content} />}
|
||||
{messageId && <CoachMessageFeedback messageId={messageId} />}
|
||||
{createdAt && <BubbleTimestamp iso={createdAt} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,22 +4,37 @@ import { useMemo } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
ProseBlocks,
|
||||
ParagraphText,
|
||||
splitParagraphs,
|
||||
PROSE_PARAGRAPH_CLASS,
|
||||
} from "@/components/insights/prose-blocks";
|
||||
|
||||
/**
|
||||
* v1.18.9 — Claude-style word-by-word fade-in for the LIVE Coach turn.
|
||||
*
|
||||
* The provider clients return the whole reply in one shot; the chat route
|
||||
* re-chunks it into per-word `token` SSE frames (`tokeniseForStreaming`),
|
||||
* so `content` grows as frames land. This component renders each
|
||||
* whitespace segment as its own `<span>` that fades + drifts up on mount,
|
||||
* giving the reply a "thinking out loud" cadence rather than a hard dump
|
||||
* of word groups. Once the turn settles (`streaming === false`) the spans
|
||||
* collapse to a single plain-text node — zero residual animation DOM, and
|
||||
* the feedback / evidence layout underneath stays stable.
|
||||
* so `content` grows as frames land. While the turn is streaming each
|
||||
* whitespace segment of the GROWING tail renders as its own `<span>` that
|
||||
* fades + drifts up on mount, giving the reply a "thinking out loud"
|
||||
* cadence rather than a hard dump of word groups.
|
||||
*
|
||||
* 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
|
||||
* v1.22 (W5) — paragraph-aware + SSE-safe. The reply now renders as real
|
||||
* `<p>` blocks (shared `ProseBlocks`), so a model that emits blank-line
|
||||
* paragraph breaks reads as paragraphs, not one run-on block — consistent
|
||||
* with the daily briefing + insight cards. Under streaming, every COMPLETE
|
||||
* paragraph settles immediately (plain text, with the safe Learn linkifier)
|
||||
* and only the LAST, still-growing paragraph animates word-by-word. When a
|
||||
* fresh `\n\n` arrives the tail becomes a completed paragraph and stops
|
||||
* animating; the already-settled paragraphs keep their index keys and never
|
||||
* replay. Once the whole turn settles (`streaming === false`) it collapses
|
||||
* to the same `ProseBlocks` render the persisted history uses.
|
||||
*
|
||||
* Why no "only animate the new words" bookkeeping: each tail 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.
|
||||
@@ -30,8 +45,8 @@ import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
* attributes are already permitted under the strict CSP
|
||||
* (`style-src 'self' 'unsafe-inline'`); there is no runtime-injected
|
||||
* `<style>` tag, no `dangerouslySetInnerHTML`, and no markdown library —
|
||||
* the text renders as plain React children, the XSS-safe path the rest of
|
||||
* the Coach uses.
|
||||
* the text renders as plain React children (with a closed-set Learn
|
||||
* linkifier), the XSS-safe path the rest of the Coach uses.
|
||||
*
|
||||
* `prefers-reduced-motion`: the animation classes are `motion-safe:`
|
||||
* gated, so reduced-motion users get the words instantly with no fade and
|
||||
@@ -42,7 +57,7 @@ export interface StreamedProseProps {
|
||||
content: string;
|
||||
/**
|
||||
* True while the turn is still streaming. When false the prose renders
|
||||
* settled as one plain-text node (persisted history + finished turns).
|
||||
* settled as real paragraph blocks (persisted history + finished turns).
|
||||
*/
|
||||
streaming: boolean;
|
||||
}
|
||||
@@ -57,40 +72,64 @@ export function splitProseSegments(text: string): string[] {
|
||||
}
|
||||
|
||||
export function StreamedProse({ content, streaming }: StreamedProseProps) {
|
||||
const clean = stripChartTokens(content);
|
||||
const segments = useMemo(
|
||||
() => (streaming ? splitProseSegments(clean) : []),
|
||||
[streaming, clean],
|
||||
// Split paragraphs on the RAW text — `stripChartTokens` normalises
|
||||
// whitespace (collapsing the `\n\n` breaks), so it must run per line
|
||||
// inside the renderers, never on the whole string before the split.
|
||||
const paras = useMemo(
|
||||
() => (streaming ? splitParagraphs(content) : []),
|
||||
[streaming, content],
|
||||
);
|
||||
|
||||
if (!streaming) {
|
||||
// Settled turn: one plain text node, no spans, no animation.
|
||||
return <>{clean}</>;
|
||||
// Settled turn: real <p> blocks + per-line token strip + the safe Learn
|
||||
// linkifier, the same render the persisted history uses.
|
||||
return <ProseBlocks text={content} />;
|
||||
}
|
||||
|
||||
if (paras.length === 0) return null;
|
||||
|
||||
const tail = paras[paras.length - 1];
|
||||
const settled = paras.slice(0, -1);
|
||||
// The growing tail animates word-by-word; strip its tokens here so a
|
||||
// mid-stream `metric:<TYPE>` leak never flashes into the prose.
|
||||
const tailSegments = splitProseSegments(stripChartTokens(tail));
|
||||
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => (
|
||||
// New trailing spans mount + animate; already-mounted spans keep
|
||||
// their stable index key and do not replay the entrance.
|
||||
<span
|
||||
key={index}
|
||||
className={cn(
|
||||
"motion-safe:animate-in motion-safe:fade-in-0",
|
||||
"motion-safe:slide-in-from-bottom-1",
|
||||
)}
|
||||
style={{
|
||||
animationDuration: "340ms",
|
||||
// A gentle, capped stagger keyed off the in-batch position so a
|
||||
// burst of words reads as a soft cascade rather than a single
|
||||
// flash, without lagging on a long frame.
|
||||
animationDelay: `${(index % 6) * 26}ms`,
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
{segment}
|
||||
</span>
|
||||
{/* Completed paragraphs settle immediately — plain text + linkifier,
|
||||
identical to the non-streaming render so nothing reflows when the
|
||||
turn finishes. */}
|
||||
{settled.map((p, i) => (
|
||||
<p key={i} className={cn(PROSE_PARAGRAPH_CLASS)}>
|
||||
<ParagraphText text={p} strip />
|
||||
</p>
|
||||
))}
|
||||
{/* The growing tail paragraph: word-by-word fade. No linkify here —
|
||||
a half-arrived URL must not flicker into a link mid-stream; it
|
||||
settles (and linkifies) once the turn finishes. */}
|
||||
<p key={settled.length} className={cn(PROSE_PARAGRAPH_CLASS)}>
|
||||
{tailSegments.map((segment, index) => (
|
||||
// New trailing spans mount + animate; already-mounted spans keep
|
||||
// their stable index key and do not replay the entrance.
|
||||
<span
|
||||
key={index}
|
||||
className={cn(
|
||||
"motion-safe:animate-in motion-safe:fade-in-0",
|
||||
"motion-safe:slide-in-from-bottom-1",
|
||||
)}
|
||||
style={{
|
||||
animationDuration: "340ms",
|
||||
// A gentle, capped stagger keyed off the in-batch position so a
|
||||
// burst of words reads as a soft cascade rather than a single
|
||||
// flash, without lagging on a long frame.
|
||||
animationDelay: `${(index % 6) * 26}ms`,
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
{segment}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
@@ -49,6 +49,12 @@ const NUDGE_SEEN_STORAGE_KEY = "healthlog-coach-nudge-seen";
|
||||
export interface CoachNudgeStatus {
|
||||
nudgedAt: string | null;
|
||||
unread: boolean;
|
||||
/**
|
||||
* v1.22 (W5) — the conversation holding the newest assistant message, so
|
||||
* the FAB can deep-link into the proactive thread on the unread path
|
||||
* instead of opening a blank new chat. Null when no Coach reply exists.
|
||||
*/
|
||||
conversationId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +76,7 @@ export function isNudgeUnread(
|
||||
export function LayoutCoachFab() {
|
||||
const { t } = useTranslations();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
@@ -182,6 +189,17 @@ export function LayoutCoachFab() {
|
||||
// device, not just this one. The local stamp above already hid the
|
||||
// dot for an instant paint; the mutation makes it durable.
|
||||
if (unread) markSeen.mutate();
|
||||
// v1.22 (W5) — when the Coach has written an UNREAD proactive message,
|
||||
// deep-link straight into that conversation (`/coach?c=<id>`) so the
|
||||
// user lands on what the Coach said, rather than opening a blank new
|
||||
// chat in the drawer. The full page honours `?c=` →
|
||||
// `initialConversationId`. Fall back to the in-place drawer for the
|
||||
// ordinary launcher case (no unread, or no conversation id yet).
|
||||
const conversationId = status?.conversationId ?? null;
|
||||
if (unread && conversationId) {
|
||||
router.push(`/coach?c=${conversationId}`);
|
||||
return;
|
||||
}
|
||||
// v1.16.11 — open the side drawer in place (the launch context the
|
||||
// layout mount consumes) instead of navigating to the chat page;
|
||||
// the conversation arrives without losing the page underneath.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { learnUrl } from "@/lib/learn-links";
|
||||
import { InlineLearnLink } from "@/components/ui/learn-more-link";
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — shared paragraph renderer for every trusted-as-text AI
|
||||
* narrative surface (the Coach, the daily briefing, the per-metric /
|
||||
* score insight cards, the period narrative).
|
||||
*
|
||||
* The AI surfaces render their prose as plain React text children — there
|
||||
* is NO markdown library and NO `dangerouslySetInnerHTML` anywhere in the
|
||||
* tree (the project's XSS posture). Before this helper, three ad-hoc
|
||||
* approaches were in the tree (a single `<p>`, a `whitespace-pre-line`
|
||||
* `<p>`, and the Coach's word-span renderer), so a model that emitted real
|
||||
* paragraph breaks read as one run-on block.
|
||||
*
|
||||
* `ProseBlocks` settles that into ONE pure, XSS-safe splitter:
|
||||
* - split a TRUSTED plain string on blank lines into real `<p>` blocks;
|
||||
* - a single `\n` inside a paragraph becomes a `<br/>`;
|
||||
* - recognised `/learn/<slug>` references (catalog-whitelisted, already
|
||||
* post-filtered server-side) render as a real, safe `<a>` via
|
||||
* `InlineLearnLink` — a closed-set linkifier, never model HTML.
|
||||
*
|
||||
* Everything is `String.prototype.split` + React children — no parser, no
|
||||
* markdown dependency, no injected markup. The chart-token strip
|
||||
* (`stripChartTokens`) runs PER LINE (not on the whole string): it
|
||||
* normalises intra-line whitespace, so running it on the full text would
|
||||
* collapse the `\n\n` paragraph breaks before they can be split on. Splitting
|
||||
* paragraphs first, then stripping each line, keeps the structure intact
|
||||
* while still ensuring a stray `metric:<TYPE>` token never surfaces.
|
||||
*/
|
||||
|
||||
/** Default paragraph styling shared by every prose surface + the Coach
|
||||
* streaming tail, so the streamed and settled renders line up exactly. */
|
||||
export const PROSE_PARAGRAPH_CLASS =
|
||||
"leading-relaxed [&:not(:first-child)]:mt-3";
|
||||
|
||||
/**
|
||||
* Split a trusted-as-text string into real paragraphs on blank lines.
|
||||
* Whitespace-only fragments are dropped. Exported for the streaming
|
||||
* paragraph-aware renderer + unit tests.
|
||||
*/
|
||||
export function splitParagraphs(text: string): string[] {
|
||||
return text
|
||||
.split(/\n{2,}/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog-whitelisted `/learn/<slug>` matcher. Mirrors the post-filter's
|
||||
* grammar (`learn-link-guard.ts`): the host is optional (relative or the
|
||||
* absolute `healthlog.dev/learn/<slug>` form), the slug is the lower-case
|
||||
* hyphenated path segment after `/learn/`. The capture is validated against
|
||||
* the published catalog (`learnUrl`) before an anchor renders — an unknown
|
||||
* slug stays plain text, so a drifted post-filter can never produce a
|
||||
* clickable invented link.
|
||||
*/
|
||||
const LEARN_URL_RE =
|
||||
/(?:https?:\/\/)?(?:[a-z0-9.-]*healthlog\.dev)?\/learn\/[a-z0-9-]+\/?/gi;
|
||||
|
||||
/**
|
||||
* Turn a trusted single line of text into React children, rendering any
|
||||
* catalog-known `/learn/<slug>` reference as a safe `InlineLearnLink` and
|
||||
* leaving everything else as plain text nodes. An unknown slug (which the
|
||||
* server post-filter should already have stripped) falls through as plain
|
||||
* text — fail-closed, never a fabricated anchor.
|
||||
*/
|
||||
function linkifyLearnLinks(line: string): ReactNode {
|
||||
if (!line.includes("/learn/")) return line;
|
||||
|
||||
const nodes: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let key = 0;
|
||||
// `matchAll` over a global regex gives each match with its index so we
|
||||
// slice the surrounding text verbatim — no mutation of the source.
|
||||
for (const match of line.matchAll(LEARN_URL_RE)) {
|
||||
const matched = match[0];
|
||||
const start = match.index ?? 0;
|
||||
// The slug is the path segment after `/learn/`, minus a trailing slash.
|
||||
const slug = matched
|
||||
.slice(matched.indexOf("/learn/") + "/learn/".length)
|
||||
.replace(/\/$/, "")
|
||||
.toLowerCase();
|
||||
if (learnUrl(slug) == null) continue; // unknown slug → leave as plain text
|
||||
|
||||
if (start > lastIndex) nodes.push(line.slice(lastIndex, start));
|
||||
nodes.push(
|
||||
<InlineLearnLink key={`learn-${key++}`} slug={slug}>
|
||||
{matched}
|
||||
</InlineLearnLink>,
|
||||
);
|
||||
lastIndex = start + matched.length;
|
||||
}
|
||||
if (nodes.length === 0) return line;
|
||||
if (lastIndex < line.length) nodes.push(line.slice(lastIndex));
|
||||
return <>{nodes}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one paragraph's inner text: each single `\n` becomes a `<br/>`, each
|
||||
* line is (optionally) chart-token-stripped, then run through the
|
||||
* catalog-whitelisted Learn linkifier. Stripping per LINE preserves the
|
||||
* `\n` breaks that a whole-string strip would collapse. Exported so the
|
||||
* Coach streaming renderer can settle completed paragraphs with the same
|
||||
* markup the non-streaming path produces.
|
||||
*/
|
||||
export function ParagraphText({
|
||||
text,
|
||||
strip = false,
|
||||
linkify = true,
|
||||
}: {
|
||||
text: string;
|
||||
strip?: boolean;
|
||||
linkify?: boolean;
|
||||
}) {
|
||||
const lines = text.split("\n");
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, i) => {
|
||||
const cleaned = strip ? stripChartTokens(line) : line;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{linkify ? linkifyLearnLinks(cleaned) : cleaned}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProseBlocksProps {
|
||||
/** Trusted-as-text prose (model output already verified server-side). */
|
||||
text: string;
|
||||
/**
|
||||
* Strip stray `metric:<TYPE>` chart tokens first (defence-in-depth).
|
||||
* Defaults true for AI prose; pass false for already-cleaned input or
|
||||
* for plain user text where a literal `metric:` substring is meaningful.
|
||||
*/
|
||||
strip?: boolean;
|
||||
/** Linkify catalog-known `/learn/<slug>` references. Default true. */
|
||||
linkify?: boolean;
|
||||
/** Per-paragraph class override (defaults to `PROSE_PARAGRAPH_CLASS`). */
|
||||
paragraphClassName?: string;
|
||||
}
|
||||
|
||||
export function ProseBlocks({
|
||||
text,
|
||||
strip = true,
|
||||
linkify = true,
|
||||
paragraphClassName,
|
||||
}: ProseBlocksProps) {
|
||||
// Split on blank lines FIRST (raw text), then strip per line inside
|
||||
// `ParagraphText` — a whole-string strip would collapse the breaks.
|
||||
// Drop a paragraph that is empty once its tokens are stripped (a rare
|
||||
// token-only line), so no hollow `<p>` renders.
|
||||
const paras = splitParagraphs(text).filter(
|
||||
(p) => !strip || stripChartTokens(p).length > 0,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{paras.map((p, i) => (
|
||||
<p key={i} className={cn(paragraphClassName ?? PROSE_PARAGRAPH_CLASS)}>
|
||||
<ParagraphText text={p} strip={strip} linkify={linkify} />
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { learnLinkForMetric } from "@/lib/learn-links";
|
||||
import { learnLinkForMetric, learnUrl } from "@/lib/learn-links";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -55,3 +56,50 @@ export function LearnMoreLink({ concept, className }: LearnMoreLinkProps) {
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — inline, catalog-whitelisted Learn anchor for AI prose.
|
||||
*
|
||||
* Where `LearnMoreLink` resolves a *concept* to its guide for a deterministic
|
||||
* UI surface, this variant linkifies a `/learn/<slug>` reference that already
|
||||
* appears in trusted prose (the Coach reply, post-filtered server-side by
|
||||
* `scrubUnknownLearnLinks`). The slug is resolved through `learnUrl` — the same
|
||||
* closed-set, registry-backed builder — so an unknown slug renders the children
|
||||
* as plain text (fail-closed): a drifted post-filter can never produce a
|
||||
* clickable invented href, and there is no markdown / HTML parsing involved.
|
||||
*
|
||||
* The visible label is the caller's `children` (the original URL text), keeping
|
||||
* the reference verbatim; the safe attributes (`target="_blank"`,
|
||||
* `rel="noopener noreferrer"`) mirror `LearnMoreLink`.
|
||||
*/
|
||||
export interface InlineLearnLinkProps {
|
||||
/** The `/learn/<slug>` path segment to resolve and link. */
|
||||
slug: string;
|
||||
/** Visible anchor text (typically the matched URL substring). */
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineLearnLink({
|
||||
slug,
|
||||
children,
|
||||
className,
|
||||
}: InlineLearnLinkProps) {
|
||||
const url = learnUrl(slug);
|
||||
if (url == null) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-slot="inline-learn-link"
|
||||
className={cn(
|
||||
"text-primary underline decoration-from-font underline-offset-2 hover:no-underline",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -877,12 +877,17 @@ export async function runCoachNudgeTick(
|
||||
// dot once the user opens the Coach). A persistence failure must
|
||||
// not swallow the notification, so it is caught locally and the
|
||||
// dispatch still fires.
|
||||
let nudgeConversationId: string | null = null;
|
||||
try {
|
||||
await recordNudgeImpl({
|
||||
const recorded = await recordNudgeImpl({
|
||||
userId: user.id,
|
||||
title,
|
||||
body,
|
||||
});
|
||||
// v1.22 (W5) — keep the conversation id so the push deep-links
|
||||
// precisely into the proactive thread instead of the generic
|
||||
// `/coach`, closing the most-recent-heuristic gap on the web side.
|
||||
nudgeConversationId = recorded.conversationId;
|
||||
summary.persisted += 1;
|
||||
} catch (persistErr: unknown) {
|
||||
const message =
|
||||
@@ -901,8 +906,12 @@ export async function runCoachNudgeTick(
|
||||
scheduledAt: now.toISOString(),
|
||||
trigger,
|
||||
// Deep link — the web-push sender threads this into the
|
||||
// service-worker payload's click target.
|
||||
url: "/coach",
|
||||
// service-worker payload's click target. Point at the exact
|
||||
// proactive conversation when it persisted; fall back to the
|
||||
// generic Coach surface if persistence failed.
|
||||
url: nudgeConversationId
|
||||
? `/coach?c=${nudgeConversationId}`
|
||||
: "/coach",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -779,6 +779,12 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
.describe(
|
||||
"True when that message is newer than the last time the caller opened the Coach.",
|
||||
),
|
||||
conversationId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Conversation holding the newest assistant message; null when none exists. The client deep-links into it (`/coach?c=<id>`) on the unread path.",
|
||||
),
|
||||
}),
|
||||
"CoachNudgeStatus",
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user