Skip to content

Commit adcec7c

Browse files
committed
refactor: clean up useVoiceInput with enum state
- Replace isListening/isTranscribing booleans with single state enum - Merge stopListening/stopListeningAndSend into stop(options?) - Rename methods: startListening→start, toggleListening→toggle - Consolidate callback refs into single callbacksRef object - Move platform checks (isMobile, isSupported) to module scope - Simplify VoiceInputButton with STATE_CONFIG lookup table - Inline simple callbacks in ChatInput (no separate handlers)
1 parent 4d9552d commit adcec7c

File tree

3 files changed

+115
-202
lines changed

3 files changed

+115
-202
lines changed

src/browser/components/ChatInput/VoiceInputButton.tsx

Lines changed: 18 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,55 @@
11
/**
22
* Voice input button - floats inside the chat input textarea.
33
* Minimal footprint: just an icon that changes color based on state.
4-
*
5-
* Visual states:
6-
* - Idle: Subtle gray mic icon
7-
* - Recording: Blue pulsing mic
8-
* - Transcribing: Amber spinning loader
9-
* - Disabled (no API key): Subtle gray with explanatory tooltip
10-
* - Hidden: When on mobile or unsupported
114
*/
125

136
import React from "react";
147
import { Mic, Loader2 } from "lucide-react";
158
import { TooltipWrapper, Tooltip } from "../Tooltip";
169
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
1710
import { cn } from "@/common/lib/utils";
11+
import type { VoiceInputState } from "@/browser/hooks/useVoiceInput";
1812

1913
interface VoiceInputButtonProps {
20-
isListening: boolean;
21-
isTranscribing: boolean;
22-
isSupported: boolean;
14+
state: VoiceInputState;
2315
isApiKeySet: boolean;
2416
shouldShowUI: boolean;
2517
onToggle: () => void;
2618
disabled?: boolean;
2719
}
2820

21+
const STATE_CONFIG: Record<VoiceInputState, { label: string; colorClass: string }> = {
22+
idle: { label: "Voice input", colorClass: "text-muted/50 hover:text-muted" },
23+
recording: { label: "Stop recording", colorClass: "text-blue-500 animate-pulse" },
24+
transcribing: { label: "Transcribing...", colorClass: "text-amber-500" },
25+
};
26+
2927
export const VoiceInputButton: React.FC<VoiceInputButtonProps> = (props) => {
30-
// Don't render on mobile or unsupported platforms
31-
if (!props.shouldShowUI) {
32-
return null;
33-
}
28+
if (!props.shouldShowUI) return null;
3429

3530
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";
31+
const { label, colorClass } = needsApiKey
32+
? { label: "Voice input (requires OpenAI API key)", colorClass: "text-muted/50" }
33+
: STATE_CONFIG[props.state];
4334

44-
const Icon = props.isTranscribing ? Loader2 : Mic;
35+
const Icon = props.state === "transcribing" ? Loader2 : Mic;
36+
const isTranscribing = props.state === "transcribing";
4537

4638
return (
4739
<TooltipWrapper inline>
4840
<button
4941
type="button"
5042
onClick={props.onToggle}
51-
disabled={
52-
(props.disabled ?? false) || !props.isSupported || props.isTranscribing || needsApiKey
53-
}
43+
disabled={(props.disabled ?? false) || isTranscribing || needsApiKey}
5444
aria-label={label}
55-
aria-pressed={props.isListening}
45+
aria-pressed={props.state === "recording"}
5646
className={cn(
5747
"inline-flex items-center justify-center rounded p-0.5 transition-colors duration-150",
5848
"disabled:cursor-not-allowed disabled:opacity-40",
59-
props.isTranscribing
60-
? "text-amber-500"
61-
: props.isListening
62-
? "text-blue-500 animate-pulse"
63-
: "text-muted/50 hover:text-muted"
49+
colorClass
6450
)}
6551
>
66-
<Icon className={cn("h-4 w-4", props.isTranscribing && "animate-spin")} strokeWidth={1.5} />
52+
<Icon className={cn("h-4 w-4", isTranscribing && "animate-spin")} strokeWidth={1.5} />
6753
</button>
6854
<Tooltip className="tooltip" align="right">
6955
{needsApiKey ? (

src/browser/components/ChatInput/index.tsx

Lines changed: 21 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -160,32 +160,17 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
160160
// Track if OpenAI API key is configured for voice input
161161
const [openAIKeySet, setOpenAIKeySet] = useState(false);
162162

163-
// Voice input handling - appends transcribed text to input
164-
const handleVoiceTranscript = useCallback(
165-
(text: string, _isFinal: boolean) => {
166-
// Whisper only returns final results, append to input with space separator if needed
163+
// Voice input - appends transcribed text to input
164+
const voiceInput = useVoiceInput({
165+
onTranscript: (text) => {
167166
setInput((prev) => {
168167
const separator = prev.length > 0 && !prev.endsWith(" ") ? " " : "";
169168
return prev + separator + text;
170169
});
171170
},
172-
[setInput]
173-
);
174-
175-
const handleVoiceError = useCallback(
176-
(error: string) => {
177-
setToast({
178-
id: Date.now().toString(),
179-
type: "error",
180-
message: error,
181-
});
171+
onError: (error) => {
172+
setToast({ id: Date.now().toString(), type: "error", message: error });
182173
},
183-
[setToast]
184-
);
185-
186-
const voiceInput = useVoiceInput({
187-
onTranscript: handleVoiceTranscript,
188-
onError: handleVoiceError,
189174
onSend: () => void handleSend(),
190175
openAIKeySet,
191176
});
@@ -508,7 +493,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
508493
});
509494
return;
510495
}
511-
voiceInput.toggleListening();
496+
voiceInput.toggle();
512497
};
513498
window.addEventListener(CUSTOM_EVENTS.TOGGLE_VOICE_INPUT, handler as EventListener);
514499
return () =>
@@ -857,7 +842,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
857842
});
858843
return;
859844
}
860-
voiceInput.toggleListening();
845+
voiceInput.toggle();
861846
return;
862847
}
863848

