mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'coach/wp4-followups' into release/v1.39.4
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user