feat(coach): add a stop control and abort the stream on drawer close

While a reply streams the composer now swaps the send button for a
visible Stop control bound to the abort handler, so a long or off-track
reply can be interrupted instead of waited out. Closing the drawer
mid-stream also calls the cancel handler, so the SSE request no longer
keeps running in the background after the drawer is gone.

Add a localised "Stop" label across all six bundles.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 15:36:16 +02:00
1 parent fc6a55b91c
commit 2e3bfbb971
9 files changed
+122 -23

No files matched your search

+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
@@ -122,6 +122,65 @@ describe("<CoachInput>", () => {
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(
<CoachInput
value="Hello"
onChange={() => {}}
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(
/<button[^>]*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(
<CoachInput
value="Hello"
onChange={() => {}}
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(
<CoachInput
value="Hallo"
onChange={() => {}}
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.
@@ -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
@@ -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")}
</PopoverContent>
</Popover>
<Button
type="submit"
size="sm"
disabled={!canSubmit}
data-slot="coach-input-send"
className="gap-1.5"
>
{isStreaming ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : (
<Send className="size-3.5" aria-hidden="true" />
)}
<span>{t("insights.coach.send")}</span>
</Button>
{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.
<Button
type="button"
size="sm"
variant="outline"
onClick={onCancel}
data-slot="coach-input-stop"
className="gap-1.5"
>
<Square
className="size-3.5 fill-current"
aria-hidden="true"
/>
<span>{t("insights.coach.stop")}</span>
</Button>
) : (
<Button
type="submit"
size="sm"
disabled={!canSubmit}
data-slot="coach-input-send"
className="gap-1.5"
>
{isStreaming ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : (
<Send className="size-3.5" aria-hidden="true" />
)}
<span>{t("insights.coach.send")}</span>
</Button>
)}
</div>
</div>
</form>