Skip to content

Commit 4d9552d

Browse files
committed
refactor: voice input cleanup and user education
User education: - Show mic button even without OpenAI key (disabled with tooltip) - Tooltip explains: 'Configure in Settings → Providers' - Toast error when trying to use keybind/command without key DRY improvements: - Extract WaveformBars component for reusable animated bars - Remove unused Web Speech API error message mappings Code quality: - Add isApiKeySet to hook result for explicit checking - shouldShowUI now only checks platform support, not API key - Verified no race conditions in hook logic
1 parent bc400fb commit 4d9552d

File tree

4 files changed

+91
-51
lines changed

4 files changed

+91
-51
lines changed

src/browser/components/ChatInput/VoiceInputButton.tsx

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
* - Idle: Subtle gray mic icon
77
* - Recording: Blue pulsing mic
88
* - Transcribing: Amber spinning loader
9-
* - Hidden: When on mobile, unsupported, or no OpenAI key
9+
* - Disabled (no API key): Subtle gray with explanatory tooltip
10+
* - Hidden: When on mobile or unsupported
1011
*/
1112

1213
import React from "react";
@@ -19,22 +20,26 @@ interface VoiceInputButtonProps {
1920
isListening: boolean;
2021
isTranscribing: boolean;
2122
isSupported: boolean;
23+
isApiKeySet: boolean;
2224
shouldShowUI: boolean;
2325
onToggle: () => void;
2426
disabled?: boolean;
2527
}
2628

