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 088e7d0f5..bdcb5fc89 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -24,21 +24,16 @@ describe("", () => { expect(html).not.toContain("Coach replies are generated"); }); - 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. + it("hides the mic in SSR markup (dictation is client-and-secure-context only)", () => { + // v1.25.0 — the mic only mounts once the client confirms the Web Speech + // API exists AND the page runs in a secure context. SSR (and any + // unsupported / non-secure environment) renders NO mic at all, so the + // composer never ships an advertised-but-erroring control. The earlier + // "render it disabled" affordance is gone. const html = render( {}} onSubmit={() => {}} />, ); - 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"); + expect(html).not.toContain('data-slot="coach-input-mic"'); }); it("renders the localised placeholder without the retired shortcut hint", () => { @@ -232,8 +227,9 @@ describe("", () => { expect(html).not.toContain('data-slot="coach-input-hub"'); expect(html).not.toContain('data-slot="coach-input-actions"'); expect(html).not.toContain('data-slot="coach-input-settings"'); - // Mic + send still render in the single-row layout. - expect(html).toContain('data-slot="coach-input-mic"'); + // Send renders in the single-row layout. The mic is client-and-secure- + // context only, so it never appears in SSR markup. + expect(html).not.toContain('data-slot="coach-input-mic"'); expect(html).toContain('data-slot="coach-input-send"'); }); @@ -256,8 +252,9 @@ describe("", () => { expect(html).toContain('data-slot="coach-input-actions"'); // The settings gear is gone from the composer entirely. expect(html).not.toContain('data-slot="coach-input-settings"'); - // Mic + send remain present on the same row. - expect(html).toContain('data-slot="coach-input-mic"'); + // Send remains in the trailing cluster. The mic is client-and-secure- + // context only, so it never appears in SSR markup. + expect(html).not.toContain('data-slot="coach-input-mic"'); expect(html).toContain('data-slot="coach-input-send"'); // The leading `+` precedes the textarea; the textarea precedes the send. const plusIdx = html.indexOf('data-slot="coach-input-actions"'); diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 31f01e4d6..e830fe0c8 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -55,10 +55,13 @@ import { useTranslations } from "@/lib/i18n/context"; * 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. + * v1.25.0: the mic only renders when dictation is genuinely usable — + * the Web Speech API exists AND the page runs in a secure context (the + * recogniser refuses to start over plain HTTP). On SSR, an unsupported + * browser (Firefox), a non-secure self-host, or after the user denies the + * mic permission, the control HIDES rather than sitting as an + * advertised-but-erroring affordance. A real failure mid-use surfaces a + * single quiet toast and then retires the button for the session. * * v1.18.11 (W11): the composer is the conversation's control hub on the * full-page Coach surface — ChatGPT-style. When `showHub` is set it grows a @@ -193,15 +196,34 @@ function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | null { // mic at SSR) and the client snapshot reflects whether the constructor // exists. The store never changes after hydration, so the subscribe // callback is a no-op. +// +// v1.25.0 — the snapshot also requires a SECURE CONTEXT. `SpeechRecognition` +// throws / fires a `not-allowed` error the instant it starts over plain HTTP, +// so a LAN / Tailscale self-host on `http://` would otherwise advertise a mic +// that can never work. Gating support on `window.isSecureContext` hides it +// there cleanly instead. const noopSubscribe = () => () => {}; function useVoiceSupported(): boolean { return useSyncExternalStore( noopSubscribe, - () => getSpeechRecognitionCtor() !== null, + () => getSpeechRecognitionCtor() !== null && window.isSecureContext, () => false, ); } +// v1.25.0 — map the app locale onto a BCP-47 tag with a region the +// recogniser accepts. The bare language subtag works in most engines, but +// the regioned form ("de-DE", "es-ES", …) yields materially better accuracy +// on Chromium / Safari. Unknown locales fall back to the bare subtag. +const SPEECH_RECOGNITION_LANG: Record = { + en: "en-US", + de: "de-DE", + es: "es-ES", + fr: "fr-FR", + it: "it-IT", + pl: "pl-PL", +}; + export function CoachInput({ value, onChange, @@ -274,6 +296,12 @@ export function CoachInput({ // always read the freshest composer state without re-subscribing. const voiceSupported = useVoiceSupported(); const [listening, setListening] = useState(false); + // v1.25.0 — once the user denies the mic permission (or the engine reports + // it cannot serve), retire the control for the session rather than leaving a + // button that errors on every tap. Combined with `voiceSupported` this is + // the single gate the mic renders behind. + const [voiceBlocked, setVoiceBlocked] = useState(false); + const voiceAvailable = voiceSupported && !voiceBlocked; const recognitionRef = useRef(null); const onChangeRef = useRef(onChange); const valueRef = useRef(value); @@ -299,7 +327,7 @@ export function CoachInput({ const Ctor = getSpeechRecognitionCtor(); if (!Ctor) return; const recognition = new Ctor(); - recognition.lang = locale === "en" ? "en-US" : locale; + recognition.lang = SPEECH_RECOGNITION_LANG[locale] ?? locale; recognition.continuous = true; recognition.interimResults = true; // Anchor every transcript onto the text present when dictation @@ -321,15 +349,17 @@ export function CoachInput({ }; 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. + // `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")); + if (!code || code === "aborted" || code === "no-speech") return; + // v1.25.0 — a permission refusal (or an engine that reports it cannot + // serve) is terminal: surface one quiet toast and HIDE the mic for the + // session so the user is not left tapping a control that always errors. + if (code === "not-allowed" || code === "service-not-allowed") { + setVoiceBlocked(true); } + toast.error(t("insights.coach.dictateError")); }; recognition.onend = () => { setListening(false); @@ -346,18 +376,16 @@ export function CoachInput({ }, [locale, t]); const toggleDictation = useCallback(() => { - if (!voiceSupported) return; + if (!voiceAvailable) return; if (listening) stopDictation(); else startDictation(); - }, [voiceSupported, listening, startDictation, stopDictation]); + }, [voiceAvailable, 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"); + // The mic only renders when dictation is available, so the label tracks the + // two live states: listening (tap to stop) and idle (tap to dictate). + const micLabel = listening + ? t("insights.coach.dictateStop") + : t("insights.coach.dictate"); const canSubmit = !disabled && value.trim().length > 0; @@ -381,19 +409,20 @@ export function CoachInput({ // v1.18.11 — the dictation mic. Lifted to a const so it can sit in the // single-row composer (drawer) OR in the hub action row (page) without - // duplicating the wiring. - const micButton = ( + // duplicating the wiring. v1.25.0 — `null` when dictation is unavailable + // (no API, non-secure context, or a denied permission) so the surface hides + // the control rather than advertising a button that errors on tap. + const micButton = voiceAvailable ? ( - ); + ) : null; // v1.18.11 — the send / stop control. Same const-lift rationale as the // mic: one definition, two mount points.