feat(coach): collapsible evidence-block disclosure under assistant bubbles

Render the v1.4.22 keyValues array as a click-to-expand "Worauf
bezieht sich das?" / "What I'm looking at" disclosure under each
assistant message. Closed by default; rows show label, value, unit,
and window with Dracula muted-foreground accents and a chevron that
rotates on open. Source chips stay above the disclosure so users see
both abstraction layers, and the whole block hides when keyValues is
empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 21:08:54 +02:00
1 parent 7b8efaae14
commit bc87fa3904
4 files changed
+157 -1

No files matched your search

+1
View File
@@ -871,6 +871,7 @@
"sourcesFresh": "Aktuell",
"sourcesStale": "Veraltet",
"sourcesFooter": "Der Coach liest nur Daten, die du verbunden hast.",
"evidenceLabel": "Worauf bezieht sich das?",
"metric": {
"bp": "Blutdruck",
"weight": "Gewicht",
+1
View File
@@ -871,6 +871,7 @@
"sourcesFresh": "Fresh",
"sourcesStale": "Stale",
"sourcesFooter": "The Coach reads only the data you've connected.",
"evidenceLabel": "What I'm looking at",
"metric": {
"bp": "Blood pressure",
"weight": "Weight",
@@ -216,4 +216,107 @@ describe("<MessageThread>", () => {
"Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten",
);
});
it("renders the evidence-block disclosure when keyValues is non-empty (EN)", () => {
// v1.4.22 — the Coach surfaces load-bearing numbers in a
// collapsible "What I'm looking at" disclosure under the assistant
// bubble. Verify the structure renders correctly and the entries
// show label / value / unit / window.
const withKeyValues: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
{
...baseConversation.messages[1],
metricSource: {
windows: ["last30days"],
metrics: ["bp"],
keyValues: [
{
label: "avg7 systolic",
value: "138",
unit: "mmHg",
window: "last7days",
},
{
label: "avg30 systolic",
value: "134",
unit: "mmHg",
window: "last30days",
},
],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />);
expect(html).toContain('data-slot="coach-evidence"');
expect(html).toContain("What I&#x27;m looking at");
expect(html).toContain('data-slot="coach-evidence-list"');
const rows = (html.match(/data-slot="coach-evidence-row"/g) ?? []).length;
expect(rows).toBe(2);
expect(html).toContain("avg7 systolic");
expect(html).toContain("138 mmHg");
expect(html).toContain("(last7days)");
expect(html).toContain("avg30 systolic");
expect(html).toContain("134 mmHg");
// Disclosure is collapsed by default (no `open` attribute).
expect(html).not.toMatch(/<details[^>]*\bopen\b/);
});
it("renders the evidence-block label in German under 'de' locale", () => {
const withKeyValues: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
{
...baseConversation.messages[1],
metricSource: {
windows: ["last30days"],
metrics: ["bp"],
keyValues: [
{
label: "avg30 systolisch",
value: "138",
unit: "mmHg",
window: "last30days",
},
],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />, "de");
expect(html).toContain("Worauf bezieht sich das?");
});
it("renders entries without unit or window cleanly", () => {
const withKeyValues: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
{
...baseConversation.messages[1],
metricSource: {
windows: [],
metrics: ["compliance"],
keyValues: [{ label: "30-day adherence", value: "96" }],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />);
expect(html).toContain("30-day adherence");
// No stray empty unit/window markup.
expect(html).not.toMatch(/30-day adherence:\s*<\/span>\s*<strong[^>]*>\s*</);
});
it("hides the disclosure entirely when keyValues is empty or absent", () => {
// baseConversation.messages[1].metricSource has no `keyValues`
// field — the source chips still render but the disclosure does
// not appear at all.
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).not.toContain('data-slot="coach-evidence"');
expect(html).not.toContain("What I&#x27;m looking at");
});
});
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { Bot, Sparkles, User } from "lucide-react";
import { Bot, ChevronRight, Sparkles, User } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -202,6 +202,8 @@ function ChatBubble({
? t("insights.coach.errorProvider")
: null;
const keyValues = metricSource?.keyValues ?? [];
return (
<div
data-slot="coach-bubble-assistant"
@@ -232,6 +234,55 @@ function ChatBubble({
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
)}
{metricSource && <SourceChips provenance={metricSource} />}
{keyValues.length > 0 && (
<details
data-slot="coach-evidence"
className={cn(
"border-border/50 bg-muted/30 group rounded-md border",
"px-2.5 py-1.5 text-xs",
)}
>
<summary
data-slot="coach-evidence-summary"
className={cn(
"text-muted-foreground hover:text-foreground flex cursor-pointer",
"items-center gap-1.5 leading-relaxed",
"[&::-webkit-details-marker]:hidden marker:hidden",
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
)}
>
<ChevronRight
aria-hidden="true"
className="size-3 transition-transform group-open:rotate-90"
/>
<span>{t("insights.coach.evidenceLabel")}</span>
</summary>
<ul
data-slot="coach-evidence-list"
className="text-foreground mt-2 flex flex-col gap-1"
>
{keyValues.map((kv, idx) => (
<li
key={`${kv.label}-${idx}`}
data-slot="coach-evidence-row"
className="leading-relaxed"
>
<span className="text-muted-foreground">{kv.label}:</span>{" "}
<strong className="font-semibold">
{kv.value}
{kv.unit ? ` ${kv.unit}` : ""}
</strong>
{kv.window && (
<span className="text-muted-foreground">
{" "}
({kv.window})
</span>
)}
</li>
))}
</ul>
</details>
)}
</div>
</div>
);