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 ? (
-
+
) : (
-
+
)}
);
}
+/**
+ * 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 (
+
+ );
+}
+
+/**
+ * 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 (
+
+ );
+}
+
/**
* 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
@@ -636,6 +719,7 @@ export function MessageThread({
optimisticUser,
emptyHint,
interleaved,
+ onRegenerate,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef(null);
@@ -827,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.
@@ -835,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 (
{guidedBefore?.node}
@@ -847,6 +943,11 @@ export function MessageThread({
tokensUsed={m.tokensUsed}
model={m.model}
createdAt={m.createdAt}
+ onRegenerate={
+ precedingUserContent !== null && onRegenerate
+ ? () => onRegenerate(precedingUserContent as string)
+ : undefined
+ }
/>
);
@@ -958,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({
@@ -975,6 +1082,7 @@ function ChatBubble({
tokensUsed,
model,
createdAt,
+ onRegenerate,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
@@ -1024,7 +1132,22 @@ function ChatBubble({
is verbatim: no chart-token strip, no Learn linkify. */}
- {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 (
)}
- {/* 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 && (
)}
@@ -1494,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 (
-