diff --git a/messages/de.json b/messages/de.json index 40d7ddbd8..17022908c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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?", diff --git a/messages/en.json b/messages/en.json index 54241ad76..2f03a8198 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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?", diff --git a/messages/es.json b/messages/es.json index 4b8561ed3..5612ce688 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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?", diff --git a/messages/fr.json b/messages/fr.json index 71ec8f395..fd4b6835b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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 ?", diff --git a/messages/it.json b/messages/it.json index 712b5c259..1b26acae6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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?", diff --git a/messages/pl.json b/messages/pl.json index 832a4d5d2..4b7dc46f0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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?", 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 ea73deed7..eb15e9b62 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -24,16 +24,21 @@ describe("", () => { 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( {}} 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(/]*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", () => { diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 8a759259c..647f06a01 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -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 & { 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 && ( - - )} + aria-hidden="true" + /> +