1458e3e152
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>
111 lines
3.6 KiB
TypeScript
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");
|
|
}
|
|
}
|