From 37339cf83e978c2bf86ba2003c12a7d124c3c8b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 18:03:55 +0200 Subject: [PATCH 1/4] fix(coach): keep the composer placeholder on one line Shorten the composer placeholder across all locales to a short, inviting one-liner and clip the placeholder pseudo-element so a longer hint can never wrap to a second line inside the single-row composer on a narrow phone. The textarea value still wraps and auto-grows for real typing. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- .../insights/coach-panel/__tests__/coach-input.test.tsx | 4 ++-- src/components/insights/coach-panel/coach-input.tsx | 5 +++++ 8 files changed, 13 insertions(+), 8 deletions(-) diff --git a/messages/de.json b/messages/de.json index 8a3a5fb4c..131190eb0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2094,7 +2094,7 @@ "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", diff --git a/messages/en.json b/messages/en.json index 06b38c3e8..8ac83d7c3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2094,7 +2094,7 @@ "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", diff --git a/messages/es.json b/messages/es.json index 0b49cd985..20fdb7c66 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2094,7 +2094,7 @@ "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", diff --git a/messages/fr.json b/messages/fr.json index 971a1bbd8..4589b0b27 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2094,7 +2094,7 @@ "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", diff --git a/messages/it.json b/messages/it.json index 4b762cd6b..07dd42fc6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2094,7 +2094,7 @@ "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", diff --git a/messages/pl.json b/messages/pl.json index 664af0d8d..1cf163931 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2094,7 +2094,7 @@ "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", diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index d54280561..088e7d0f5 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -49,7 +49,7 @@ describe("", () => { const html = render( {}} 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("", () => { {}} 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", () => { diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index dfa9540a0..31f01e4d6 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -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 ? ( From bd1a5e2da5c226dcb82e62dc58e1fcdcd15c352a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 18:04:01 +0200 Subject: [PATCH 2/4] fix(coach): widen the conversation search field The history search rendered as a thin, short strip that read as an afterthought. Give it the app's standard full-width input height and padding so it matches the standalone conversations page and looks intentional. --- src/components/insights/coach-panel/history-rail.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index 0b7133e6c..f450ca08f 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -100,10 +100,10 @@ export function HistoryRail({ {t("insights.coach.historyTitle")} )} -
+
Date: Sat, 27 Jun 2026 18:05:09 +0200 Subject: [PATCH 3/4] feat(coach): select a natural speech voice for read-aloud Read-aloud only set the utterance language and let the browser fall back to its default voice, which on most systems is a legacy compact engine that reads robotically. Enumerate the installed voices, rank them for the active locale (prefer enhanced/premium, Siri, Microsoft Natural/Online, Google, and an exact lang-region match; penalise known compact engines), and assign the winner with a natural rate and pitch. The voice list loads asynchronously, so subscribe to voiceschanged and re-rank lazily; when no voice beats the legacy default, fall back gracefully to prior behaviour. --- .../__tests__/read-aloud-voice.test.ts | 60 +++++++++ .../insights/coach-panel/message-thread.tsx | 126 +++++++++++++++++- 2 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/read-aloud-voice.test.ts diff --git a/src/components/insights/coach-panel/__tests__/read-aloud-voice.test.ts b/src/components/insights/coach-panel/__tests__/read-aloud-voice.test.ts new file mode 100644 index 000000000..210083104 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/read-aloud-voice.test.ts @@ -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(); + }); +}); diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index b9cbd0be3..63f624e33 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -383,12 +383,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 +472,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>( + 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 +501,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,13 +529,22 @@ 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 }; From 66e3742cd20e45b9bec31144538c8220ea494cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 18:05:19 +0200 Subject: [PATCH 4/4] feat(coach): add a per-message hover action row (copy, feedback, regenerate, read-aloud) Surface an icon-only action row below each message, sharing the row with the hover timestamp: muted until the bubble is hovered or focused on pointer devices, and always visible on touch where there is no hover. User turns get Copy; assistant turns get Copy, Read-aloud, a thumbs-up / thumbs-down feedback pair (wired to the existing per-message feedback endpoint), and Try-again. Copy strips inline chart tokens from assistant prose so they are never pasted. Try-again resubmits the user turn that produced the reply as a fresh turn, leaving the composer untouched. --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/pl.json | 3 + .../coach-panel/coach-conversation.tsx | 21 ++ .../insights/coach-panel/message-thread.tsx | 203 +++++++++++++++--- 8 files changed, 216 insertions(+), 26 deletions(-) diff --git a/messages/de.json b/messages/de.json index 131190eb0..3969c450f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2100,6 +2100,9 @@ "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", diff --git a/messages/en.json b/messages/en.json index 8ac83d7c3..1e116bd27 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2100,6 +2100,9 @@ "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", diff --git a/messages/es.json b/messages/es.json index 20fdb7c66..250b70c7f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2100,6 +2100,9 @@ "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", diff --git a/messages/fr.json b/messages/fr.json index 4589b0b27..7bf0c93ac 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2100,6 +2100,9 @@ "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", diff --git a/messages/it.json b/messages/it.json index 07dd42fc6..09ed93a64 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2100,6 +2100,9 @@ "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", diff --git a/messages/pl.json b/messages/pl.json index 1cf163931..43aefb785 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2100,6 +2100,9 @@ "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", diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 3dc1dd5b2..5a9236f6d 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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} />
{/* 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} diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 63f624e33..dfe579708 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -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; } /** @@ -550,6 +558,18 @@ function useReadAloud(): { 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` @@ -570,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 ? ( -
- {createdAt && } + {/* 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). */} +
+ + {createdAt && } +
{/* 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 @@ -1100,7 +1223,7 @@ function ChatBubble({ return (