fix(coach): make the composer mic reliable and never a dead tap

Always render the dictation mic so the affordance stays discoverable. On
a browser without the Web Speech API (or during SSR) render it disabled
with an explanatory tooltip instead of hiding it or leaving a control
that silently does nothing on tap. When a dictation start fails — denied
microphone permission, an insecure-context self-host, or no network for
the recogniser — surface a quiet toast rather than resetting in silence.
Add voiceUnsupported + dictateError strings across all locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 16:42:10 +02:00
1 parent 01b73f6379
commit 398f5c3b6b
8 files changed
+87 -50

No files matched your search

+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} Werte",
"dictate": "Diktieren",
"dictateStop": "Diktat beenden",
"voiceUnsupported": "Spracheingabe wird in diesem Browser nicht unterstützt",
"dictateError": "Spracheingabe konnte nicht gestartet werden. Prüfe die Mikrofonberechtigung und versuche es erneut.",
"showConversations": "Unterhaltungen anzeigen",
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Wie kann ich dir helfen?",
+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} metrics",
"dictate": "Dictate",
"dictateStop": "Stop dictation",
"voiceUnsupported": "Voice input is not supported in this browser",
"dictateError": "Could not start voice input. Check microphone permission and try again.",
"showConversations": "Show conversations",
"hideConversations": "Hide conversations",
"heroGreeting": "How can I help you?",
+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} métricas",
"dictate": "Dictar",
"dictateStop": "Detener el dictado",
"voiceUnsupported": "La entrada de voz no es compatible con este navegador",
"dictateError": "No se pudo iniciar la entrada de voz. Comprueba el permiso del micrófono e inténtalo de nuevo.",
"showConversations": "Mostrar conversaciones",
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "¿En qué puedo ayudarte?",
+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} mesures",
"dictate": "Dicter",
"dictateStop": "Arrêter la dictée",
"voiceUnsupported": "La saisie vocale n'est pas prise en charge par ce navigateur",
"dictateError": "Impossible de démarrer la saisie vocale. Vérifiez l'autorisation du microphone et réessayez.",
"showConversations": "Afficher les conversations",
"hideConversations": "Masquer les conversations",
"heroGreeting": "Comment puis-je t'aider ?",
+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} metriche",
"dictate": "Detta",
"dictateStop": "Interrompi dettatura",
"voiceUnsupported": "L'inserimento vocale non è supportato in questo browser",
"dictateError": "Impossibile avviare l'inserimento vocale. Controlla l'autorizzazione del microfono e riprova.",
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Come posso aiutarti?",
+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} wskaźników",
"dictate": "Dyktuj",
"dictateStop": "Zatrzymaj dyktowanie",
"voiceUnsupported": "Wprowadzanie głosowe nie jest obsługiwane w tej przeglądarce",
"dictateError": "Nie udało się uruchomić wprowadzania głosowego. Sprawdź uprawnienia mikrofonu i spróbuj ponownie.",
"showConversations": "Pokaż rozmowy",
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "W czym mogę pomóc?",
@@ -24,16 +24,21 @@ describe("<CoachInput>", () => {
expect(html).not.toContain("Coach replies are generated");
});
it("does not render the mic in SSR markup (Web Speech API is client-only)", () => {
// v1.18.7 W-coach C-UI — voice dictation returns, but the mic only
// mounts client-side once `SpeechRecognition` is confirmed present
// (a post-hydration effect flips support on). The SSR / unsupported
// path renders no button so the affordance is never a dead control.
it("renders the mic disabled with an unsupported tooltip in SSR markup", () => {
// v1.18.10 (W4) — the mic always renders so the affordance stays
// discoverable. SSR (and any browser without the Web Speech API) shows
// it DISABLED with an explanatory tooltip rather than vanishing or
// sitting as a dead control. A post-hydration effect re-enables it once
// `SpeechRecognition` is confirmed present.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).not.toContain('data-slot="coach-input-mic"');
expect(html).not.toContain("Voice input arrives with the iOS app");
const micTag = html.match(/<button[^>]*data-slot="coach-input-mic"[^>]*>/);
expect(micTag).not.toBeNull();
expect(micTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
expect(micTag?.[0]).toContain('data-unsupported="true"');
// Tooltip explains why it is inert, sourced from i18n.
expect(html).toContain("Voice input is not supported in this browser");
});
it("renders the localised placeholder without the retired shortcut hint", () => {
@@ -10,6 +10,7 @@ import {
useSyncExternalStore,
} from "react";
import { Loader2, Mic, Send, Square } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -35,11 +36,14 @@ import { useTranslations } from "@/lib/i18n/context";
* v1.18.7 (W-coach C-UI): voice dictation returns to the web composer.
* The earlier placeholder mic was dropped in v1.4.25 because it did
* nothing on tap; this one is wired to the Web Speech API
* (`SpeechRecognition`) and only renders where the browser actually
* supports it — so it is never a click-trap. While the user dictates,
* interim + final transcripts append into the controlled `value`; the
* button toggles listening on/off and is fully keyboard- and
* screen-reader-accessible.
* (`SpeechRecognition`). While the user dictates, interim + final
* transcripts append into the controlled `value`; the button toggles
* listening on/off and is fully keyboard- and screen-reader-accessible.
*
* v1.18.10 (W4): the mic always renders so the affordance is discoverable.
* On a browser without the Web Speech API (or during SSR) it renders
* DISABLED with an explanatory tooltip rather than vanishing or sitting as
* a dead control that silently does nothing on tap.
*/
export interface CoachInputProps {
value: string;
@@ -125,9 +129,10 @@ type SpeechRecognitionLike = {
stop: () => void;
abort: () => void;
onresult: ((event: SpeechRecognitionResultEventLike) => void) | null;
onerror: (() => void) | null;
onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;
onend: (() => void) | null;
};
type SpeechRecognitionErrorEventLike = { error?: string };
type SpeechRecognitionResultEventLike = {
resultIndex: number;
results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal: boolean }>;
@@ -271,7 +276,18 @@ export function CoachInput({
const joiner = base && !base.endsWith(" ") ? " " : "";
onChangeRef.current(`${base}${joiner}${spoken}`);
};
recognition.onerror = () => setListening(false);
recognition.onerror = (event) => {
setListening(false);
// v1.18.10 (W4) — surface a quiet toast on a real failure so a tap
// that "does nothing" (denied mic permission, an insecure-context
// self-host, or no network for the recogniser) gives feedback rather
// than silently resetting. `aborted` / `no-speech` are the benign
// codes that fire on a normal stop or a quiet pause — stay silent.
const code = event?.error;
if (code && code !== "aborted" && code !== "no-speech") {
toast.error(t("insights.coach.dictateError"));
}
};
recognition.onend = () => {
setListening(false);
recognitionRef.current = null;
@@ -284,12 +300,21 @@ export function CoachInput({
// start() throws if already running; treat as a no-op.
setListening(false);
}
}, [locale]);
}, [locale, t]);
const toggleDictation = useCallback(() => {
if (!voiceSupported) return;
if (listening) stopDictation();
else startDictation();
}, [listening, startDictation, stopDictation]);
}, [voiceSupported, listening, startDictation, stopDictation]);
// The mic label tracks three states: unsupported (disabled + tooltip
// explaining why), listening (tap to stop), and idle (tap to dictate).
const micLabel = !voiceSupported
? t("insights.coach.voiceUnsupported")
: listening
? t("insights.coach.dictateStop")
: t("insights.coach.dictate");
const canSubmit = !disabled && value.trim().length > 0;
@@ -329,42 +354,37 @@ export function CoachInput({
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:bg-background focus-within:ring-2",
)}
>
{voiceSupported && (
<Button
type="button"
size="icon"
variant="ghost"
onClick={toggleDictation}
disabled={disabled}
data-slot="coach-input-mic"
data-listening={listening ? "true" : undefined}
aria-label={
listening
? t("insights.coach.dictateStop")
: t("insights.coach.dictate")
}
aria-pressed={listening}
title={
listening
? t("insights.coach.dictateStop")
: t("insights.coach.dictate")
}
{/* v1.18.10 (W4) — the mic always renders so it stays discoverable.
When the browser lacks the Web Speech API (or during SSR) it is
DISABLED with an explanatory tooltip rather than vanishing or
sitting as a dead control that does nothing on tap. */}
<Button
type="button"
size="icon"
variant="ghost"
onClick={toggleDictation}
disabled={disabled || !voiceSupported}
data-slot="coach-input-mic"
data-listening={listening ? "true" : undefined}
data-unsupported={!voiceSupported ? "true" : undefined}
aria-label={micLabel}
aria-pressed={voiceSupported ? listening : undefined}
title={micLabel}
className={cn(
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
: "text-muted-foreground hover:text-foreground",
)}
>
<Mic
className={cn(
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
: "text-muted-foreground hover:text-foreground",
"size-4",
listening && "animate-pulse motion-reduce:animate-none",
)}
>
<Mic
className={cn(
"size-4",
listening && "animate-pulse motion-reduce:animate-none",
)}
aria-hidden="true"
/>
</Button>
)}
aria-hidden="true"
/>
</Button>
<textarea
id={inputId}
ref={textareaRef}