@@ -990,43 +975,42 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
990975
/>
991976

992977
<div className="relative flex items-end" data-component="ChatInputControls">
993-
{/* Recording/transcribing overlay - dramatically replaces textarea */}
994-
{voiceInput.isListening || voiceInput.isTranscribing ? (
978+
{/* Recording/transcribing overlay - replaces textarea when active */}
979+
{voiceInput.state !== "idle" ? (
995980
<button
996981
type="button"
997982
ref={(el) => el?.focus()}
998-
onClick={voiceInput.isListening ? voiceInput.toggleListening : undefined}
983+
onClick={voiceInput.state === "recording" ? voiceInput.toggle : undefined}
999984
onKeyDown={(e) => {
1000-
// Space stops recording and sends immediately after transcription
1001-
if (e.key === " " && voiceInput.isListening) {
985+
if (e.key === " " && voiceInput.state === "recording") {
1002986
e.preventDefault();
1003-
voiceInput.stopListeningAndSend();
987+
voiceInput.stop({ send: true });
1004988
}
1005989
}}
1006-
disabled={voiceInput.isTranscribing}
990+
disabled={voiceInput.state === "transcribing"}
1007991
className={cn(
1008992
"mb-1 flex min-h-[60px] w-full items-center justify-center gap-3 rounded-md border px-4 py-4 transition-all",
1009-
voiceInput.isListening
993+
voiceInput.state === "recording"
1010994
? "cursor-pointer border-blue-500 bg-blue-500/10"
1011995
: "cursor-wait border-amber-500 bg-amber-500/10"
1012996
)}
1013-
aria-label={voiceInput.isListening ? "Stop recording" : "Transcribing..."}
997+
aria-label={voiceInput.state === "recording" ? "Stop recording" : "Transcribing..."}
1014998
>
1015999
<WaveformBars
1016-
colorClass={voiceInput.isListening ? "bg-blue-500" : "bg-amber-500"}
1000+
colorClass={voiceInput.state === "recording" ? "bg-blue-500" : "bg-amber-500"}
10171001
/>
10181002
<span
10191003
className={cn(
10201004
"text-sm font-medium",
1021-
voiceInput.isListening ? "text-blue-500" : "text-amber-500"
1005+
voiceInput.state === "recording" ? "text-blue-500" : "text-amber-500"
10221006
)}
10231007
>
1024-
{voiceInput.isListening
1008+
{voiceInput.state === "recording"
10251009
? `Recording... space to send, ${formatKeybind(KEYBINDS.TOGGLE_VOICE_INPUT)} to stop`
10261010
: "Transcribing..."}
10271011
</span>
10281012
<WaveformBars
1029-
colorClass={voiceInput.isListening ? "bg-blue-500" : "bg-amber-500"}
1013+
colorClass={voiceInput.state === "recording" ? "bg-blue-500" : "bg-amber-500"}
10301014
mirrored
10311015
/>
10321016
</button>
@@ -1057,12 +1041,10 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10571041
{/* Floating voice input button inside textarea */}
10581042
<div className="absolute right-2 bottom-2">
10591043
<VoiceInputButton
1060-
isListening={voiceInput.isListening}
1061-
isTranscribing={voiceInput.isTranscribing}
1062-
isSupported={voiceInput.isSupported}
1044+
state={voiceInput.state}
10631045
isApiKeySet={voiceInput.isApiKeySet}
10641046
shouldShowUI={voiceInput.shouldShowUI}
1065-
onToggle={voiceInput.toggleListening}
1047+
onToggle={voiceInput.toggle}
10661048
disabled={disabled || isSending}
10671049
/>
10681050
</div>

0 commit comments

Comments
 (0)