Files
gitmost/apps/client/src/features/dictation/dictation-status.ts
T
claude code agent 227 1458e3e152 fix(dictation): cover read-only precedence, suppress misleading disabled tooltip, drop dead "busy" (#314 F3/F4/F5)
F3: add computeDictationAvailability assertions for the read-only ∩ pre-sync
intersection (editable:false, inEditMode:true, showStatic:true) → read-only for
both isDisconnected states, pinning that lack of edit permission takes
precedence over the pre-sync reason (kills a mutant dropping `editable &&`).

F4: switching native disabled → data-disabled made a disabled mic hoverable — good
for the byline mic (shows the reason), but a consumer passing bare `disabled`
without a reason (AI chat's isStreaming) got a misleading, actionable
"Start dictation" tooltip on a click-rejecting control. Now: disabled + no reason
→ render the icon with NO Tooltip and a neutral aria-label; disabled + reason →
reason tooltip; enabled → "Start dictation". Click guard/data-disabled preserved.

F5: remove the dead "busy" DictationUnavailableReason (never produced) — union
member, its resolver case (folded into default), and the vacuous test assert.

vitest (dictation + editor-sync + dictation-group): 41 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:18:16 +03:00

111 lines
3.6 KiB
TypeScript

// Single source of truth for "why dictation is unavailable" and "why it failed".
// Both dictation hooks and the mic button pull their user-facing strings from
// the resolvers here so the wording lives in exactly one place.
export type DictationUnavailableReason =
| "connecting"
| "offline"
| "read-only"
| "unsupported";
export type DictationErrorCode =
| "no-media-devices"
| "mic-denied"
| "no-mic"
| "mic-in-use"
| "recorder-failed"
| "vad-init-failed"
| "stt-not-configured"
| "transcription-failed"
| "unknown";
// True if this browser/context can record audio.
export function isDictationSupported(): boolean {
return (
typeof MediaRecorder !== "undefined" &&
typeof navigator !== "undefined" &&
!!navigator.mediaDevices?.getUserMedia
);
}
// getUserMedia / VAD.start rejection -> code, by DOMException .name.
export function classifyGetUserMediaError(err: unknown): DictationErrorCode {
const name = (err as { name?: string })?.name;
if (name === "NotAllowedError" || name === "SecurityError")
return "mic-denied";
if (name === "NotFoundError" || name === "OverconstrainedError")
return "no-mic";
if (name === "NotReadableError" || name === "AbortError") return "mic-in-use";
return "unknown";
}
// Transcription HTTP failure -> code (+ verbatim server message when present).
export function classifyTranscriptionError(err: unknown): {
code: DictationErrorCode;
serverMessage?: string;
} {
const resp = (
err as { response?: { status?: number; data?: { message?: string } } }
)?.response;
const serverMessage = resp?.data?.message;
if (serverMessage && serverMessage.trim().length > 0)
return { code: "transcription-failed", serverMessage };
if (resp?.status === 503 || resp?.status === 403)
return { code: "stt-not-configured" };
return { code: "transcription-failed" };
}
type TFn = (key: string) => string;
// Code -> user text. The ONE place runtime error strings are formed.
// serverMessage (verbatim) wins for transcription-failed; detail is appended
// to the generic "could not start"/"transcription failed" strings.
export function dictationErrorMessage(
code: DictationErrorCode,
t: TFn,
extra?: { serverMessage?: string; detail?: string },
): string {
const detail = extra?.detail;
switch (code) {
case "mic-denied":
return t("Microphone access denied");
case "no-mic":
return t("No microphone found");
case "mic-in-use":
return t("Microphone is unavailable or already in use");
case "no-media-devices":
return t("Audio recording is not available in this browser/context");
case "stt-not-configured":
return t("Voice dictation is not configured");
case "transcription-failed":
if (extra?.serverMessage && extra.serverMessage.trim().length > 0)
return extra.serverMessage;
return `${t("Transcription failed")}${detail ? `: ${detail}` : ""}`;
case "recorder-failed":
case "vad-init-failed":
case "unknown":
default:
return `${t("Could not start recording")}${detail ? `: ${detail}` : ""}`;
}
}
// Unavailable reason -> tooltip text (the ONE place these strings are formed).
export function resolveUnavailableLabel(
r: DictationUnavailableReason,
t: TFn,
): string {
switch (r) {
case "connecting":
return t("Dictation becomes available once the page finishes connecting");
case "offline":
return t(
"No connection to the collaboration server — dictation unavailable",
);
case "read-only":
return t("This page is read-only");
case "unsupported":
default:
return t("Audio recording is not available in this browser/context");
}
}