add dictate option to file browser context menu

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 00:07:01 +00:00
co-authored by Claude Opus 4.6
parent 6a6ed5487d
commit 0a39c5043a
5 changed files with 220 additions and 2 deletions
@@ -92,7 +92,7 @@ export type UseAudioRecordingType = ReturnType<typeof useAudioRecording>;
// ── Helpers ──
const blobToWav = async (blob: Blob): Promise<Blob> => {
export const blobToWav = async (blob: Blob): Promise<Blob> => {
const ctx = new AudioContext();
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
await ctx.close();
@@ -4,6 +4,7 @@ import { UploadProgress } from './components/UploadProgress';
import { FileViewContainer } from './components/FileViewContainer';
import { TaskRunnerDialog } from './components/TaskRunnerDialog';
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
import { DictateDialog } from './components/DictateDialog';
import { useFileBrowserApp } from './useFileBrowserApp';
type DefaultSort = {
@@ -32,6 +33,7 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, initialPath, defa
<FileViewContainer fileBrowserManager={fileBrowserManager} />
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
<DictateDialog fileBrowserManager={fileBrowserManager} />
</div>
);
};
@@ -0,0 +1,208 @@
import { useEffect, useRef, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { blobToWav } from '../../../Chat/useAudioRecording';
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
type DictateDialogProps = {
fileBrowserManager: UseFileBrowserAppType;
};
export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
const { showDictate, setShowDictate, handleUpload } = fileBrowserManager;
const [recording, setRecording] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const animFrameRef = useRef<number>(0);
const streamRef = useRef<MediaStream | null>(null);
const cleanup = () => {
cancelAnimationFrame(animFrameRef.current);
mediaRecorderRef.current?.stream?.getTracks().forEach((t) => t.stop());
streamRef.current?.getTracks().forEach((t) => t.stop());
audioCtxRef.current?.close();
mediaRecorderRef.current = null;
streamRef.current = null;
audioCtxRef.current = null;
analyserRef.current = null;
chunksRef.current = [];
setRecording(false);
};
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const audioCtx = new AudioContext();
audioCtxRef.current = audioCtx;
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyserRef.current = analyser;
const recorder = new MediaRecorder(stream);
mediaRecorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunksRef.current.push(ev.data);
};
recorder.start(250);
setRecording(true);
drawSpectrum();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Could not access microphone');
setShowDictate(false);
}
};
const drawSpectrum = () => {
const canvas = canvasRef.current;
const analyser = analyserRef.current;
if (!canvas || !analyser) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
animFrameRef.current = requestAnimationFrame(draw);
analyser.getByteFrequencyData(dataArray);
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
const barWidth = (w / bufferLength) * 2;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i]! / 255) * h;
ctx.fillStyle = `hsl(174, 60%, ${40 + (dataArray[i]! / 255) * 30}%)`;
ctx.fillRect(x, h - barHeight, barWidth - 1, barHeight);
x += barWidth;
}
};
draw();
};
const stopRecording = async () => {
const recorder = mediaRecorderRef.current;
if (!recorder || recorder.state === 'inactive') {
cleanup();
return;
}
try {
const blob = await new Promise<Blob>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Recording stop timed out')), 5000);
recorder.onstop = () => {
clearTimeout(timeout);
resolve(new Blob(chunksRef.current, { type: recorder.mimeType }));
chunksRef.current = [];
};
recorder.stop();
});
cancelAnimationFrame(animFrameRef.current);
recorder.stream.getTracks().forEach((t) => t.stop());
streamRef.current?.getTracks().forEach((t) => t.stop());
audioCtxRef.current?.close();
if (blob.size === 0) {
toast.error('No audio was captured');
cleanup();
setShowDictate(false);
return;
}
const wav = await blobToWav(blob);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const file = new File([wav], `dictation_${ts}.wav`, { type: 'audio/wav' });
setShowDictate(false);
setRecording(false);
await handleUpload([file]);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Recording failed');
cleanup();
setShowDictate(false);
}
};
useEffect(() => {
if (showDictate) {
startRecording();
}
return () => {
if (!showDictate) cleanup();
};
}, [showDictate]);
useEffect(() => {
if (!showDictate || !recording) return;
const handler = (ev: KeyboardEvent) => {
if (ev.key === 'Enter') {
ev.preventDefault();
stopRecording();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [showDictate, recording]);
return (
<Dialog
open={showDictate}
onOpenChange={(open) => {
if (!open) {
cleanup();
setShowDictate(false);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Dictate</DialogTitle>
<DialogDescription>Recording audio. Press Enter or click Stop to save.</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4">
<canvas ref={canvasRef} width={460} height={120} className="w-full rounded-md bg-duck-dark/5" />
<div className="flex items-center gap-2">
{recording && <span className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />}
<span className="text-sm text-duck-dark/60">{recording ? 'Recording...' : 'Starting...'}</span>
</div>
<div className="flex justify-end w-full gap-2">
<button
type="button"
onClick={() => {
cleanup();
setShowDictate(false);
}}
className="px-4 py-2 text-sm rounded-md text-duck-dark/70 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={stopRecording}
disabled={!recording}
className="px-4 py-2 text-sm rounded-md bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Stop & Save
</button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -1,4 +1,4 @@
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download } from 'lucide-react';
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download, Mic } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
@@ -28,6 +28,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
handleCreateDir,
handleCreateDashboardHere,
setShowVideoDownload,
setShowDictate,
} = fileBrowserManager;
return (
@@ -128,6 +129,10 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
<Download className="mr-2 h-4 w-4" />
Download video
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowDictate(true)} className="cursor-pointer">
<Mic className="mr-2 h-4 w-4" />
Dictate
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
@@ -43,6 +43,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
const [showVideoDownload, setShowVideoDownload] = useState(false);
const [videoUrl, setVideoUrl] = useState('');
const [audioOnly, setAudioOnly] = useState(false);
const [showDictate, setShowDictate] = useState(false);
const dragCounter = useRef(0);
const { getMatchingTasks } = useTasks();
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -688,6 +689,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
showVideoDownload, setShowVideoDownload,
videoUrl, setVideoUrl,
audioOnly, setAudioOnly,
// Dictate
showDictate, setShowDictate,
// Refs
fileScrollRef,
// Handlers