mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
fc6a55b91c
commit
2e3bfbb971
9 files changed
+122
-23
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user