1import { MICROPHONE_PERMISSION_ERROR } from "@t3tools/client-runtime/voice-input";
2import { useNavigate } from "@tanstack/react-router";
3import { CircleAlertIcon, XIcon } from "lucide-react";
4
5import { Button } from "../ui/button";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7import { VoiceInputPill } from "./VoiceInputPill";
8
9const VOICE_ERROR_LABELS: Record<string, string> = {
10 [MICROPHONE_PERMISSION_ERROR]: "Mic blocked",
11 "Microphone access is required for voice input.": "Mic blocked",
12 "This draft is no longer available.": "Draft unavailable",
13 "Another voice recording is already active.": "Already recording",
14 "Voice transcription is not available.": "Voice input unavailable",
15 "Voice transcription is still finishing. Try again shortly.": "Still transcribing",
16 "Voice transcription is not available for this language.": "Language unsupported",
17 "Could not prepare voice transcription.": "Couldn’t start",
18 "Could not start voice recording.": "Couldn’t start",
19 "Voice recording was interrupted.": "Recording interrupted",
20 "Could not finish voice recording.": "Couldn’t finish",
21 "Could not transcribe this recording.": "Transcription failed",
22 "Voice transcription on this environment failed.": "Transcription failed",
23 "The draft changed while voice input was running. The transcript was not added.": "Draft changed",
24 "No speech was detected.": "No speech heard",
25 "Microphone processing failed.": "Mic failed",
26 "The microphone disconnected.": "Mic disconnected",
27};
28
29const RETRYABLE_TRANSCRIPTIONS = new Set([
30 "Could not transcribe this recording.",
31 "Voice transcription on this environment failed.",
32]);
33
34export function VoiceInputErrorPill(props: {
35 error: string | null;
36 errorAction: "retry" | "settings" | null;
37 disabled?: boolean;
38 cancelShortcutLabel?: string | null;
39 size?: "default" | "compact" | undefined;
40 onRetry(): void;
41 onDismiss(): void;
42}) {
43 const navigate = useNavigate();
44 const message = props.error ?? "Voice input failed";
45 const openSettings = props.errorAction === "settings";
46 const compact = props.size === "compact";
47 const action = openSettings
48 ? { label: "Open voice settings", text: "Settings" }
49 : props.error && RETRYABLE_TRANSCRIPTIONS.has(props.error)
50 ? { label: "Retry transcription", text: "Retry" }
51 : { label: "Record again", text: "Record again" };
52 return (
53 <VoiceInputPill tone="error" size={props.size}>
54 <Tooltip>
55 <TooltipTrigger
56 render={
57 <Button
58 type="button"
59 size={compact ? "icon-xs" : "icon-sm-pill"}
60 variant="ghost"
61 aria-label="Dismiss voice input error"
62 onPointerDown={(event) => event.preventDefault()}
63 onClick={props.onDismiss}
64 className="shrink-0"
65 >
66 <XIcon />
67 </Button>
68 }
69 />
70 <TooltipPopup side="top">
71 Dismiss
72 {props.cancelShortcutLabel ? ` (${props.cancelShortcutLabel})` : ""}
73 </TooltipPopup>
74 </Tooltip>
75 <Tooltip>
76 <TooltipTrigger
77 render={
78 <span
79 tabIndex={0}
80 role="status"
81 aria-label={message}
82 className="flex min-w-0 items-center gap-1.5 rounded-sm text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
83 />
84 }
85 >
86 <CircleAlertIcon aria-hidden className="size-3.5 shrink-0" />
87 <span className="truncate">
88 {(props.error && VOICE_ERROR_LABELS[props.error]) || "Voice input failed"}
89 </span>
90 </TooltipTrigger>
91 <TooltipPopup side="top">{message}</TooltipPopup>
92 </Tooltip>
93 {props.errorAction ? (
94 <Button
95 type="button"
96 size={compact ? "xs" : "sm-pill"}
97 variant="outline"
98 aria-label={action.label}
99 disabled={props.disabled}
100 onPointerDown={(event) => event.preventDefault()}
101 onClick={() => {
102 if (openSettings) {
103 props.onDismiss();
104 void navigate({ to: "/settings/voice" });
105 } else {
106 props.onRetry();
107 }
108 }}
109 className="ms-auto shrink-0"
110 >
111 {action.text}
112 </Button>
113 ) : (
114 <span aria-hidden className="w-1.5 shrink-0" />
115 )}
116 </VoiceInputPill>
117 );
118}