Merge branch 'coach/wp4-followups' into release/v1.39.4

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 12:41:44 +02:00
commit 968b8b1efd
19 files changed
+2333 -63

No files matched your search

@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { getServerTranslator } from "@/lib/i18n/server-translator";
// The chips' only hook is the translator; with the server one the component
// can be called as a plain function and its handlers exercised.
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({
t: (key: string) => getServerTranslator("en").t(key),
}),
}));
import { CoachFollowUpChips } from "../follow-up-chips";
import type { CoachFollowUp } from "@/lib/ai/coach/types";
function chip(id: string, label: string): CoachFollowUp {
return {
id,
kind: "previous_period",
labelKey: "coach.followUp.previousPeriod",
label,
reuse: false,
origin: "server",
};
}
const CHIPS = [
chip("f1", "Compare with the period before"),
chip("f2", "Look at Sleep too"),
chip("f3", "Show as a chart"),
chip("f4", "A fourth one"),
];
function render(followUps: CoachFollowUp[], disabled = false) {
const onSelect = vi.fn();
const tree = CoachFollowUpChips({
followUps,
messageId: "m1",
disabled,
onSelect,
});
return { tree, onSelect, html: tree ? renderToStaticMarkup(tree) : "" };
}
function buttons(node: ReactNode): ReactElement<Record<string, unknown>>[] {
if (Array.isArray(node)) return node.flatMap(buttons);
if (!isValidElement(node)) return [];
const el = node as ReactElement<Record<string, unknown>>;
const own = el.props["data-follow-up-id"] ? [el] : [];
return [...own, ...buttons(el.props.children as ReactNode)];
}
describe("<CoachFollowUpChips>", () => {
it("renders at most three outline chips in a labelled group, with the server's label", () => {
const { html, tree } = render(CHIPS);
expect(html).toContain('role="group"');
expect(html).toContain('aria-label="Suggested follow-ups"');
expect(html).toContain(">Look at Sleep too</button>");
expect(html).not.toContain("A fourth one");
const rendered = buttons(tree);
expect(rendered).toHaveLength(3);
for (const button of rendered) {
expect(button.props.variant).toBe("outline");
expect(button.props.className).toContain("min-h-11");
}
});
it("sends the tapped chip with the message that offered it", () => {
const { tree, onSelect } = render(CHIPS);
(buttons(tree)[1].props.onClick as () => void)();
expect(onSelect).toHaveBeenCalledWith(CHIPS[1], "m1");
});
it("is hidden while a turn is in flight, and when there is nothing to offer", () => {
expect(render(CHIPS, true).tree).toBeNull();
expect(render([]).tree).toBeNull();
});
});
@@ -5,10 +5,20 @@
* 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.
* The label is the server's rendering in the request locale. It is not
* re-rendered from `labelKey` here: a related-metric chip names its metric
* in the label, and the key alone does not carry it. Hidden while a turn is
* in flight, so a chip can never be tapped against a reply that is about to
* stop being the latest.
*/
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import type { CoachFollowUp } from "@/lib/ai/coach/types";
/** At most this many chips render, whatever the payload carries. */
const MAX_CHIPS = 3;
export interface CoachFollowUpChipsProps {
followUps: CoachFollowUp[];
/** The assistant message that offered the chips. */
@@ -18,6 +28,37 @@ export interface CoachFollowUpChipsProps {
onSelect: (followUp: CoachFollowUp, messageId: string) => void;
}
export function CoachFollowUpChips(_props: CoachFollowUpChipsProps) {
return null;
export function CoachFollowUpChips({
followUps,
messageId,
disabled,
onSelect,
}: CoachFollowUpChipsProps) {
const { t } = useTranslations();
const chips = followUps.slice(0, MAX_CHIPS);
if (disabled || chips.length === 0) return null;
return (
<div
role="group"
aria-label={t(COACH_FOLLOW_UP_UI_KEYS.groupLabel)}
data-slot="coach-follow-up-chips"
data-message-id={messageId}
className="flex flex-wrap gap-2"
>
{chips.map((chip) => (
<Button
key={chip.id}
type="button"
variant="outline"
size="sm"
className="min-h-11"
data-follow-up-id={chip.id}
data-follow-up-kind={chip.kind}
onClick={() => onSelect(chip, messageId)}
>
{chip.label}
</Button>
))}
</div>
);
}
@@ -40,6 +40,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import {
DEFAULT_COACH_PREFS,
type CoachExcludeMetric,
@@ -351,6 +352,30 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
}
/>
</div>
{/* v1.39.4 — follow-up chips under the Coach's latest answer.
Absent in the saved prefs means on. */}
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1">
<Label
htmlFor="coach-prefs-follow-up-chips"
className="cursor-pointer text-xs font-medium"
>
{t(COACH_FOLLOW_UP_UI_KEYS.settingLabel)}
</Label>
<p className="text-muted-foreground text-xs leading-relaxed">
{t(COACH_FOLLOW_UP_UI_KEYS.settingHint)}
</p>
</div>
<Switch
id="coach-prefs-follow-up-chips"
data-slot="coach-prefs-follow-up-chips"
checked={draft.followUpChips !== false}
onCheckedChange={(next) =>
setDraft((prev) => ({ ...prev, followUpChips: next }))
}
/>
</div>
</div>
)}
<SettingsCardActions>