Merge branch 'wave/v1221-c' into release/v1.22.1

This commit is contained in:
Marc-André Bombeck committed 2026-06-27 18:07:16 +02:00
commit 802ea1c60a
12 files changed
+416 -41

No files matched your search

+4 -1
View File
@@ -2094,12 +2094,15 @@
"send": "Senden",
"stop": "Stopp",
"thinking": "Denke nach…",
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
"composerPlaceholder": "Frag mich etwas …",
"nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen",
"fabLabel": "Coach öffnen",
"readAloud": "Vorlesen",
"readAloudStop": "Vorlesen beenden",
"messageTimeLabel": "Nachricht gesendet {time}",
"copyMessage": "Kopieren",
"copyMessageError": "Kopieren fehlgeschlagen",
"regenerate": "Erneut versuchen",
"selfContextAdopt": {
"offer": "Antwort in deine Selbstauskunft übernehmen?",
"confirm": "Übernehmen",
+4 -1
View File
@@ -2094,12 +2094,15 @@
"send": "Send",
"stop": "Stop",
"thinking": "Thinking…",
"composerPlaceholder": "Ask anything about your data…",
"composerPlaceholder": "Ask me anything …",
"nudgeBubbleLabel": "New message from the Coach — open chat",
"fabLabel": "Open the Coach",
"readAloud": "Read aloud",
"readAloudStop": "Stop reading",
"messageTimeLabel": "Message sent {time}",
"copyMessage": "Copy",
"copyMessageError": "Could not copy",
"regenerate": "Try again",
"selfContextAdopt": {
"offer": "Add this answer to your self-context?",
"confirm": "Add",
+4 -1
View File
@@ -2094,12 +2094,15 @@
"send": "Enviar",
"stop": "Detener",
"thinking": "Pensando…",
"composerPlaceholder": "Pregúntame sobre tus datos…",
"composerPlaceholder": "Pregúntame algo …",
"nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat",
"fabLabel": "Abrir el coach",
"readAloud": "Leer en voz alta",
"readAloudStop": "Detener lectura",
"messageTimeLabel": "Mensaje enviado {time}",
"copyMessage": "Copiar",
"copyMessageError": "No se pudo copiar",
"regenerate": "Reintentar",
"selfContextAdopt": {
"offer": "¿Añadir esta respuesta a tu información personal?",
"confirm": "Añadir",
+4 -1
View File
@@ -2094,12 +2094,15 @@
"send": "Envoyer",
"stop": "Arrêter",
"thinking": "Réflexion en cours…",
"composerPlaceholder": "Pose-moi une question sur tes données…",
"composerPlaceholder": "Pose ta question …",
"nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat",
"fabLabel": "Ouvrir le coach",
"readAloud": "Lire à voix haute",
"readAloudStop": "Arrêter la lecture",
"messageTimeLabel": "Message envoyé {time}",
"copyMessage": "Copier",
"copyMessageError": "Copie impossible",
"regenerate": "Réessayer",
"selfContextAdopt": {
"offer": "Ajouter cette réponse à vos informations personnelles ?",
"confirm": "Ajouter",
+4 -1
View File
@@ -2094,12 +2094,15 @@
"send": "Invia",
"stop": "Interrompi",
"thinking": "Sto pensando…",
"composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…",
"composerPlaceholder": "Chiedimi pure …",
"nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat",
"fabLabel": "Apri il coach",
"readAloud": "Leggi ad alta voce",
"readAloudStop": "Interrompi lettura",
"messageTimeLabel": "Messaggio inviato {time}",
"copyMessage": "Copia",
"copyMessageError": "Copia non riuscita",
"regenerate": "Riprova",
"selfContextAdopt": {
"offer": "Aggiungere questa risposta alle tue informazioni personali?",
"confirm": "Aggiungi",
+4 -1
View File
@@ -2094,12 +2094,15 @@
"send": "Wyślij",
"stop": "Zatrzymaj",
"thinking": "Myślę…",
"composerPlaceholder": "Zapytaj o swoje dane…",
"composerPlaceholder": "Zapytaj mnie …",
"nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat",
"fabLabel": "Otwórz trenera",
"readAloud": "Czytaj na głos",
"readAloudStop": "Zatrzymaj czytanie",
"messageTimeLabel": "Wiadomość wysłana {time}",
"copyMessage": "Kopiuj",
"copyMessageError": "Nie udało się skopiować",
"regenerate": "Spróbuj ponownie",
"selfContextAdopt": {
"offer": "Dodać tę odpowiedź do Twoich informacji o sobie?",
"confirm": "Dodaj",
@@ -49,7 +49,7 @@ describe("<CoachInput>", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toContain("Ask anything about your data");
expect(html).toContain("Ask me anything");
expect(html).not.toContain('data-slot="coach-input-hint"');
expect(html).not.toContain("Press Enter to send");
expect(html).toContain('data-slot="coach-input-send"');
@@ -60,7 +60,7 @@ describe("<CoachInput>", () => {
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
"de",
);
expect(html).toContain("Frag mich etwas zu deinen Daten");
expect(html).toContain("Frag mich etwas");
});
it("disables the send button when the value is empty", () => {
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { pickSpeechVoice, scoreSpeechVoice } from "../message-thread";
type V = { name: string; lang: string; localService?: boolean };
const asVoices = (vs: V[]) => vs as unknown as SpeechSynthesisVoice[];
describe("scoreSpeechVoice", () => {
it("rejects voices that do not match the target language", () => {
expect(scoreSpeechVoice({ name: "Samantha", lang: "en-US" }, "de-DE")).toBe(
-Infinity,
);
});
it("ranks an enhanced/premium voice above a legacy compact one", () => {
const enhanced = scoreSpeechVoice(
{ name: "Samantha (Enhanced)", lang: "en-US" },
"en-US",
);
const compact = scoreSpeechVoice(
{ name: "Albert", lang: "en-US" },
"en-US",
);
expect(enhanced).toBeGreaterThan(compact);
expect(compact).toBeLessThan(0);
});
it("rewards an exact lang-region match over a bare language match", () => {
const region = scoreSpeechVoice({ name: "Google", lang: "de-DE" }, "de-DE");
const bare = scoreSpeechVoice({ name: "Google", lang: "de-AT" }, "de-DE");
expect(region).toBeGreaterThan(bare);
});
});
describe("pickSpeechVoice", () => {
it("returns the best-scoring voice for the locale", () => {
const chosen = pickSpeechVoice(
asVoices([
{ name: "Anna", lang: "de-DE", localService: true },
{ name: "Google Deutsch", lang: "de-DE" },
{ name: "Samantha", lang: "en-US" },
]),
"de-DE",
);
expect(chosen?.name).toBe("Google Deutsch");
});
it("returns null when nothing beats a legacy default", () => {
const chosen = pickSpeechVoice(
asVoices([{ name: "Albert", lang: "en-US" }]),
"en-US",
);
expect(chosen).toBeNull();
});
it("returns null on an empty voice list (graceful fallback)", () => {
expect(pickSpeechVoice(asVoices([]), "en-US")).toBeNull();
});
});
@@ -340,6 +340,25 @@ export function CoachConversation({
}
}
// v1.22 — "Try again": regenerate an assistant reply by resubmitting the
// user turn that produced it as a FRESH turn. The composer value is left
// untouched (unlike `handleSubmit`, which clears it) so a half-typed
// follow-up survives a regenerate. The existing thread is the conversation
// context, so the new turn continues it rather than forking.
function handleRegenerate(userText: string) {
const trimmed = userText.trim();
if (!trimmed || send.isStreaming) return;
const scope =
currentConversationId === null
? launchScopeToCoachScope(launchScope)
: undefined;
void send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
scope,
});
}
function handleNewChat() {
setCurrentConversationId(null);
setInputValue("");
@@ -618,6 +637,7 @@ export function CoachConversation({
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
/>
</div>
{/* Docked composer — the SAME centred, capped column as the
@@ -722,6 +742,7 @@ export function CoachConversation({
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
/>
}
composer={composerStack}
@@ -567,6 +567,11 @@ export function CoachInput({
// scroll regions). Scoped here, not in globals.css.
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_35%,transparent)_transparent]",
"placeholder:text-muted-foreground disabled:opacity-60",
// Keep the placeholder a single line on narrow phones: a long
// hint used to wrap to two lines inside the one-row composer and
// read as a cramped block. Clip the PLACEHOLDER pseudo only — the
// textarea value still wraps and auto-grows for real typing.
"placeholder:overflow-hidden placeholder:text-ellipsis placeholder:whitespace-nowrap",
)}
/>
{showHub ? (
@@ -100,10 +100,10 @@ export function HistoryRail({
{t("insights.coach.historyTitle")}
</h3>
)}
<div className="relative">
<div className="relative w-full">
<Search
aria-hidden="true"
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
/>
<Input
type="search"
@@ -111,7 +111,9 @@ export function HistoryRail({
onChange={(e) => setFilter(e.target.value)}
placeholder={t("insights.coach.historySearchPlaceholder")}
data-slot="coach-history-search"
className="h-9 pl-7 text-sm"
// Full-width, comfortable field (matches the standalone
// conversations page) instead of the old thin h-9 strip.
className="h-10 w-full pl-9"
/>
</div>
<div
@@ -19,7 +19,9 @@ import {
Check,
ChevronRight,
Clock,
Copy,
Loader2,
RotateCcw,
Sparkles,
ThumbsDown,
ThumbsUp,
@@ -98,6 +100,12 @@ export interface MessageThreadProps {
* unanchored items render at the thread tail. See `placeInterleaved`.
*/
interleaved?: InterleavedThreadItem[];
/**
* v1.22 — "Try again" on an assistant turn. The thread resolves the user
* message that produced the reply and hands its text up; the surface
* resubmits it as a fresh turn. Omitted → the regenerate action is hidden.
*/
onRegenerate?: (userText: string) => void;
}
/**
@@ -383,12 +391,87 @@ function useSpeechSynthesisSupported(): boolean {
);
}
/**
* v1.22 — score a synthesis voice for a target language. Higher is better;
* a negative score marks a legacy/compact voice we would rather skip. Pure
* (no browser globals) so the ranking contract is unit-testable. Exported
* for the read-aloud voice-selection test.
*
* The default OS voice is often a legacy "compact" engine (Anna, Albert,
* Zarvox, eSpeak) that reads robotically. We prefer, in order: an Apple
* enhanced/premium or Siri voice, a Microsoft "Natural"/"Online" (Edge)
* voice, a "Google …" (Chrome) voice, and a small set of known-good names
* (Samantha). An exact lang-REGION match (de-DE/en-US) outweighs a bare
* language match. `localService` only breaks a tie. Known-bad names are
* penalised so they never win over a neutral remote voice.
*/
export function scoreSpeechVoice(
voice: { name: string; lang: string; localService?: boolean },
lang: string,
): number {
const base = lang.split("-")[0].toLowerCase();
const vlang = voice.lang.toLowerCase().replace("_", "-");
if (!vlang.startsWith(base)) return -Infinity;
const name = voice.name.toLowerCase();
let score = 0;
// Exact lang-REGION match (de-DE, en-US) over a bare language match.
if (lang.includes("-") && vlang === lang.toLowerCase()) score += 40;
// High-quality engines.
if (/\b(enhanced|premium)\b/.test(name)) score += 60;
if (name.includes("siri")) score += 55;
if (name.includes("natural") || name.includes("online")) score += 50;
if (name.startsWith("google")) score += 35;
if (name.includes("samantha")) score += 30;
// Known-bad legacy / compact engines.
if (name.includes("compact")) score -= 80;
if (/\b(albert|zarvox|espeak|anna|fred|ralph|bahh|trinoids)\b/.test(name)) {
score -= 100;
}
// Tie-breaker only: a local voice has no network latency.
if (voice.localService) score += 1;
return score;
}
/**
* v1.22 — pick the best-scoring installed voice for `lang`, or null when
* none beats a legacy/compact default (graceful fallback to the browser
* default). Pure helper, exported for the ranking test.
*/
export function pickSpeechVoice(
voices: SpeechSynthesisVoice[],
lang: string,
): SpeechSynthesisVoice | null {
let best: SpeechSynthesisVoice | null = null;
let bestScore = 0; // require a positive score to override the default
for (const v of voices) {
const s = scoreSpeechVoice(v, lang);
if (s > bestScore) {
bestScore = s;
best = v;
}
}
return best;
}
/**
* v1.22 (W5) — read the assistant prose aloud via the browser's
* `SpeechSynthesis`. Client-only, no dependency, no egress, CSP-clean. A
* single in-flight utterance: a new `speak` cancels any prior one; `toggle`
* stops while speaking. The utterance language follows the active UI locale
* so the browser picks the closest installed voice (best-effort by OS).
* stops while speaking.
*
* v1.22 — natural-voice selection. The browser default is frequently a
* legacy/compact engine that reads robotically; instead of only stamping
* `utterance.lang` we enumerate `getVoices()`, rank the candidates for the
* locale (`pickSpeechVoice`), and assign the winner. The voice list is async
* (empty on the first call, and on iOS Safari it can stay empty until the
* first user-gesture speak), so we subscribe to `voiceschanged` and also
* re-read it lazily on the first toggle. The chosen voice is cached per
* locale in a ref so the ranking runs once.
*/
function useReadAloud(): {
supported: boolean;
@@ -397,6 +480,25 @@ function useReadAloud(): {
} {
const supported = useSpeechSynthesisSupported();
const [speaking, setSpeaking] = useState(false);
// Cache the resolved voice per locale so the ranking runs once.
const voiceByLocaleRef = useRef<Map<string, SpeechSynthesisVoice | null>>(
new Map(),
);
// Pre-warm the voice list + keep it fresh: the list arrives asynchronously
// and `voiceschanged` fires when the OS finishes loading installed voices.
useEffect(() => {
if (!supported) return;
const synth = window.speechSynthesis;
const refresh = () => {
// A late-arriving list can surface a better voice than an early read;
// clear the cache so the next toggle re-ranks against the full set.
voiceByLocaleRef.current.clear();
};
synth.getVoices();
synth.addEventListener?.("voiceschanged", refresh);
return () => synth.removeEventListener?.("voiceschanged", refresh);
}, [supported]);
// Stop any in-flight utterance if the bubble unmounts (thread reset, nav).
useEffect(() => {
@@ -407,6 +509,21 @@ function useReadAloud(): {
};
}, []);
const resolveVoice = useCallback(
(synth: SpeechSynthesis, lang: string): SpeechSynthesisVoice | null => {
const cached = voiceByLocaleRef.current.get(lang);
if (cached !== undefined) return cached;
const voices = synth.getVoices();
// An empty list (first call / iOS pre-gesture) is not cached so a later
// toggle re-ranks once the OS has populated the voices.
if (voices.length === 0) return null;
const chosen = pickSpeechVoice(voices, lang);
voiceByLocaleRef.current.set(lang, chosen);
return chosen;
},
[],
);
const toggle = useCallback(
(text: string, lang: string) => {
if (!supported) return;
@@ -420,18 +537,39 @@ function useReadAloud(): {
if (!trimmed) return;
synth.cancel();
const utterance = new SpeechSynthesisUtterance(trimmed);
utterance.lang = lang;
const voice = resolveVoice(synth, lang);
if (voice) {
utterance.voice = voice;
utterance.lang = voice.lang;
} else {
utterance.lang = lang;
}
// A natural cadence: default rate, neutral pitch.
utterance.rate = 1.0;
utterance.pitch = 1.0;
utterance.onend = () => setSpeaking(false);
utterance.onerror = () => setSpeaking(false);
setSpeaking(true);
synth.speak(utterance);
},
[supported, speaking],
[supported, speaking, resolveVoice],
);
return { supported, speaking, toggle };
}
/**
* v1.22 — shared styling for the icon-only per-message action buttons. One
* tap convention across Copy / Read-aloud / feedback / Try-again: a 44px
* mobile tap floor (WCAG 2.5.5) collapsing to a compact 32px desktop target,
* muted until hover/focus, with the standard focus ring.
*/
const COACH_ICON_BUTTON = cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex size-11 items-center justify-center rounded outline-none",
"focus-visible:ring-2 disabled:opacity-50 sm:size-8",
);
/**
* v1.22 (W5) — read-aloud toggle for a settled assistant turn. Hidden when
* the browser has no Speech Synthesis support. Speaks `stripChartTokens`
@@ -452,20 +590,83 @@ function ReadAloudButton({ content }: { content: string }) {
aria-label={label}
aria-pressed={speaking}
title={label}
// Interactive bubble affordances share one tap convention:
// `min-h-11 sm:min-h-9` (44px mobile floor → 36px desktop), matching the
// feedback buttons below. Keep TTS / remember / feedback in lockstep.
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-1.5 py-1 text-xs outline-none focus-visible:ring-2 sm:min-h-9"
className={COACH_ICON_BUTTON}
>
{speaking ? (
<VolumeX className="size-3" aria-hidden="true" />
<VolumeX className="size-3.5" aria-hidden="true" />
) : (
<Volume2 className="size-3" aria-hidden="true" />
<Volume2 className="size-3.5" aria-hidden="true" />
)}
</button>
);
}
/**
* v1.22 — copy a message to the clipboard. Assistant prose is copied through
* `stripChartTokens` (the same text the bubble shows) so inline chart tokens
* are never pasted; user text is verbatim. A brief check-mark + toast confirm
* the copy. Hidden when the Clipboard API is unavailable (insecure context).
*/
function CopyMessageButton({
content,
strip,
}: {
content: string;
strip: boolean;
}) {
const { t } = useTranslations();
const [copied, setCopied] = useState(false);
const handle = useCallback(async () => {
const text = strip ? stripChartTokens(content) : content;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t("insights.coach.copyMessageError"));
}
}, [content, strip, t]);
const label = t("insights.coach.copyMessage");
return (
<button
type="button"
data-slot="coach-copy-message"
onClick={handle}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
{copied ? (
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
</button>
);
}
/**
* v1.22 — "Try again": re-run the user turn that produced this assistant
* reply so the user can regenerate an unsatisfying answer. The thread hands
* down the preceding user message; the surface resubmits it as a fresh turn.
*/
function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) {
const { t } = useTranslations();
const label = t("insights.coach.regenerate");
return (
<button
type="button"
data-slot="coach-try-again"
onClick={onRegenerate}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
<RotateCcw className="size-3.5" aria-hidden="true" />
</button>
);
}
/**
* v1.22 (W5) — hover/tap timestamp for a persisted message bubble. A small,
* calm clock affordance; the locale-aware date + time surfaces in a tooltip
@@ -518,6 +719,7 @@ export function MessageThread({
optimisticUser,
emptyHint,
interleaved,
onRegenerate,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef<HTMLDivElement | null>(null);
@@ -709,7 +911,7 @@ export function MessageThread({
COACH_SCROLLBAR,
)}
>
{messages.map((m) => {
{messages.map((m, idx) => {
// v1.4.22 W5 reconcile (Code-MED-3) — suppress the persisted
// twin during the 150ms grace window so the streaming bubble
// stays alone on slow connections.
@@ -717,6 +919,18 @@ export function MessageThread({
// v1.16.5 — a guided question renders directly above the user
// message that answered it.
const guidedBefore = placement.before.get(m.id);
// v1.22 — "Try again": resolve the user message that produced this
// assistant reply (the nearest preceding user turn) so the surface
// can resubmit it. Null when there is none → no regenerate action.
let precedingUserContent: string | null = null;
if (m.role === "assistant" && onRegenerate) {
for (let j = idx - 1; j >= 0; j--) {
if (messages[j].role === "user") {
precedingUserContent = messages[j].content;
break;
}
}
}
return (
<Fragment key={m.id}>
{guidedBefore?.node}
@@ -729,6 +943,11 @@ export function MessageThread({
tokensUsed={m.tokensUsed}
model={m.model}
createdAt={m.createdAt}
onRegenerate={
precedingUserContent !== null && onRegenerate
? () => onRegenerate(precedingUserContent as string)
: undefined
}
/>
</Fragment>
);
@@ -840,6 +1059,12 @@ interface ChatBubbleProps {
* (no persisted time yet), so those render no timestamp.
*/
createdAt?: string;
/**
* v1.22 — bound "Try again" callback for a settled assistant turn (the
* thread closes over the preceding user message). Absent on user / streaming
* / refusal turns and when the surface supplies no regenerate handler.
*/
onRegenerate?: () => void;
}
function ChatBubble({
@@ -857,6 +1082,7 @@ function ChatBubble({
tokensUsed,
model,
createdAt,
onRegenerate,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
@@ -906,7 +1132,22 @@ function ChatBubble({
is verbatim: no chart-token strip, no Learn linkify. */}
<ProseBlocks text={content} strip={false} linkify={false} />
</div>
{createdAt && <BubbleTimestamp iso={createdAt} align="end" />}
{/* v1.22 — per-message action row: Copy, then the timestamp
trailing. Muted until the bubble is hovered / focused on pointer
devices; always visible on touch (no hover to reveal it). */}
<div
data-slot="coach-bubble-actions"
className={cn(
"flex items-center justify-end gap-0.5",
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<CopyMessageButton content={content} strip={false} />
{createdAt && <BubbleTimestamp iso={createdAt} align="end" />}
</div>
{/* v1.16.8 — explicit remember control. Stating an allergy in
chat used to leave no durable trace unless a narrow
pattern pass happened to match the phrasing; this stores
@@ -982,7 +1223,7 @@ function ChatBubble({
return (
<div
data-slot="coach-bubble-assistant"
className="flex items-start gap-2.5"
className="group/assistant-bubble flex items-start gap-2.5"
>
<div
aria-hidden="true"
@@ -1195,18 +1436,29 @@ function ChatBubble({
model={usage?.model ?? model}
/>
)}
{/* v1.4.23 H7 — per-message thumbs feedback. Only persisted
assistant messages get the row (skipped for refusals,
errors, in-flight stream bubbles). v1.22 (W5) — the
read-aloud toggle joins the same action row on a settled turn;
the bubble timestamp trails it. */}
{/* v1.22 — per-message hover action row. Icon-only, muted until the
bubble is hovered / focused on pointer devices; always visible on
touch (no hover to reveal it). Copy + Read-aloud + Good / Bad
feedback + Try-again, with the timestamp trailing. Only settled
persisted assistant turns get the row (skipped for refusals,
errors, in-flight stream bubbles). */}
{!inProgress && !errorCode && providerType !== "refusal" && content && (
<div
data-slot="coach-bubble-actions"
className="flex items-center gap-2"
className={cn(
"flex items-center gap-0.5",
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/assistant-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/assistant-bubble:opacity-100",
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<CopyMessageButton content={content} strip />
{!streaming && <ReadAloudButton content={content} />}
{messageId && <CoachMessageFeedback messageId={messageId} />}
{!streaming && onRegenerate && (
<TryAgainButton onRegenerate={onRegenerate} />
)}
{createdAt && <BubbleTimestamp iso={createdAt} />}
</div>
)}
@@ -1376,38 +1628,55 @@ function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
},
});
// v1.22 — icon-only feedback in the per-message action row. After a rating
// lands, the chosen thumb stays visible in its confirming colour (the other
// is dropped) with an `sr-only` thanks so the signal is legible without a
// text caption breaking the icon row.
if (submittedRating) {
return (
<p
<span
data-slot="coach-message-feedback-thanks"
className="text-muted-foreground text-xs"
role="status"
className="inline-flex items-center"
>
{t("insights.coach.feedbackThanks")}
</p>
<span className="sr-only">{t("insights.coach.feedbackThanks")}</span>
{submittedRating === "helpful" ? (
<ThumbsUp className="text-success size-3.5" aria-hidden="true" />
) : (
<ThumbsDown className="text-warning size-3.5" aria-hidden="true" />
)}
</span>
);
}
const helpfulLabel = t("insights.coach.feedbackHelpful");
const unhelpfulLabel = t("insights.coach.feedbackUnhelpful");
return (
<div data-slot="coach-message-feedback" className="flex items-center gap-2">
<div
data-slot="coach-message-feedback"
className="inline-flex items-center"
>
<button
type="button"
data-slot="coach-message-feedback-helpful"
onClick={() => submit.mutate("helpful")}
disabled={submit.isPending}
className="text-muted-foreground hover:text-success focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
aria-label={helpfulLabel}
title={helpfulLabel}
className={cn(COACH_ICON_BUTTON, "hover:text-success")}
>
<ThumbsUp className="size-3" aria-hidden="true" />
{t("insights.coach.feedbackHelpful")}
<ThumbsUp className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
data-slot="coach-message-feedback-unhelpful"
onClick={() => submit.mutate("unhelpful")}
disabled={submit.isPending}
className="text-muted-foreground hover:text-warning focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
aria-label={unhelpfulLabel}
title={unhelpfulLabel}
className={cn(COACH_ICON_BUTTON, "hover:text-warning")}
>
<ThumbsDown className="size-3" aria-hidden="true" />
{t("insights.coach.feedbackUnhelpful")}
<ThumbsDown className="size-3.5" aria-hidden="true" />
</button>
</div>
);