diff --git a/messages/de.json b/messages/de.json index e753b8a60..3c69a1f9a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1700,6 +1700,7 @@ "tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.", "newChat": "Neuer Chat", "send": "Senden", + "stop": "Stopp", "thinking": "Denke nach…", "composerPlaceholder": "Frag mich etwas zu deinen Daten…", "composerHint": "Enter zum Senden, Umschalt+Enter für eine neue Zeile.", diff --git a/messages/en.json b/messages/en.json index 02f68a71f..0004b2dcf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1700,6 +1700,7 @@ "tagline": "Personal health coach, grounded in your data.", "newChat": "New chat", "send": "Send", + "stop": "Stop", "thinking": "Thinking…", "composerPlaceholder": "Ask anything about your data…", "composerHint": "Press Enter to send, Shift+Enter for a new line.", diff --git a/messages/es.json b/messages/es.json index d42255e5c..eb336d0f6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1700,6 +1700,7 @@ "tagline": "Coach personal de salud, apoyado en tus datos.", "newChat": "Nueva conversación", "send": "Enviar", + "stop": "Detener", "thinking": "Pensando…", "composerPlaceholder": "Pregúntame sobre tus datos…", "composerHint": "Intro para enviar, Mayús+Intro para una nueva línea.", diff --git a/messages/fr.json b/messages/fr.json index a88341839..40ed28bfe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1700,6 +1700,7 @@ "tagline": "Coach personnel de santé, ancré sur tes données.", "newChat": "Nouvelle conversation", "send": "Envoyer", + "stop": "Arrêter", "thinking": "Réflexion en cours…", "composerPlaceholder": "Pose-moi une question sur tes données…", "composerHint": "Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne.", diff --git a/messages/it.json b/messages/it.json index 536e0b18c..921291c6a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1700,6 +1700,7 @@ "tagline": "Coach personale di salute, basato sui tuoi dati.", "newChat": "Nuova chat", "send": "Invia", + "stop": "Interrompi", "thinking": "Sto pensando…", "composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…", "composerHint": "Invio per inviare, Shift+Invio per una nuova riga.", diff --git a/messages/pl.json b/messages/pl.json index f5b13eb51..af00afbcc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1700,6 +1700,7 @@ "tagline": "Osobisty coach zdrowia, oparty na twoich danych.", "newChat": "Nowa rozmowa", "send": "Wyślij", + "stop": "Zatrzymaj", "thinking": "Myślę…", "composerPlaceholder": "Zapytaj o swoje dane…", "composerHint": "Enter wysyła, Shift+Enter przechodzi do nowej linii.", 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 fcf9290e1..4785060a0 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -122,6 +122,65 @@ describe("", () => { expect(html).toContain("animate-spin"); }); + it("swaps the send button for a Stop control while streaming with onCancel", () => { + // v1.11.3 D1 — while a reply streams the composer must surface a + // visible Stop affordance bound to the abort handler so the user + // can interrupt a long or off-track reply. The Stop button only + // appears when an `onCancel` handler is wired (the drawer always + // passes `send.cancel`). + const html = render( + {}} + onSubmit={() => {}} + onCancel={() => {}} + disabled + isStreaming + />, + ); + expect(html).toContain('data-slot="coach-input-stop"'); + expect(html).not.toContain('data-slot="coach-input-send"'); + expect(html).toContain("Stop"); + // The Stop control is a plain button, never a submit, so tapping it + // aborts rather than re-firing the form. + const stopTag = html.match( + /]*data-slot="coach-input-stop"[^>]*>/, + ); + expect(stopTag?.[0]).toContain('type="button"'); + }); + + it("keeps the send button (with spinner) while streaming without onCancel", () => { + // Backwards-compatible fallback: without an `onCancel` the composer + // keeps the legacy disabled-spinner send button. + const html = render( + {}} + onSubmit={() => {}} + disabled + isStreaming + />, + ); + expect(html).toContain('data-slot="coach-input-send"'); + expect(html).not.toContain('data-slot="coach-input-stop"'); + expect(html).toContain("animate-spin"); + }); + + it("renders the localised Stop label under the 'de' locale", () => { + const html = render( + {}} + onSubmit={() => {}} + onCancel={() => {}} + disabled + isStreaming + />, + "de", + ); + expect(html).toContain("Stopp"); + }); + it("invokes onChange when the parent passes a controlled handler", () => { // SSR can't fire DOM events; smoke-check the contract by calling // the supplied handler directly. diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index ef97a5396..8e7c2344d 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -163,9 +163,19 @@ export function CoachDrawer({ // and folds the saved window server-side — "what the rail shows" and // "what the model receives" cannot drift. + const { data: conversation } = useCoachConversation(currentConversationId); + const send = useSendCoachMessage({ + onDone: (resolvedId) => { + setCurrentConversationId(resolvedId); + }, + }); + const handleOpenChange = useCallback( (next: boolean) => { if (!next) { + // Abort any in-flight streamed reply so closing the drawer + // doesn't leave the SSE request running in the background. + send.cancel(); // Reset thread + composer on close so the next open starts on // the rail's empty hint instead of re-rendering the previous // conversation by accident. @@ -174,16 +184,9 @@ export function CoachDrawer({ } onOpenChange(next); }, - [onOpenChange, setInputValue], + [onOpenChange, send, setInputValue], ); - const { data: conversation } = useCoachConversation(currentConversationId); - const send = useSendCoachMessage({ - onDone: (resolvedId) => { - setCurrentConversationId(resolvedId); - }, - }); - async function handleSubmit(value: string) { const trimmed = value.trim(); if (!trimmed || send.isStreaming) return; @@ -352,6 +355,7 @@ export function CoachDrawer({ value={inputValue} onChange={setInputValue} onSubmit={() => handleSubmit(inputValue)} + onCancel={send.cancel} disabled={send.isStreaming} isStreaming={send.isStreaming} // v1.4.27 MB3 / CF-30 — the composer mounts on drawer diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 5961d0427..f44cda755 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef, } from "react"; -import { Info, Loader2, Send } from "lucide-react"; +import { Info, Loader2, Send, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -43,6 +43,13 @@ export interface CoachInputProps { value: string; onChange: (next: string) => void; onSubmit: () => void; + /** + * Aborts the in-flight streamed reply. The composer swaps the send + * button for a Stop control while `isStreaming` is set; tapping it + * cancels the SSE request so the user can interrupt a long or + * off-track reply. + */ + onCancel?: () => void; /** Disabled while a previous reply is still streaming. */ disabled?: boolean; /** Surfaces the streaming-in-progress affordance on the send button. */ @@ -98,6 +105,7 @@ export function CoachInput({ value, onChange, onSubmit, + onCancel, disabled = false, isStreaming = false, inputId = "coach-composer-textarea", @@ -256,20 +264,42 @@ export function CoachInput({ {t("insights.coach.composerHint")} - + {isStreaming && onCancel ? ( + // While a reply streams, swap the send button for a Stop + // control bound to the abort handler so the user can + // interrupt a long or off-track reply instead of waiting it + // out. The spinner stays inside the Stop affordance so the + // in-progress state is still legible. + + ) : ( + + )}