2729
export const VoiceInputButton: React.FC<VoiceInputButtonProps> = (props) => {
28-
// Don't render if we shouldn't show UI (mobile, unsupported, or no OpenAI key)
30+
// Don't render on mobile or unsupported platforms
2931
if (!props.shouldShowUI) {
3032
return null;
3133
}
3234

33-
const label = props.isTranscribing
34-
? "Transcribing..."
35-
: props.isListening
36-
? "Stop recording"
37-
: "Voice input";
35+
const needsApiKey = !props.isApiKeySet;
36+
const label = needsApiKey
37+
? "Voice input (requires OpenAI API key)"
38+
: props.isTranscribing
39+
? "Transcribing..."
40+
: props.isListening
41+
? "Stop recording"
42+
: "Voice input";
3843

3944
const Icon = props.isTranscribing ? Loader2 : Mic;
4045

@@ -43,7 +48,9 @@ export const VoiceInputButton: React.FC<VoiceInputButtonProps> = (props) => {
4348
<button
4449
type="button"
4550
onClick={props.onToggle}
46-
disabled={(props.disabled ?? false) || !props.isSupported || props.isTranscribing}
51+
disabled={
52+
(props.disabled ?? false) || !props.isSupported || props.isTranscribing || needsApiKey
53+
}
4754
aria-label={label}
4855
aria-pressed={props.isListening}
4956
className={cn(
@@ -59,7 +66,17 @@ export const VoiceInputButton: React.FC<VoiceInputButtonProps> = (props) => {
5966
<Icon className={cn("h-4 w-4", props.isTranscribing && "animate-spin")} strokeWidth={1.5} />
6067
</button>
6168
<Tooltip className="tooltip" align="right">
62-
{label} ({formatKeybind(KEYBINDS.TOGGLE_VOICE_INPUT)})
69+
{needsApiKey ? (
70+
<>
71+
Voice input requires OpenAI API key.
72+
<br />
73+
Configure in Settings → Providers.
74+
</>
75+
) : (
76+
<>
77+
{label} ({formatKeybind(KEYBINDS.TOGGLE_VOICE_INPUT)})
78+
</>
79+
)}
6380
</Tooltip>
6481
</TooltipWrapper>
6582
);
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Animated waveform bars for voice recording UI.
3+
* Shows 5 bars with staggered pulse animation.
4+
*/
5+
6+
import { cn } from "@/common/lib/utils";
7+
8+
interface WaveformBarsProps {
9+
/** Color class for the bars (e.g., "bg-blue-500") */
10+
colorClass: string;
11+
/** Whether to mirror the animation (for right-side waveform) */
12+
mirrored?: boolean;
13+
}
14+
15+
export const WaveformBars: React.FC<WaveformBarsProps> = (props) => {
16+
const indices = props.mirrored ? [4, 3, 2, 1, 0] : [0, 1, 2, 3, 4];
17+
18+
return (
19+
<div className="flex items-center gap-1">
20+
{indices.map((i, displayIndex) => (
21+
<div
22+
key={displayIndex}
23+
className={cn("w-1 rounded-full", props.colorClass)}
24+
style={{
25+
height: `${12 + Math.sin(i * 0.8) * 8}px`,
26+
animation: `pulse 0.8s ease-in-out ${i * 0.1}s infinite alternate`,
27+
}}
28+
/>
29+
))}
30+
</div>
31+
);
32+
};

src/browser/components/ChatInput/index.tsx

Lines changed: 27 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { useCreationWorkspace } from "./useCreationWorkspace";
6767
import { useTutorial } from "@/browser/contexts/TutorialContext";
6868
import { useVoiceInput } from "@/browser/hooks/useVoiceInput";
6969
import { VoiceInputButton } from "./VoiceInputButton";
70+
import { WaveformBars } from "./WaveformBars";
7071

7172
const LEADING_COMMAND_NOISE = /^(?:\s|\u200B|\u200C|\u200D|\u200E|\u200F|\uFEFF)+/;
7273

@@ -173,17 +174,10 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
173174

174175
const handleVoiceError = useCallback(
175176
(error: string) => {
176-
// Map common errors to user-friendly messages
177-
const errorMessages: Record<string, string> = {
178-
"not-allowed": "Microphone access denied. Please allow microphone access and try again.",
179-
"no-speech": "No speech detected. Please try again.",
180-
network: "Network error. Please check your connection.",
181-
"audio-capture": "No microphone found. Please connect a microphone.",
182-
};
183177
setToast({
184178
id: Date.now().toString(),
185179
type: "error",
186-
message: errorMessages[error] ?? `Voice input error: ${error}`,
180+
message: error,
187181
});
188182
},
189183
[setToast]
@@ -506,12 +500,20 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
506500
if (!voiceInput.shouldShowUI) return;
507501

508502
const handler = () => {
503+
if (!voiceInput.isApiKeySet) {
504+
setToast({
505+
id: Date.now().toString(),
506+
type: "error",
507+
message: "Voice input requires OpenAI API key. Configure in Settings → Providers.",
508+
});
509+
return;
510+
}
509511
voiceInput.toggleListening();
510512
};
511513
window.addEventListener(CUSTOM_EVENTS.TOGGLE_VOICE_INPUT, handler as EventListener);
512514
return () =>
513515
window.removeEventListener(CUSTOM_EVENTS.TOGGLE_VOICE_INPUT, handler as EventListener);
514-
}, [voiceInput]);
516+
}, [voiceInput, setToast]);
515517

516518
// Auto-focus chat input when workspace changes (workspace only)
517519
const workspaceIdForFocus = variant === "workspace" ? props.workspaceId : null;
@@ -847,6 +849,14 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
847849
// Handle voice input toggle (Ctrl+D / Cmd+D)
848850
if (matchesKeybind(e, KEYBINDS.TOGGLE_VOICE_INPUT) && voiceInput.shouldShowUI) {
849851
e.preventDefault();
852+
if (!voiceInput.isApiKeySet) {
853+
setToast({
854+
id: Date.now().toString(),
855+
type: "error",
856+
message: "Voice input requires OpenAI API key. Configure in Settings → Providers.",
857+
});
858+
return;
859+
}
850860
voiceInput.toggleListening();
851861
return;
852862
}
@@ -1002,22 +1012,9 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10021012
)}
10031013
aria-label={voiceInput.isListening ? "Stop recording" : "Transcribing..."}
10041014
>
1005-
{/* Animated waveform bars */}
1006-
<div className="flex items-center gap-1">
1007-
{[0, 1, 2, 3, 4].map((i) => (
1008-
<div
1009-
key={i}
1010-
className={cn(
1011-
"w-1 rounded-full",
1012-
voiceInput.isListening ? "bg-blue-500" : "bg-amber-500"
1013-
)}
1014-
style={{
1015-
height: `${12 + Math.sin(i * 0.8) * 8}px`,
1016-
animation: `pulse 0.8s ease-in-out ${i * 0.1}s infinite alternate`,
1017-
}}
1018-
/>
1019-
))}
1020-
</div>
1015+
<WaveformBars
1016+
colorClass={voiceInput.isListening ? "bg-blue-500" : "bg-amber-500"}
1017+
/>
10211018
<span
10221019
className={cn(
10231020
"text-sm font-medium",
@@ -1028,21 +1025,10 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10281025
? `Recording... space to send, ${formatKeybind(KEYBINDS.TOGGLE_VOICE_INPUT)} to stop`
10291026
: "Transcribing..."}
10301027
</span>
1031-
<div className="flex items-center gap-1">
1032-
{[0, 1, 2, 3, 4].map((i) => (
1033-
<div
1034-
key={i}
1035-
className={cn(
1036-
"w-1 rounded-full",
1037-
voiceInput.isListening ? "bg-blue-500" : "bg-amber-500"
1038-
)}
1039-
style={{
1040-
height: `${12 + Math.sin((4 - i) * 0.8) * 8}px`,
1041-
animation: `pulse 0.8s ease-in-out ${(4 - i) * 0.1}s infinite alternate`,
1042-
}}
1043-
/>
1044-
))}
1045-
</div>
1028+
<WaveformBars
1029+
colorClass={voiceInput.isListening ? "bg-blue-500" : "bg-amber-500"}
1030+
mirrored
1031+
/>
10461032
</button>
10471033
) : (
10481034
<>
@@ -1074,6 +1060,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10741060
isListening={voiceInput.isListening}
10751061
isTranscribing={voiceInput.isTranscribing}
10761062
isSupported={voiceInput.isSupported}
1063+
isApiKeySet={voiceInput.isApiKeySet}
10771064
shouldShowUI={voiceInput.shouldShowUI}
10781065
onToggle={voiceInput.toggleListening}
10791066
disabled={disabled || isSending}

src/browser/hooks/useVoiceInput.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ export interface UseVoiceInputResult {
4242
isTranscribing: boolean;
4343
/** Whether the browser supports MediaRecorder */
4444
isSupported: boolean;
45-
/** Whether we should show voice UI (supported, not mobile, API key set) */
45+
/** Whether OpenAI API key is configured */
46+
isApiKeySet: boolean;
47+
/** Whether we should show voice UI (supported and not mobile) */
4648
shouldShowUI: boolean;
4749
/** Start recording for voice input */
4850
startListening: () => void;
@@ -205,7 +207,9 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
205207
isListening,
206208
isTranscribing,
207209
isSupported,
208-
shouldShowUI: isSupported && !isMobile && openAIKeySet,
210+
isApiKeySet: openAIKeySet,
211+
// Show UI on supported desktop platforms (mobile has native dictation)
212+
shouldShowUI: isSupported && !isMobile,
209213
startListening: () => void startListening(),
210214
stopListening,
211215
stopListeningAndSend,

0 commit comments

Comments
 (0)