mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'coach/wp5-clarify' into release/v1.39.4
# Conflicts: # src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap
This commit is contained in:
commit
00a5a8862f
9 files changed
+1219
-50
No files matched your search
@@ -0,0 +1,102 @@
|
||||
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 card's only hook is the translator; the server one resolves the same
|
||||
// bundles and returns the key for a missing string, like the client one. With
|
||||
// it, the card can be called as a plain function and its handlers exercised
|
||||
// (the node test environment has no DOM to click through).
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string) => getServerTranslator("en").t(key),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CoachClarificationCard } from "../clarification-card";
|
||||
import type { CoachClarification } from "@/lib/ai/coach/types";
|
||||
|
||||
const METRIC: CoachClarification = {
|
||||
kind: "metric",
|
||||
choices: [
|
||||
{
|
||||
id: "c1",
|
||||
labelKey: "insights.coach.metric.pulse",
|
||||
label: "Pulse (server)",
|
||||
value: { metric: "pulse" },
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
labelKey: "coach.not.a.key",
|
||||
label: "Server label",
|
||||
value: { metric: "resting_hr" },
|
||||
},
|
||||
],
|
||||
freeText: true,
|
||||
};
|
||||
|
||||
function card(clarification: CoachClarification, disabled = false) {
|
||||
const onChoose = vi.fn();
|
||||
const tree = CoachClarificationCard({
|
||||
clarification,
|
||||
messageId: "m1",
|
||||
disabled,
|
||||
onChoose,
|
||||
});
|
||||
return { tree, onChoose, html: renderToStaticMarkup(tree) };
|
||||
}
|
||||
|
||||
function choiceButtons(
|
||||
node: ReactNode,
|
||||
): ReactElement<Record<string, unknown>>[] {
|
||||
if (Array.isArray(node)) return node.flatMap(choiceButtons);
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as ReactElement<Record<string, unknown>>;
|
||||
const own = el.props["data-choice-id"] ? [el] : [];
|
||||
return [...own, ...choiceButtons(el.props.children as ReactNode)];
|
||||
}
|
||||
|
||||
describe("<CoachClarificationCard>", () => {
|
||||
it("announces politely and takes no focus", () => {
|
||||
const { html } = card(METRIC);
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
expect(html).toContain('aria-label="The Coach has a question"');
|
||||
expect(html).not.toContain("autofocus");
|
||||
expect(html).not.toContain("autoFocus");
|
||||
});
|
||||
|
||||
it("renders the catalog label, falling back to the server label", () => {
|
||||
const { html } = card(METRIC);
|
||||
expect(html).toContain('aria-label="Possible answers"');
|
||||
expect(html).toContain(">Pulse</button>");
|
||||
expect(html).toContain(">Server label</button>");
|
||||
expect(html).toContain("You can also type your own answer.");
|
||||
});
|
||||
|
||||
it("sends the tapped choice", () => {
|
||||
const { tree, onChoose } = card(METRIC);
|
||||
const buttons = choiceButtons(tree);
|
||||
expect(buttons).toHaveLength(2);
|
||||
(buttons[1].props.onClick as () => void)();
|
||||
expect(onChoose).toHaveBeenCalledWith(METRIC.choices[1]);
|
||||
});
|
||||
|
||||
it("disables the choices while a turn is in flight", () => {
|
||||
const { tree } = card(METRIC, true);
|
||||
for (const button of choiceButtons(tree)) {
|
||||
expect(button.props.disabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("asks for a typed answer on a context question", () => {
|
||||
const { html, tree } = card({
|
||||
kind: "context",
|
||||
choices: [],
|
||||
freeText: true,
|
||||
});
|
||||
expect(choiceButtons(tree)).toHaveLength(0);
|
||||
expect(html).not.toContain('role="group"');
|
||||
expect(html).toContain("Type your answer below.");
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,21 @@
|
||||
|
||||
/**
|
||||
* 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 }`.
|
||||
* The question itself is the assistant reply in the thread, so the card
|
||||
* does not repeat it: a short heading, the choices, and a meta line saying a
|
||||
* typed answer works too. A tap on a choice sends its label with
|
||||
* `clarification: { messageId, choiceId }`; a typed answer goes out with
|
||||
* `clarification: { messageId }`.
|
||||
*
|
||||
* Not built yet: renders nothing.
|
||||
* Announced politely when it appears and never takes focus: the person may
|
||||
* already be typing.
|
||||
*/
|
||||
import { MessageCircleQuestionMark } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { COACH_CLARIFY_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import type {
|
||||
CoachClarification,
|
||||
CoachClarificationChoice,
|
||||
@@ -22,6 +31,70 @@ export interface CoachClarificationCardProps {
|
||||
onChoose: (choice: CoachClarificationChoice) => void;
|
||||
}
|
||||
|
||||
export function CoachClarificationCard(_props: CoachClarificationCardProps) {
|
||||
return null;
|
||||
export function CoachClarificationCard({
|
||||
clarification,
|
||||
messageId,
|
||||
disabled,
|
||||
onChoose,
|
||||
}: CoachClarificationCardProps) {
|
||||
const { t } = useTranslations();
|
||||
// The catalog string in the reader's locale when the bundle has it, the
|
||||
// server's rendering when it does not.
|
||||
const choiceLabel = (choice: CoachClarificationChoice) => {
|
||||
const local = t(choice.labelKey);
|
||||
return local === choice.labelKey ? choice.label : local;
|
||||
};
|
||||
const { choices } = clarification;
|
||||
const hint =
|
||||
choices.length === 0
|
||||
? t(COACH_CLARIFY_UI_KEYS.contextHint)
|
||||
: clarification.freeText
|
||||
? t(COACH_CLARIFY_UI_KEYS.freeTextHint)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="coach-clarification-card"
|
||||
data-message-id={messageId}
|
||||
aria-live="polite"
|
||||
aria-label={t(COACH_CLARIFY_UI_KEYS.cardLabel)}
|
||||
role="region"
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 md:px-6">
|
||||
<MessageCircleQuestionMark
|
||||
aria-hidden="true"
|
||||
className="text-foreground size-4 shrink-0"
|
||||
/>
|
||||
<p className="text-sm font-medium">
|
||||
{t(COACH_CLARIFY_UI_KEYS.cardLabel)}
|
||||
</p>
|
||||
</div>
|
||||
{choices.length > 0 ? (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t(COACH_CLARIFY_UI_KEYS.choicesLabel)}
|
||||
className="flex flex-wrap gap-2 px-4 md:px-6"
|
||||
>
|
||||
{choices.map((choice) => (
|
||||
<Button
|
||||
key={choice.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={disabled}
|
||||
data-choice-id={choice.id}
|
||||
onClick={() => onChoose(choice)}
|
||||
>
|
||||
{choiceLabel(choice)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{hint ? (
|
||||
<p className="text-muted-foreground px-4 text-xs md:px-6">{hint}</p>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user