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] 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 ? ( -