From a86e5f409feada90056d1f2a7b2c0934fb24ddfe Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 3 Jul 2026 18:15:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(dictation):=20reason=20model=20=E2=80=94?= =?UTF-8?q?=20speaking=20tooltip=20on=20a=20disabled=20mic=20+=20shared=20?= =?UTF-8?q?error=20resolver=20(#309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dictation mic could be grey/disabled while silently showing "Start dictation", and Mantine's native `disabled` set pointer-events:none so the Tooltip never fired at all — the UI knew the cause but told the user nothing. Runtime error strings were also duplicated verbatim across the two dictation hooks. - New dictation-status.ts: the single source of truth. A DictationUnavailableReason enum (connecting/offline/read-only/unsupported/busy) + a DictationErrorCode enum, pure classifiers (classifyGetUserMediaError / classifyTranscriptionError) and resolvers (resolveUnavailableLabel / dictationErrorMessage). All user-facing dictation strings are formed here; the verbatim server message still wins for transcription errors. - page-editor publishes dictationAvailabilityAtom { isEditable, reason } computed at the source (editable/edit-mode/showStatic/collab status): connecting vs offline (stuck) vs read-only. DictationGroup forwards the reason to MicButton. - MicButton is reason-aware: a disabled mic shows the cause-specific tooltip. The disabled-hover silence is fixed by marking disabled the Mantine way (data-disabled/aria-disabled + click guard) instead of the native attribute, so the Tooltip fires — applied to both the idle (reason) and error (errorMessage) states. - Both hooks route every error through the shared resolver (deleting the duplicated transcriptionErrorMessage), and expose errorMessage for the tooltip. Wording is byte-identical to each hook's original (incl. the batch hook's DOMException name prefix and the verbatim server message). - i18n: 3 new reason keys in en-US + ru-RU, and the previously-missing ru-RU dictation error translations. Tests: dictation-status.test.ts (all classifier/resolver branches, incl. server message passthrough) + mic-button.test.tsx (disabled mic shows the reason text, uses data-disabled not native disabled — fails against the pre-fix code). vitest: 5 files / 32 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/locales/en-US/translation.json | 3 + .../public/locales/ru-RU/translation.json | 10 ++ .../dictation/components/mic-button.test.tsx | 80 +++++++++ .../dictation/components/mic-button.tsx | 61 ++++++- .../dictation/dictation-status.test.ts | 157 ++++++++++++++++++ .../features/dictation/dictation-status.ts | 113 +++++++++++++ .../features/dictation/hooks/use-dictation.ts | 78 ++++----- .../hooks/use-streaming-dictation.ts | 66 +++----- .../src/features/editor/atoms/editor-atoms.ts | 13 ++ .../fixed-toolbar/groups/dictation-group.tsx | 16 +- .../src/features/editor/page-editor.tsx | 33 +++- 11 files changed, 532 insertions(+), 98 deletions(-) create mode 100644 apps/client/src/features/dictation/components/mic-button.test.tsx create mode 100644 apps/client/src/features/dictation/dictation-status.test.ts create mode 100644 apps/client/src/features/dictation/dictation-status.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 5dec73ff..7edbe638 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1274,6 +1274,9 @@ "Voice dictation is not configured": "Voice dictation is not configured", "Microphone is unavailable or already in use": "Microphone is unavailable or already in use", "Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context", + "Dictation becomes available once the page finishes connecting": "Dictation becomes available once the page finishes connecting", + "No connection to the collaboration server — dictation unavailable": "No connection to the collaboration server — dictation unavailable", + "This page is read-only": "This page is read-only", "Request format": "Request format", "How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint", "OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index be16c5c9..61fd5262 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -393,6 +393,16 @@ "No speech detected": "Речь не распознана", "Transcription failed": "Не удалось распознать речь", "Voice dictation is not configured": "Голосовой ввод не настроен", + "Start dictation": "Начать диктовку", + "Stop recording": "Остановить запись", + "Microphone access denied": "Доступ к микрофону запрещён", + "No microphone found": "Микрофон не найден", + "Microphone is unavailable or already in use": "Микрофон недоступен или уже используется", + "Could not start recording": "Не удалось начать запись", + "Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте", + "Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу", + "No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна", + "This page is read-only": "Страница открыта только для чтения", "Embed PDF": "Встроить PDF", "Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.", "Embed as PDF": "Встроить как PDF", diff --git a/apps/client/src/features/dictation/components/mic-button.test.tsx b/apps/client/src/features/dictation/components/mic-button.test.tsx new file mode 100644 index 00000000..4d92cdf6 --- /dev/null +++ b/apps/client/src/features/dictation/components/mic-button.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +// A disabled mic must explain WHY it is unavailable rather than silently saying +// "Start dictation". This renders MicButton in its idle+disabled state with a +// forwarded reason and asserts the accessible label resolves to that reason's +// text via the shared resolver (dictation-status.resolveUnavailableLabel). + +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. + +// Pass i18n keys through verbatim so we assert the exact resolved string. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (s: string) => s }), +})); + +// Keep both controllers inert and idle so MicButton renders the idle branch. +const idleCtl = { + status: "idle" as const, + start: vi.fn(async () => {}), + stop: vi.fn(), + cancel: vi.fn(), + audioLevel: 0, + errorMessage: null, +}; +vi.mock("@/features/dictation/hooks/use-dictation", () => ({ + useDictation: () => idleCtl, +})); +vi.mock("@/features/dictation/hooks/use-streaming-dictation", () => ({ + useStreamingDictation: () => idleCtl, +})); + +import { MicButton } from "./mic-button"; + +function renderButton(props: React.ComponentProps) { + render( + + + , + ); +} + +describe("MicButton — disabled reason label", () => { + // jsdom has no MediaRecorder / mediaDevices, so isDictationSupported() would + // report "unsupported" and mask the forwarded reason. Stub both so the button + // is considered supported and the availability reason is what surfaces. + beforeEach(() => { + (globalThis as unknown as { MediaRecorder: unknown }).MediaRecorder = + class {}; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn() }, + }); + }); + afterEach(() => { + delete (globalThis as unknown as { MediaRecorder?: unknown }).MediaRecorder; + }); + + it("shows the cause-specific reason instead of 'Start dictation' when disabled with a reason", () => { + renderButton({ onText: () => {}, disabled: true, unavailableReason: "offline" }); + const expected = + "No connection to the collaboration server — dictation unavailable"; + // The reason surfaces as the accessible label (and the tooltip text). + const button = screen.getByRole("button", { name: expected }); + expect(button).toBeDefined(); + // It is marked disabled the Mantine way (data-disabled), NOT the native + // `disabled` attribute — otherwise pointer-events:none would kill the tooltip. + expect(button.getAttribute("data-disabled")).toBe("true"); + expect(button.hasAttribute("disabled")).toBe(false); + // And it no longer silently reads "Start dictation". + expect(screen.queryByRole("button", { name: "Start dictation" })).toBeNull(); + }); + + it("reads 'Start dictation' when enabled with no reason", () => { + renderButton({ onText: () => {} }); + expect( + screen.getByRole("button", { name: "Start dictation" }), + ).toBeDefined(); + }); +}); diff --git a/apps/client/src/features/dictation/components/mic-button.tsx b/apps/client/src/features/dictation/components/mic-button.tsx index 70ead74e..6ef64532 100644 --- a/apps/client/src/features/dictation/components/mic-button.tsx +++ b/apps/client/src/features/dictation/components/mic-button.tsx @@ -4,6 +4,11 @@ import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useDictation } from "@/features/dictation/hooks/use-dictation"; import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation"; +import { + isDictationSupported, + resolveUnavailableLabel, + type DictationUnavailableReason, +} from "@/features/dictation/dictation-status"; import classes from "./mic-button.module.css"; interface MicButtonProps { @@ -21,6 +26,9 @@ interface MicButtonProps { // When true, use the streaming (Silero-VAD) dictation controller, which emits // text progressively as the user pauses; otherwise use the batch controller. streaming?: boolean; + // When the mic is disabled for an availability reason, this is the cause the + // idle tooltip explains (e.g. pre-sync "connecting", "offline", "read-only"). + unavailableReason?: DictationUnavailableReason; } /** @@ -37,6 +45,7 @@ export const MicButton: FC = ({ color, iconSize, streaming = false, + unavailableReason, }) => { const { t } = useTranslation(); // Call BOTH hooks unconditionally to respect the rules of hooks: which one is @@ -46,7 +55,7 @@ export const MicButton: FC = ({ const batchCtl = useDictation({ onText, onStart }); const streamingCtl = useStreamingDictation({ onText, onStart }); const ctl = streaming ? streamingCtl : batchCtl; - const { status, start, stop, audioLevel } = ctl; + const { status, start, stop, audioLevel, errorMessage } = ctl; const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16); if (status === "recording") { @@ -82,15 +91,28 @@ export const MicButton: FC = ({ ) { // "loading" (streaming hook fetching the VAD model on first use) shows the // same spinner+disabled state so the first click is visibly acknowledged and - // a confusing second click can't fire while the model loads. - const label = status === "loading" ? t("Preparing…") : t("Transcribing…"); + // a confusing second click can't fire while the model loads. The error case + // explains the failure via the hook's resolved errorMessage instead of the + // transient "Transcribing…" label. + const label = + status === "error" + ? (errorMessage ?? t("Transcription failed")) + : status === "loading" + ? t("Preparing…") + : t("Transcribing…"); return ( @@ -99,15 +121,38 @@ export const MicButton: FC = ({ ); } + // Idle branch. A grey/disabled mic must explain WHY it can't record. An + // unsupported browser/context is detected here; otherwise the parent forwards + // a cause-specific reason. We must NOT pass the native `disabled` prop: Mantine + // renders `