Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
46700a7
feat: implement voice mode improvements with continuous loop, audio e…
devin-ai-integration[bot] Mar 6, 2026
cbf891e
style: fix Prettier formatting in RecordingOverlay and UnifiedChat
devin-ai-integration[bot] Mar 6, 2026
279cdd0
fix: track recording start time with ref, fix stale closure in handle…
devin-ai-integration[bot] Mar 6, 2026
2201b78
fix: use startRecordingRef in voice continuation effect, fix || vs ??…
devin-ai-integration[bot] Mar 6, 2026
b6ce319
fix: add isTauri() guard for iOS TTS platform check, restart recordin…
devin-ai-integration[bot] Mar 6, 2026
2f5cc62
fix: use refs for exitVoiceMode recorder check and stale handleSendMe…
devin-ai-integration[bot] Mar 6, 2026
08db7a3
style: fix Prettier formatting for ref type declarations
devin-ai-integration[bot] Mar 6, 2026
8e1d985
fix: call cancelTTSGeneration/stopTTS unconditionally in exitVoiceMod…
devin-ai-integration[bot] Mar 6, 2026
f272943
fix: clear voiceState after non-voice retry success, exit voice mode …
devin-ai-integration[bot] Mar 6, 2026
995bfa1
fix: re-throw TTS playback error so speakAndWait rejects and voice mo…
devin-ai-integration[bot] Mar 6, 2026
c12676a
fix: catch errors in speak() wrapper to prevent unhandled rejection i…
devin-ai-integration[bot] Mar 6, 2026
09d2535
fix: reset voiceRetryCount and recordingDuration when dismissing erro…
devin-ai-integration[bot] Mar 6, 2026
013626c
fix: TestFlight bugs - show playing state in compact overlay, iOS aud…
devin-ai-integration[bot] Mar 6, 2026
8d2c190
style: run Prettier on RecordingOverlay and UnifiedChat
devin-ai-integration[bot] Mar 6, 2026
6941054
fix: reset timer on re-entering recording state, remove duplicate mic…
devin-ai-integration[bot] Mar 6, 2026
7f74698
fix: prevent AudioContext leak and handle empty preprocessed TTS text
devin-ai-integration[bot] Mar 6, 2026
ef634eb
fix: exit voice mode on startRecording failure, guard stale audioCont…
devin-ai-integration[bot] Mar 6, 2026
0cc9e9b
fix: TestFlight round 2 - iOS audio cues resume(), duration flash fix…
devin-ai-integration[bot] Mar 6, 2026
2ad3d51
debug: add test buttons for audio cue debugging on mobile Safari
devin-ai-integration[bot] Mar 6, 2026
acca493
fix: cancelGeneration now stops playback to prevent audio during asyn…
devin-ai-integration[bot] Mar 6, 2026
7feeec0
fix: play audio cues before mic activation and after mic deactivation…
devin-ai-integration[bot] Mar 6, 2026
596f5d9
fix: restore audioSession type after cue playback to allow mic capture
devin-ai-integration[bot] Mar 6, 2026
08cfc92
fix: use play-and-record audioSession type instead of playback
devin-ai-integration[bot] Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added frontend/public/audio/mic-off.wav
Binary file not shown.
Binary file added frontend/public/audio/mic-on.wav
Binary file not shown.
243 changes: 195 additions & 48 deletions frontend/src/components/RecordingOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import { useEffect, useState, useRef, useMemo } from "react";
import { X, CornerRightUp, Loader2 } from "lucide-react";
import { X, CornerRightUp, Loader2, RotateCcw, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/utils";

/** The current phase shown by the overlay. */
export type VoiceOverlayState =
| "recording"
| "processing"
| "error"
| "waiting"
| "generating"
| "playing";

interface RecordingOverlayProps {
isRecording: boolean;
isProcessing?: boolean;
onSend: () => void;
onCancel: () => void;
isCompact?: boolean;
className?: string;

// Voice-mode extensions
/** Current voice-mode state (defaults to recording/processing based on isRecording/isProcessing) */
voiceState?: VoiceOverlayState;
/** Error message to display in error state */
errorMessage?: string;
/** Duration of the recording that failed (shown in error state) */
savedDuration?: number;
/** Called when user taps Retry in error state */
onRetry?: () => void;
/** Called when user taps Discard in error state */
onDiscard?: () => void;
}

export function RecordingOverlay({
Expand All @@ -18,14 +39,38 @@ export function RecordingOverlay({
onSend,
onCancel,
isCompact = false,
className
className,
voiceState: voiceStateProp,
errorMessage,
savedDuration,
onRetry,
onDiscard
}: RecordingOverlayProps) {
// Derive the effective state: use voiceState prop if provided, otherwise fall back
const effectiveState: VoiceOverlayState = voiceStateProp
? voiceStateProp
: isProcessing
? "processing"
: "recording";

const [duration, setDuration] = useState(0);
const startTimeRef = useRef<number>(0);
const animationFrameRef = useRef<number>();

// Reset duration immediately when effectiveState changes away from recording
// so the next time recording starts, it doesn't flash the old value
const prevEffectiveStateRef = useRef(effectiveState);
if (prevEffectiveStateRef.current !== effectiveState) {
prevEffectiveStateRef.current = effectiveState;
if (effectiveState === "recording") {
// Synchronous state reset — avoids the one-frame flash of stale duration
// that happens when setDuration(0) is called only inside useEffect
setDuration(0);
}
}

useEffect(() => {
if (isRecording && !isProcessing) {
if (effectiveState === "recording") {
startTimeRef.current = Date.now();

const updateTimer = () => {
Expand All @@ -42,14 +87,17 @@ export function RecordingOverlay({
}
};
}
}, [isRecording, isProcessing]);
}, [effectiveState]);

const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
};

// Determine color scheme based on state
const isPlaybackStyle = effectiveState === "generating" || effectiveState === "playing";

// Generate stable bar configurations once when component mounts
const waveformBars = useMemo(() => {
const barCount = 30;
Expand Down Expand Up @@ -83,24 +131,109 @@ export function RecordingOverlay({
return bars;
}, []); // Empty deps = generated once

const shouldAnimate =
effectiveState === "recording" ||
effectiveState === "generating" ||
effectiveState === "playing";

const renderWaveformBars = () => {
const barColorClass = isPlaybackStyle ? "bg-blue-400/50" : "bg-primary/40";
const animName = isPlaybackStyle ? "pulse-blue" : "pulse";

return waveformBars.map((bar, i) => (
<div
key={i}
className="flex-shrink-0 bg-primary/40 rounded-full"
className={cn("flex-shrink-0 rounded-full", barColorClass)}
style={{
width: "2px",
height: `${bar.height}%`,
animation: isRecording
? `pulse ${bar.animationDuration}s ease-in-out ${bar.animationDelay}s infinite`
animation: shouldAnimate
? `${animName} ${bar.animationDuration}s ease-in-out ${bar.animationDelay}s infinite`
: "none",
transition: "height 0.3s ease-out"
}}
/>
));
};

if (!isRecording) return null;
// Show the overlay when recording OR when in any voice-mode state
const isVisible = isRecording || !!voiceStateProp;
if (!isVisible) return null;

// Whether the top-right send button should be shown (only in recording state)
const showSendButton = effectiveState === "recording" || effectiveState === "processing";

const renderStatusContent = () => {
switch (effectiveState) {
case "recording":
return (
<>
<div className="w-2 h-2 bg-destructive rounded-full animate-pulse" />
Recording
</>
);
case "processing":
return (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
</>
);
case "error":
return (
<div className="flex flex-col items-center gap-3">
<div className="text-destructive text-sm text-center max-w-xs">
{errorMessage || "Transcription failed"}
</div>
{savedDuration !== undefined && (
<div className="text-xs text-muted-foreground">
Recording: {formatTime(savedDuration)}
</div>
)}
<div className="flex items-center gap-2">
{onRetry && (
<Button onClick={onRetry} variant="outline" size="sm" className="gap-1.5">
<RotateCcw className="h-3.5 w-3.5" />
Retry
</Button>
)}
{onDiscard && (
<Button
onClick={onDiscard}
variant="ghost"
size="sm"
className="gap-1.5 text-muted-foreground"
>
<Trash2 className="h-3.5 w-3.5" />
Discard
</Button>
)}
</div>
</div>
);
case "waiting":
return (
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-400 rounded-full animate-[breathing_2s_ease-in-out_infinite]" />
Waiting for response...
</div>
);
case "generating":
return (
<div className="flex items-center gap-2 text-blue-400">
<Loader2 className="w-4 h-4 animate-spin" />
Generating audio...
</div>
);
case "playing":
return (
<div className="flex items-center gap-2 text-blue-400">
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse" />
Playing
</div>
);
}
};

return (
<div
Expand All @@ -116,68 +249,82 @@ export function RecordingOverlay({
0%, 100% { transform: scaleY(0.5); opacity: 0.6; }
50% { transform: scaleY(1); opacity: 1; }
}
@keyframes pulse-blue {
0%, 100% { transform: scaleY(0.5); opacity: 0.5; }
50% { transform: scaleY(1); opacity: 0.9; }
}
@keyframes breathing {
0%, 100% { opacity: 0.4; transform: scale(0.9); }
50% { opacity: 1; transform: scale(1.1); }
}
`}
</style>

<div className="w-full h-full rounded-lg bg-background/95 backdrop-blur-sm border border-primary/20 relative overflow-hidden flex flex-col items-center justify-center p-4">
{/* Top buttons - Cancel on left, Send on right */}
<div
className={cn(
"w-full h-full rounded-lg bg-background/95 backdrop-blur-sm border relative overflow-hidden flex flex-col items-center justify-center p-4",
isPlaybackStyle ? "border-blue-400/30" : "border-primary/20"
)}
>
{/* Top buttons */}
<div className="absolute top-3 left-3 right-3 flex justify-between">
<Button
onClick={onCancel}
variant="ghost"
size="icon"
className="rounded-full hover:bg-muted"
aria-label="Cancel recording"
disabled={isProcessing}
aria-label={effectiveState === "recording" ? "Cancel recording" : "Exit voice mode"}
disabled={effectiveState === "processing"}
>
<X className="h-4 w-4" />
</Button>

<Button
onClick={onSend}
size={isCompact ? "icon" : "sm"}
className={cn(isCompact ? "rounded-full" : "gap-1.5")}
aria-label="Send recording"
disabled={isProcessing}
>
{isProcessing ? (
<Loader2 className={cn(isCompact ? "h-4 w-4" : "h-3.5 w-3.5", "animate-spin")} />
) : isCompact ? (
<CornerRightUp className="h-4 w-4" />
) : (
<>
<CornerRightUp className="h-3.5 w-3.5" />
Send
</>
)}
</Button>
{showSendButton && (
<Button
onClick={onSend}
size={isCompact ? "icon" : "sm"}
className={cn(isCompact ? "rounded-full" : "gap-1.5")}
aria-label="Send recording"
disabled={effectiveState === "processing"}
>
{effectiveState === "processing" ? (
<Loader2 className={cn(isCompact ? "h-4 w-4" : "h-3.5 w-3.5", "animate-spin")} />
) : isCompact ? (
<CornerRightUp className="h-4 w-4" />
) : (
<>
<CornerRightUp className="h-3.5 w-3.5" />
Send
</>
)}
</Button>
)}
</div>

<div className="flex flex-col items-center gap-6 max-w-md w-full">
{/* Waveform visualization - only show when not compact */}
{!isCompact && (
<div
className={cn(
"flex flex-col items-center max-w-md w-full",
isCompact ? "gap-2" : "gap-6"
)}
>
{/* Waveform visualization - show for recording (non-compact), generating, and playing (always) */}
{((!isCompact && effectiveState === "recording") ||
effectiveState === "generating" ||
effectiveState === "playing") && (
<div className="flex items-center justify-center h-12 w-full gap-0.5 px-4">
{renderWaveformBars()}
</div>
)}

{/* Timer */}
<div className="text-2xl font-mono text-muted-foreground">{formatTime(duration)}</div>
{/* Timer - show during recording */}
{(effectiveState === "recording" || effectiveState === "processing") && (
<div className="text-2xl font-mono text-muted-foreground">{formatTime(duration)}</div>
)}

{/* Status indicator - only show when not compact */}
{!isCompact && (
{/* Status indicator - show in all modes for voice states, only non-compact for recording */}
{(!isCompact || (effectiveState !== "recording" && effectiveState !== "processing")) && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
</>
) : (
<>
<div className="w-2 h-2 bg-destructive rounded-full animate-pulse" />
Recording
</>
)}
{renderStatusContent()}
</div>
)}
</div>
Expand Down
Loading
Loading