feat(dictation): reason model — speaking tooltip on a disabled mic + shared error resolver (#309)

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) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-07-03 18:15:09 +03:00
parent b861266ff8
commit a86e5f409f
11 changed files with 532 additions and 98 deletions
@@ -27,14 +27,16 @@ import {
collabExtensions,
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
dictationAvailabilityAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
activeCommentIdAtom,
@@ -139,6 +141,7 @@ export default function PageEditor({
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const setDictationAvailability = useSetAtom(dictationAvailabilityAtom);
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
@@ -488,6 +491,34 @@ export default function PageEditor({
);
}, [currentPageEditMode, editor, editable, showStatic]);
// Publish whether dictation can start and, if not, the cause-specific reason
// the mic button surfaces. Recomputed on the same signals that drive body
// editability so the tooltip never lies about the current state.
useEffect(() => {
const inEditMode = currentPageEditMode === PageEditMode.Edit;
const isEditable = editable && inEditMode && !showStatic; // mirrors editor.isEditable
let reason: DictationUnavailableReason | null = null;
if (!isEditable) {
if (editable && inEditMode && showStatic) {
// Permitted to edit and in edit mode, but the collab doc hasn't synced yet.
reason =
yjsConnectionStatus === WebSocketStatus.Disconnected
? "offline"
: "connecting";
} else {
// No edit permission or not in edit mode.
reason = "read-only";
}
}
setDictationAvailability({ isEditable, reason });
}, [
editable,
currentPageEditMode,
showStatic,
yjsConnectionStatus,
setDictationAvailability,
]);
useEffect(() => {
if (
!hasConnectedOnceRef.current &&