mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(coach): move an answer's model and tokens behind a details icon
The tokens and model no longer sit as right-aligned text under a Coach answer. The row is one left-aligned group: copy, read aloud, try again, a details icon, then the time. The details icon is a focusable button whose tooltip names the model and the token count; it opens on hover, on keyboard focus and on a tap, and a second tap, a tap elsewhere or Escape closes it. Strings in all seven locales; the joined tokens-with-model string is gone.
This commit is contained in:
1 parent
3bede8230b
commit
0c3de5bdfb
10 files changed
+204
-103
No files matched your search
@@ -12,11 +12,12 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
*
|
||||
* Contracts under test:
|
||||
*
|
||||
* 1. Under an answer there is exactly one action row: the icon group and
|
||||
* the time on the left, the tokens and model on the right, both halves
|
||||
* on one line (their centres within 2 px, the row no taller than one
|
||||
* 44 px target), the left group starting on the bubble's left edge and
|
||||
* the meta ending inside the answer column.
|
||||
* 1. Under an answer there is exactly one action row, left-aligned on the
|
||||
* bubble's left edge and no taller than one 44 px target: copy, read
|
||||
* aloud, try again, details, then the time. The model and the tokens
|
||||
* are not text on the row; the details icon shows them in a tooltip on
|
||||
* keyboard focus (desktop) and on a tap (phone), and a second tap or
|
||||
* Escape closes it.
|
||||
* 2. The follow-up chips sit in the answer's column: their left edge is
|
||||
* the bubble's, their text is the answer's size, and a chip is at most
|
||||
* 36 px tall beside a pointer and at least 44 px on a phone.
|
||||
@@ -246,28 +247,58 @@ test.describe("Coach answer layout", () => {
|
||||
const bubble = last.locator('[data-slot="coach-answer-bubble"]');
|
||||
const column = last.locator('[data-slot="coach-answer-column"]');
|
||||
const row = last.locator('[data-slot="coach-answer-actions"]');
|
||||
const left = row.locator('[data-slot="coach-answer-actions-left"]');
|
||||
const meta = row.locator('[data-slot="coach-answer-meta"]');
|
||||
const info = row.locator('[data-slot="coach-answer-info"]');
|
||||
const time = row.locator('[data-slot="coach-message-time"]');
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(meta).toContainText("1,234 tokens");
|
||||
await expect(row).toHaveCSS("opacity", "1");
|
||||
const [bubbleBox, columnBox, rowBox, leftBox, metaBox] = await Promise.all(
|
||||
[bubble, column, row, left, meta].map(box),
|
||||
);
|
||||
expect(
|
||||
Math.abs(
|
||||
leftBox.y + leftBox.height / 2 - (metaBox.y + metaBox.height / 2),
|
||||
),
|
||||
"left group and meta share one line",
|
||||
).toBeLessThanOrEqual(2);
|
||||
await expect(row).not.toContainText("tokens");
|
||||
await expect(row.locator('[data-slot="coach-answer-meta"]')).toHaveCount(0);
|
||||
await expect(info).toHaveAttribute("aria-label", "Answer details");
|
||||
const [bubbleBox, columnBox, rowBox, answerCopyBox, infoBox, timeBox] =
|
||||
await Promise.all(
|
||||
[
|
||||
bubble,
|
||||
column,
|
||||
row,
|
||||
row.locator('[data-slot="coach-copy-message"]'),
|
||||
info,
|
||||
time,
|
||||
].map(box),
|
||||
);
|
||||
expect(rowBox.height, "the row is one line").toBeLessThanOrEqual(44);
|
||||
expect(Math.abs(leftBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
|
||||
expect(metaBox.x + metaBox.width).toBeLessThanOrEqual(
|
||||
expect(Math.abs(answerCopyBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
|
||||
expect(infoBox.x, "details follows the other icons").toBeGreaterThan(
|
||||
answerCopyBox.x,
|
||||
);
|
||||
expect(timeBox.x, "the time comes last").toBeGreaterThan(infoBox.x);
|
||||
expect(timeBox.x + timeBox.width).toBeLessThanOrEqual(
|
||||
columnBox.x + columnBox.width + 1,
|
||||
);
|
||||
await expect(left.locator('[data-slot="coach-message-time"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
for (const [b, name] of [
|
||||
[infoBox, "details"],
|
||||
[timeBox, "time"],
|
||||
] as const) {
|
||||
expect(
|
||||
Math.abs(
|
||||
b.y + b.height / 2 - (answerCopyBox.y + answerCopyBox.height / 2),
|
||||
),
|
||||
`${name} shares the row's line`,
|
||||
).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
// The details tooltip: focus opens it on a desktop, a tap on a phone.
|
||||
const tip = page.locator('[data-slot="coach-answer-info-content"]');
|
||||
await expect(tip).toHaveCount(0);
|
||||
if (desktop) await info.focus();
|
||||
else await info.tap();
|
||||
await expect(tip).toBeVisible();
|
||||
await expect(tip).toContainText("Model: gpt-4o-mini-2024-07-18");
|
||||
await expect(tip).toContainText("1,234 tokens");
|
||||
await expect(info).toHaveAttribute("aria-describedby", /.+/);
|
||||
await shoot(page, testInfo, `answer-info-${viewport.label}`);
|
||||
if (desktop) await page.keyboard.press("Escape");
|
||||
else await info.tap();
|
||||
await expect(tip).toHaveCount(0);
|
||||
|
||||
// 2. The chips in the answer's column, at the answer's size.
|
||||
const chips = last.locator('[data-slot="coach-follow-up-chips"]');
|
||||
|
||||
+3
-2
@@ -3023,7 +3023,6 @@
|
||||
"thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat",
|
||||
"thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.",
|
||||
"tokensUsed": "{count} Tokens",
|
||||
"tokensUsedWithModel": "{count} Tokens · {model}",
|
||||
"askAboutThis": "Den Coach dazu fragen",
|
||||
"readStrip": {
|
||||
"label": "Üblicher Bereich",
|
||||
@@ -3143,7 +3142,9 @@
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "1 Bereich angesehen",
|
||||
"areasDoneFew": "{count} Bereiche angesehen",
|
||||
"areasDoneOther": "{count} Bereiche angesehen"
|
||||
"areasDoneOther": "{count} Bereiche angesehen",
|
||||
"info": "Details zur Antwort",
|
||||
"infoModel": "Modell: {model}"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "gerade eben",
|
||||
|
||||
+3
-2
@@ -3023,7 +3023,6 @@
|
||||
"thinkingExpandAria": "Show what the Coach considered",
|
||||
"thinkingDetail": "Answered from your health snapshot.",
|
||||
"tokensUsed": "{count} tokens",
|
||||
"tokensUsedWithModel": "{count} tokens · {model}",
|
||||
"askAboutThis": "Ask the coach about this",
|
||||
"readStrip": {
|
||||
"label": "Usual range",
|
||||
@@ -3143,7 +3142,9 @@
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "Looked at 1 area",
|
||||
"areasDoneFew": "Looked at {count} areas",
|
||||
"areasDoneOther": "Looked at {count} areas"
|
||||
"areasDoneOther": "Looked at {count} areas",
|
||||
"info": "Answer details",
|
||||
"infoModel": "Model: {model}"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
|
||||
+3
-2
@@ -3023,7 +3023,6 @@
|
||||
"thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta",
|
||||
"thinkingDetail": "Respondido a partir de tu resumen de salud.",
|
||||
"tokensUsed": "{count} tokens",
|
||||
"tokensUsedWithModel": "{count} tokens · {model}",
|
||||
"askAboutThis": "Pregúntale al coach sobre esto",
|
||||
"readStrip": {
|
||||
"label": "Rango habitual",
|
||||
@@ -3143,7 +3142,9 @@
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "1 área consultada",
|
||||
"areasDoneFew": "{count} áreas consultadas",
|
||||
"areasDoneOther": "{count} áreas consultadas"
|
||||
"areasDoneOther": "{count} áreas consultadas",
|
||||
"info": "Detalles de la respuesta",
|
||||
"infoModel": "Modelo: {model}"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "ahora mismo",
|
||||
|
||||
+3
-2
@@ -3023,7 +3023,6 @@
|
||||
"thinkingExpandAria": "Montrer ce que le Coach a pris en compte",
|
||||
"thinkingDetail": "Réponse établie à partir de ton aperçu santé.",
|
||||
"tokensUsed": "{count} jetons",
|
||||
"tokensUsedWithModel": "{count} jetons · {model}",
|
||||
"askAboutThis": "Interroger le coach à ce sujet",
|
||||
"readStrip": {
|
||||
"label": "Plage habituelle",
|
||||
@@ -3143,7 +3142,9 @@
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "1 domaine consulté",
|
||||
"areasDoneFew": "{count} domaines consultés",
|
||||
"areasDoneOther": "{count} domaines consultés"
|
||||
"areasDoneOther": "{count} domaines consultés",
|
||||
"info": "Détails de la réponse",
|
||||
"infoModel": "Modèle : {model}"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "à l’instant",
|
||||
|
||||
+3
-2
@@ -3023,7 +3023,6 @@
|
||||
"thinkingExpandAria": "Mostra cosa ha considerato il Coach",
|
||||
"thinkingDetail": "Risposta basata sul tuo riepilogo di salute.",
|
||||
"tokensUsed": "{count} token",
|
||||
"tokensUsedWithModel": "{count} token · {model}",
|
||||
"askAboutThis": "Chiedi al coach",
|
||||
"readStrip": {
|
||||
"label": "Intervallo abituale",
|
||||
@@ -3143,7 +3142,9 @@
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "1 area consultata",
|
||||
"areasDoneFew": "{count} aree consultate",
|
||||
"areasDoneOther": "{count} aree consultate"
|
||||
"areasDoneOther": "{count} aree consultate",
|
||||
"info": "Dettagli della risposta",
|
||||
"infoModel": "Modello: {model}"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "proprio ora",
|
||||
|
||||
+3
-2
@@ -3023,7 +3023,6 @@
|
||||
"thinkingExpandAria": "코치가 참고한 내용 보기",
|
||||
"thinkingDetail": "건강 스냅샷을 바탕으로 답했어요.",
|
||||
"tokensUsed": "{count} 토큰",
|
||||
"tokensUsedWithModel": "{count} 토큰 · {model}",
|
||||
"askAboutThis": "코치에게 물어보기",
|
||||
"readStrip": {
|
||||
"label": "평소 범위",
|
||||
@@ -3143,7 +3142,9 @@
|
||||
"timeEarlier": "{date} {time}",
|
||||
"areasDoneOne": "영역 1개 확인",
|
||||
"areasDoneFew": "영역 {count}개 확인",
|
||||
"areasDoneOther": "영역 {count}개 확인"
|
||||
"areasDoneOther": "영역 {count}개 확인",
|
||||
"info": "답변 정보",
|
||||
"infoModel": "모델: {model}"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "방금",
|
||||
|
||||
+3
-2
@@ -3023,7 +3023,6 @@
|
||||
"thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę",
|
||||
"thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.",
|
||||
"tokensUsed": "{count} tokenów",
|
||||
"tokensUsedWithModel": "{count} tokenów · {model}",
|
||||
"askAboutThis": "Zapytaj trenera o to",
|
||||
"readStrip": {
|
||||
"label": "Typowy zakres",
|
||||
@@ -3143,7 +3142,9 @@
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "Sprawdzono 1 obszar",
|
||||
"areasDoneFew": "Sprawdzono {count} obszary",
|
||||
"areasDoneOther": "Sprawdzono {count} obszarów"
|
||||
"areasDoneOther": "Sprawdzono {count} obszarów",
|
||||
"info": "Szczegóły odpowiedzi",
|
||||
"infoModel": "Model: {model}"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "przed chwilą",
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* The one action row under a Coach message, and the follow-up chips that
|
||||
* live in the answer's column.
|
||||
*
|
||||
* - Assistant: copy, read aloud, try again, then the time, in one left
|
||||
* group; the tokens and model in the right group of the same row. No
|
||||
* feedback thumbs, no evidence disclosure.
|
||||
* - Assistant: copy, read aloud, try again, details, then the time, in one
|
||||
* left-aligned row; the model and tokens live behind the details icon,
|
||||
* not as text on the row. No feedback thumbs, no evidence disclosure.
|
||||
* - User: copy, remember, time in one row; remember is an icon.
|
||||
* - The time reads "14:32" today, "yesterday 14:32", or the date further
|
||||
* back.
|
||||
@@ -50,7 +50,7 @@ vi.mock("../read-aloud", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { ChatBubble, areChatBubblePropsEqual } from "../chat-bubble";
|
||||
import { answerMetaText, messageTimeText } from "../message-actions";
|
||||
import { answerInfoLines, messageTimeText } from "../message-actions";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
@@ -100,26 +100,27 @@ const ANSWER = {
|
||||
describe("assistant action row", () => {
|
||||
const html = render(<ChatBubble {...ANSWER} />);
|
||||
const row = slot(html, "coach-answer-actions");
|
||||
const left = slot(row, "coach-answer-actions-left");
|
||||
|
||||
it("lists copy, read aloud, try again and the time on the left, in that order", () => {
|
||||
it("lists copy, read aloud, try again, details and the time, in that order", () => {
|
||||
const order = [
|
||||
"coach-copy-message",
|
||||
"coach-read-aloud",
|
||||
"coach-try-again",
|
||||
"coach-answer-info",
|
||||
"coach-message-time",
|
||||
].map((name) => left.indexOf(`data-slot="${name}"`));
|
||||
].map((name) => row.indexOf(`data-slot="${name}"`));
|
||||
expect(order.every((at) => at > -1)).toBe(true);
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||
});
|
||||
|
||||
it("puts the tokens and the model on the right of the same row", () => {
|
||||
const meta = slot(row, "coach-answer-meta");
|
||||
expect(meta).toContain("1,234 tokens · gpt-4o-mini");
|
||||
expect(meta).toContain('title="gpt-4o-mini"');
|
||||
expect(meta).toContain("truncate");
|
||||
expect(left).not.toContain("coach-answer-meta");
|
||||
expect(row).toContain("justify-between");
|
||||
it("keeps the model and tokens off the row, behind a named details button", () => {
|
||||
expect(row).not.toContain("1,234");
|
||||
expect(row).not.toContain("gpt-4o-mini");
|
||||
expect(row).not.toContain("justify-between");
|
||||
expect(row).not.toContain("coach-answer-meta");
|
||||
const info = slot(row, "coach-answer-info");
|
||||
expect(info).toMatch(/^<button type="button"/);
|
||||
expect(info).toContain('aria-label="Answer details"');
|
||||
});
|
||||
|
||||
it("has no thumbs, no evidence disclosure and no separate token line", () => {
|
||||
@@ -129,19 +130,18 @@ describe("assistant action row", () => {
|
||||
});
|
||||
|
||||
it("shows the time as text, the full date for screen readers", () => {
|
||||
const time = slot(left, "coach-message-time");
|
||||
const time = slot(row, "coach-message-time");
|
||||
expect(time).toMatch(/^<time[^>]*dateTime="/);
|
||||
expect(time).toContain("Message sent");
|
||||
expect(time).toMatch(/<span aria-hidden="true">[^<]*\d:\d{2}[^<]*<\/span>/);
|
||||
});
|
||||
|
||||
it("keeps an empty right group when the answer has no count", () => {
|
||||
it("offers no details button when neither the model nor the count is known", () => {
|
||||
const bare = render(
|
||||
<ChatBubble {...ANSWER} tokensUsed={null} model={null} />,
|
||||
);
|
||||
expect(slot(bare, "coach-answer-meta")).toMatch(
|
||||
/<p data-slot="coach-answer-meta"[^>]*><\/p>/,
|
||||
);
|
||||
expect(bare).toContain("coach-answer-actions");
|
||||
expect(bare).not.toContain("coach-answer-info");
|
||||
});
|
||||
|
||||
it("is absent on a refusal and while the answer is in flight", () => {
|
||||
@@ -276,17 +276,29 @@ describe("messageTimeText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("answerMetaText", () => {
|
||||
describe("answerInfoLines", () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
|
||||
it("formats the count for the locale and adds the model", () => {
|
||||
expect(answerMetaText(1234, "gpt-4o", "en-US", t)).toBe(
|
||||
"1,234 tokens · gpt-4o",
|
||||
);
|
||||
expect(answerMetaText(1234, null, "de-DE", t)).toBe("1.234 tokens");
|
||||
it("names the model, then the count formatted for the locale", () => {
|
||||
expect(answerInfoLines(1234, "gpt-4o", "en-US", t)).toEqual([
|
||||
"Model: gpt-4o",
|
||||
"1,234 tokens",
|
||||
]);
|
||||
expect(answerInfoLines(1234, null, "de-DE", t)).toEqual(["1.234 tokens"]);
|
||||
});
|
||||
|
||||
it("is nothing without a count", () => {
|
||||
expect(answerMetaText(null, "gpt-4o", "en-US", t)).toBeNull();
|
||||
it("keeps the model without a count, and is empty without either", () => {
|
||||
expect(answerInfoLines(null, "gpt-4o", "en-US", t)).toEqual([
|
||||
"Model: gpt-4o",
|
||||
]);
|
||||
expect(answerInfoLines(null, null, "en-US", t)).toEqual([]);
|
||||
});
|
||||
|
||||
it("speaks the reader's language", () => {
|
||||
const de = getServerTranslator("de").t;
|
||||
expect(answerInfoLines(1234, "gpt-4o", "de-DE", de)).toEqual([
|
||||
"Modell: gpt-4o",
|
||||
"1.234 Tokens",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,10 @@
|
||||
/**
|
||||
* The one action row under a Coach message, for both roles.
|
||||
*
|
||||
* Assistant: a single line. Left, the icon actions in reading order (copy,
|
||||
* read aloud, try again) followed by the time the answer was written; right,
|
||||
* the tokens and model the answer cost, as meta. The right group truncates
|
||||
* before the row would wrap, so the row is one line at 390 px too.
|
||||
* Assistant: a single line, left-aligned. The icon actions in reading order
|
||||
* (copy, read aloud, try again, details) followed by the time the answer was
|
||||
* written. The details icon holds the model and the tokens the answer cost
|
||||
* in a tooltip that opens on hover, on keyboard focus and on a tap.
|
||||
*
|
||||
* User: copy, remember, time, flush with the bubble's right edge.
|
||||
*
|
||||
@@ -15,7 +15,7 @@
|
||||
* `opacity-0` rather than `invisible` keeps every control focusable, and
|
||||
* focus reveals the row.
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -23,11 +23,18 @@ import {
|
||||
BookmarkPlus,
|
||||
Check,
|
||||
Copy,
|
||||
Info,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { Formatters } from "@/lib/format-locale";
|
||||
@@ -85,27 +92,30 @@ export function messageTimeText(
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer's cost as one meta string: tokens with the model when both are
|
||||
* known, tokens alone otherwise, nothing without a count. The count is the
|
||||
* server's (`done.usage` live, `tokensUsed` on reload), formatted for the
|
||||
* reader's locale.
|
||||
* What the details tooltip says about an answer: the model that wrote it and
|
||||
* the tokens it cost, one line each, in that order, each only when known. The
|
||||
* count is the server's (`done.usage` live, `tokensUsed` on reload),
|
||||
* formatted for the reader's locale. Empty when neither is known, and then
|
||||
* the row offers no details icon at all.
|
||||
*/
|
||||
export function answerMetaText(
|
||||
export function answerInfoLines(
|
||||
tokens: number | null | undefined,
|
||||
model: string | null | undefined,
|
||||
locale: string,
|
||||
t: Translate,
|
||||
): string | null {
|
||||
if (tokens == null) return null;
|
||||
let count: string;
|
||||
try {
|
||||
count = new Intl.NumberFormat(locale).format(tokens);
|
||||
} catch {
|
||||
count = String(tokens);
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
if (model) lines.push(t("insights.coach.answer.infoModel", { model }));
|
||||
if (tokens != null) {
|
||||
let count: string;
|
||||
try {
|
||||
count = new Intl.NumberFormat(locale).format(tokens);
|
||||
} catch {
|
||||
count = String(tokens);
|
||||
}
|
||||
lines.push(t("insights.coach.tokensUsed", { count }));
|
||||
}
|
||||
return model
|
||||
? t("insights.coach.tokensUsedWithModel", { count, model })
|
||||
: t("insights.coach.tokensUsed", { count });
|
||||
return lines;
|
||||
}
|
||||
|
||||
function CopyMessageButton({
|
||||
@@ -170,6 +180,58 @@ function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The model and token count of an answer, behind an info icon. A tooltip,
|
||||
* opened the three ways a reader can reach it: hovering the icon, focusing
|
||||
* it from the keyboard, and tapping it on a touch screen, where there is no
|
||||
* hover. A second tap, a tap elsewhere or Escape closes it.
|
||||
*
|
||||
* The tap is handled here because the tooltip primitive closes on every
|
||||
* click: the click toggles from the state the tooltip had when the pointer
|
||||
* went down, before the primitive's own pointer-down close ran.
|
||||
*/
|
||||
function AnswerInfoButton({ lines }: { lines: string[] }) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const openAtPointerDown = useRef<boolean | null>(null);
|
||||
const label = t("insights.coach.answer.info");
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip open={open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-answer-info"
|
||||
aria-label={label}
|
||||
className={COACH_ICON_BUTTON}
|
||||
onPointerDown={() => {
|
||||
openAtPointerDown.current = open;
|
||||
}}
|
||||
onClick={(event) => {
|
||||
// Keep the primitive from closing it again on this click.
|
||||
event.preventDefault();
|
||||
const was = openAtPointerDown.current;
|
||||
openAtPointerDown.current = null;
|
||||
setOpen(was === null ? (current) => !current : !was);
|
||||
}}
|
||||
>
|
||||
<Info className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
data-slot="coach-answer-info-content"
|
||||
className="flex flex-col gap-0.5 tabular-nums"
|
||||
>
|
||||
{lines.map((line) => (
|
||||
<span key={line}>{line}</span>
|
||||
))}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the message was written, as text. The visible run is short ("14:32");
|
||||
* the full date and time ride the `title` and the screen-reader text.
|
||||
@@ -293,34 +355,23 @@ export function AssistantMessageActions({
|
||||
model,
|
||||
}: AssistantMessageActionsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const meta = answerMetaText(tokens, model, resolveIntlLocale(locale), t);
|
||||
const info = answerInfoLines(tokens, model, resolveIntlLocale(locale), t);
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-answer-actions"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center justify-between gap-2 self-stretch",
|
||||
"flex w-full min-w-0 items-center gap-0.5 self-stretch",
|
||||
REVEAL.assistant,
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-slot="coach-answer-actions-left"
|
||||
className="flex shrink-0 items-center gap-0.5"
|
||||
>
|
||||
<CopyMessageButton content={content} strip />
|
||||
{!streaming && <ReadAloudButton content={content} />}
|
||||
{!streaming && onRegenerate && (
|
||||
<TryAgainButton onRegenerate={onRegenerate} />
|
||||
)}
|
||||
{createdAt && <MessageTime iso={createdAt} />}
|
||||
</div>
|
||||
<p
|
||||
data-slot="coach-answer-meta"
|
||||
title={model ?? undefined}
|
||||
className="text-muted-foreground min-w-0 truncate text-right text-xs tabular-nums"
|
||||
>
|
||||
{meta}
|
||||
</p>
|
||||
<CopyMessageButton content={content} strip />
|
||||
{!streaming && <ReadAloudButton content={content} />}
|
||||
{!streaming && onRegenerate && (
|
||||
<TryAgainButton onRegenerate={onRegenerate} />
|
||||
)}
|
||||
{!streaming && info.length > 0 && <AnswerInfoButton lines={info} />}
|
||||
{createdAt && <MessageTime iso={createdAt} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user