From 586b9794edacfecd9011d55225d8fa7d5110b344 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 15 Jan 2026 17:07:16 -0300 Subject: [PATCH 01/18] fix: adjust margin-top in FileQueue CSS for better layout --- .../features/transcription/components/FileQueue/FileQueue.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/features/transcription/components/FileQueue/FileQueue.css b/src/renderer/features/transcription/components/FileQueue/FileQueue.css index 5ac1d73..dfc8b5f 100644 --- a/src/renderer/features/transcription/components/FileQueue/FileQueue.css +++ b/src/renderer/features/transcription/components/FileQueue/FileQueue.css @@ -2,7 +2,7 @@ background: var(--surface, #1a1a1f); border: 1px solid var(--border, rgba(255, 255, 255, 0.1)); border-radius: var(--radius, 8px); - margin-top: 12px; + margin-top: 10px; display: flex; flex-direction: column; } From d6b73027e299c7ca105124a65153afe7b0742df1 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 19 Feb 2026 20:54:07 -0300 Subject: [PATCH 02/18] fix: simplify language argument handling in transcribe function --- src/main/services/whisper.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/services/whisper.ts b/src/main/services/whisper.ts index 29d440f..dd48e66 100644 --- a/src/main/services/whisper.ts +++ b/src/main/services/whisper.ts @@ -512,8 +512,7 @@ export function transcribe( outputBase, ]; - // Add language if specified - if (language && language !== 'auto') { + if (language) { args.push('-l', language); } From f6746dda1be8e9302b13b36f97004439d60bcdcd Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 19 Feb 2026 21:09:03 -0300 Subject: [PATCH 03/18] feat: implement duplicate file handling in batch queue and UI updates --- src/main/ipc/index.ts | 9 +++ src/main/utils/media-info.ts | 40 ++++++++++ .../components/layout/LeftPanel/LeftPanel.tsx | 2 + src/renderer/contexts/AppContext.tsx | 3 + src/renderer/contexts/types.ts | 1 + .../__tests__/useBatchQueue.test.ts | 77 ++++++++++++++++++- .../components/FileDropZone/FileDropZone.css | 11 +++ .../components/FileDropZone/FileDropZone.tsx | 8 ++ .../__tests__/FileDropZone.test.tsx | 8 ++ .../transcription/hooks/useBatchQueue.ts | 65 ++++++++++++---- src/shared/types.ts | 1 + 11 files changed, 211 insertions(+), 14 deletions(-) create mode 100644 src/main/utils/media-info.ts diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 0cae402..ad0b9a5 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -16,6 +16,7 @@ import { generatePdfDocument, generateMarkdownDocument, } from '../utils/export-helper'; +import { generateFileFingerprint } from '../utils/media-info'; import { trackEvent, AnalyticsEvents } from '../services/analytics'; import type { TranscriptionOptions, SaveFileOptions } from '../../shared/types'; @@ -92,10 +93,18 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { ipcMain.handle('file:getInfo', async (_event, filePath: string) => { try { const stats = fs.statSync(filePath); + let fingerprint: string | undefined; + try { + fingerprint = generateFileFingerprint(filePath, stats.size); + } catch { + fingerprint = undefined; + } + return { name: path.basename(filePath), path: filePath, size: stats.size, + fingerprint, }; } catch { return null; diff --git a/src/main/utils/media-info.ts b/src/main/utils/media-info.ts new file mode 100644 index 0000000..1083ab9 --- /dev/null +++ b/src/main/utils/media-info.ts @@ -0,0 +1,40 @@ +import fs from 'fs'; +import crypto from 'crypto'; + +const CHUNK_SIZE = 128 * 1024; // 128 KB + +function readChunk(fd: number, position: number, length: number): Buffer { + const safeLength = Math.max(0, Math.min(length, CHUNK_SIZE)); + if (safeLength === 0) { + return Buffer.alloc(0); + } + + const buffer = Buffer.allocUnsafe(safeLength); + const bytesRead = fs.readSync(fd, buffer, 0, safeLength, position); + return bytesRead === safeLength ? buffer : buffer.subarray(0, bytesRead); +} + +export function generateFileFingerprint(filePath: string, fileSize: number): string { + const hash = crypto.createHash('sha256'); + hash.update(String(fileSize)); + + const fd = fs.openSync(filePath, 'r'); + try { + const firstChunkSize = Math.min(fileSize, CHUNK_SIZE); + hash.update(readChunk(fd, 0, firstChunkSize)); + + if (fileSize > CHUNK_SIZE * 2) { + const middleOffset = Math.max(0, Math.floor(fileSize / 2) - Math.floor(CHUNK_SIZE / 2)); + hash.update(readChunk(fd, middleOffset, CHUNK_SIZE)); + } + + if (fileSize > CHUNK_SIZE) { + const lastOffset = Math.max(0, fileSize - CHUNK_SIZE); + hash.update(readChunk(fd, lastOffset, CHUNK_SIZE)); + } + } finally { + fs.closeSync(fd); + } + + return hash.digest('hex'); +} diff --git a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx index 8dbc9b9..471f54b 100644 --- a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx +++ b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx @@ -15,6 +15,7 @@ function LeftPanel(): React.JSX.Element { setSettings, setModelDownloaded, queue, + duplicateFilesSkipped, selectedQueueItemId, handleFilesSelect, removeFromQueue, @@ -36,6 +37,7 @@ function LeftPanel(): React.JSX.Element { diff --git a/src/renderer/contexts/AppContext.tsx b/src/renderer/contexts/AppContext.tsx index b517d06..b8b6f83 100644 --- a/src/renderer/contexts/AppContext.tsx +++ b/src/renderer/contexts/AppContext.tsx @@ -55,6 +55,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { const { queue, isProcessing, + duplicateFilesSkipped, addFiles, removeFile, clearCompleted, @@ -199,6 +200,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { transcription, error, modelDownloaded, + duplicateFilesSkipped, copySuccess, queue, selectedQueueItemId, @@ -210,6 +212,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { transcription, error, modelDownloaded, + duplicateFilesSkipped, copySuccess, queue, selectedQueueItemId, diff --git a/src/renderer/contexts/types.ts b/src/renderer/contexts/types.ts index ec5c266..55fa002 100644 --- a/src/renderer/contexts/types.ts +++ b/src/renderer/contexts/types.ts @@ -30,6 +30,7 @@ export interface TranscriptionStateContextValue { transcription: string; error: string | null; modelDownloaded: boolean; + duplicateFilesSkipped: number; copySuccess: boolean; queue: QueueItem[]; selectedQueueItemId: string | null; diff --git a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts index d10c7d6..5a6c8fb 100644 --- a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts +++ b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts @@ -8,6 +8,7 @@ import type { TranscriptionResult, TranscriptionProgress, } from '@/types'; +import { logger } from '@/services/logger'; describe('useBatchQueue', () => { const mockSettings: TranscriptionSettings = { @@ -18,10 +19,14 @@ describe('useBatchQueue', () => { const mockOnHistoryAdd = vi.fn(); const mockOnFirstComplete = vi.fn(); - const createMockSelectedFile = (name: string): SelectedFile => ({ + const createMockSelectedFile = ( + name: string, + overrides: Partial = {} + ): SelectedFile => ({ name, path: `/path/to/${name}`, size: 1024, + ...overrides, }); beforeEach(() => { @@ -76,6 +81,76 @@ describe('useBatchQueue', () => { const ids = result.current.queue.map((item) => item.id); expect(new Set(ids).size).toBe(2); }); + + it('should skip duplicate file paths and expose skipped count', () => { + const warnSpy = vi.spyOn(logger, 'warn'); + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + const duplicateFile = createMockSelectedFile('audio1.mp3'); + const newFile = createMockSelectedFile('audio2.mp3'); + + act(() => { + result.current.addFiles([duplicateFile]); + }); + + expect(result.current.queue).toHaveLength(1); + expect(result.current.duplicateFilesSkipped).toBe(0); + + act(() => { + result.current.addFiles([duplicateFile, newFile, duplicateFile]); + }); + + expect(result.current.queue).toHaveLength(2); + expect(result.current.queue[0]!.file.path).toBe('/path/to/audio1.mp3'); + expect(result.current.queue[1]!.file.path).toBe('/path/to/audio2.mp3'); + expect(result.current.duplicateFilesSkipped).toBe(2); + expect(warnSpy).toHaveBeenCalledWith('Skipped duplicate files in batch queue', { + count: 2, + files: ['audio1.mp3', 'audio1.mp3'], + }); + }); + + it('should skip content duplicates using fingerprint even with different file paths', () => { + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + const fingerprint = 'shared-fingerprint'; + const originalFile = createMockSelectedFile('audio1.mp3', { + path: '/desktop/audio1.mp3', + fingerprint, + }); + const copiedFile = createMockSelectedFile('audio1 copy.mp3', { + path: '/desktop/audio1 copy.mp3', + fingerprint, + }); + + act(() => { + result.current.addFiles([originalFile, copiedFile]); + }); + + expect(result.current.queue).toHaveLength(1); + expect(result.current.queue[0]!.file.path).toBe('/desktop/audio1.mp3'); + expect(result.current.duplicateFilesSkipped).toBe(1); + }); + + it('should reset duplicate count when no duplicates are skipped', () => { + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); + }); + + act(() => { + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); + }); + + expect(result.current.duplicateFilesSkipped).toBe(1); + + act(() => { + result.current.addFiles([createMockSelectedFile('audio2.mp3')]); + }); + + expect(result.current.duplicateFilesSkipped).toBe(0); + }); }); describe('removeFile', () => { diff --git a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.css b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.css index d9e3377..96aeece 100644 --- a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.css +++ b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.css @@ -78,3 +78,14 @@ font-weight: 500; color: var(--accent); } + +.dropzone-duplicate-badge { + margin-top: 8px; + padding: 4px 10px; + background: var(--warning-light); + border: 1px solid var(--warning); + border-radius: 12px; + font-size: 0.7rem; + font-weight: 500; + color: var(--warning-dark); +} diff --git a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx index 304760d..beca68e 100644 --- a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx +++ b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx @@ -8,12 +8,14 @@ import './FileDropZone.css'; export interface FileDropZoneProps { onFilesSelect: (files: SelectedFile[]) => void; queueCount?: number; + duplicateFilesSkipped?: number; disabled: boolean; } function FileDropZone({ onFilesSelect, queueCount = 0, + duplicateFilesSkipped = 0, disabled, }: FileDropZoneProps): React.JSX.Element { const handleClick = async (): Promise => { @@ -82,6 +84,12 @@ function FileDropZone({ {queueCount > 0 && ( {queueCount} files in queue )} + {duplicateFilesSkipped > 0 && ( + + Skipped {duplicateFilesSkipped} duplicate file + {duplicateFilesSkipped > 1 ? 's' : ''} + + )} ); diff --git a/src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx b/src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx index 881e7e0..7ed66f5 100644 --- a/src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx +++ b/src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx @@ -108,6 +108,14 @@ describe('FileDropZone', () => { expect(screen.getByText('3 files in queue')).toBeInTheDocument(); }); + it('should show duplicate files skipped badge', () => { + render( + + ); + + expect(screen.getByText('Skipped 2 duplicate files')).toBeInTheDocument(); + }); + it('should handle multiple files drop', async () => { overrideElectronAPI({ getPathForFile: vi.fn((file) => `/path/to/${file.name}`), diff --git a/src/renderer/features/transcription/hooks/useBatchQueue.ts b/src/renderer/features/transcription/hooks/useBatchQueue.ts index 3fabfa1..ed7c446 100644 --- a/src/renderer/features/transcription/hooks/useBatchQueue.ts +++ b/src/renderer/features/transcription/hooks/useBatchQueue.ts @@ -24,6 +24,7 @@ interface UseBatchQueueReturn { queue: QueueItem[]; isProcessing: boolean; currentItemId: string | null; + duplicateFilesSkipped: number; addFiles: (files: SelectedFile[]) => void; removeFile: (id: string) => void; @@ -40,16 +41,29 @@ function generateId(): string { return crypto.randomUUID(); } +function getFileIdentityKey(file: SelectedFile): string { + if (file.fingerprint) { + return `fingerprint:${file.fingerprint}`; + } + return `path:${file.path}`; +} + export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueReturn { const { settings, onHistoryAdd, onFirstComplete } = options; const [queue, setQueue] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [currentItemId, setCurrentItemId] = useState(null); + const [duplicateFilesSkipped, setDuplicateFilesSkipped] = useState(0); const isCancelledRef = useRef(false); const hasCalledFirstCompleteRef = useRef(false); const progressUnsubscribeRef = useRef<(() => void) | null>(null); + const queueRef = useRef([]); + + useEffect(() => { + queueRef.current = queue; + }, [queue]); useEffect(() => { return () => { @@ -61,19 +75,43 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur }, []); const addFiles = useCallback((files: SelectedFile[]) => { - const newItems: QueueItem[] = files.map((file) => ({ - id: generateId(), - file, - status: 'pending' as QueueItemStatus, - progress: { percent: 0, status: '' }, - })); - - setQueue((prev) => [...prev, ...newItems]); - - logger.info('Added files to batch queue', { - count: files.length, - files: files.map((f) => f.name), - }); + const existingKeys = new Set(queueRef.current.map((item) => getFileIdentityKey(item.file))); + const duplicateFiles: SelectedFile[] = []; + + const newItems: QueueItem[] = files.reduce((items, file) => { + const identityKey = getFileIdentityKey(file); + if (existingKeys.has(identityKey)) { + duplicateFiles.push(file); + return items; + } + + existingKeys.add(identityKey); + items.push({ + id: generateId(), + file, + status: 'pending' as QueueItemStatus, + progress: { percent: 0, status: '' }, + }); + return items; + }, []); + + if (newItems.length > 0) { + queueRef.current = [...queueRef.current, ...newItems]; + setQueue((prev) => [...prev, ...newItems]); + logger.info('Added files to batch queue', { + count: newItems.length, + files: newItems.map((item) => item.file.name), + }); + } + + setDuplicateFilesSkipped(duplicateFiles.length); + + if (duplicateFiles.length > 0) { + logger.warn('Skipped duplicate files in batch queue', { + count: duplicateFiles.length, + files: duplicateFiles.map((file) => file.name), + }); + } }, []); const removeFile = useCallback((id: string) => { @@ -307,6 +345,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur queue, isProcessing, currentItemId, + duplicateFilesSkipped, addFiles, removeFile, diff --git a/src/shared/types.ts b/src/shared/types.ts index d2b2e15..55ff40c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2,6 +2,7 @@ export interface SelectedFile { name: string; path: string; size?: number; + fingerprint?: string; } export type WhisperModelName = From 5011ecc985cfc8988a51f6dabf1c80214e4e9376 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 19 Feb 2026 21:14:23 -0300 Subject: [PATCH 04/18] feat: add retry functionality for failed and cancelled transcription items --- .../components/layout/LeftPanel/LeftPanel.tsx | 2 + src/renderer/contexts/AppContext.tsx | 7 ++ src/renderer/contexts/types.ts | 1 + .../__tests__/useBatchQueue.test.ts | 70 +++++++++++++ .../components/FileQueue/FileQueue.css | 6 ++ .../components/FileQueue/FileQueue.tsx | 45 ++++++--- .../FileQueue/__tests__/FileQueue.test.tsx | 30 ++++++ .../transcription/hooks/useBatchQueue.ts | 98 ++++++++++++------- 8 files changed, 209 insertions(+), 50 deletions(-) diff --git a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx index 471f54b..249a656 100644 --- a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx +++ b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx @@ -20,6 +20,7 @@ function LeftPanel(): React.JSX.Element { handleFilesSelect, removeFromQueue, clearCompletedFromQueue, + handleRetryFailed, selectQueueItem, } = useAppTranscription(); @@ -46,6 +47,7 @@ function LeftPanel(): React.JSX.Element { queue={queue} onRemove={removeFromQueue} onClearCompleted={clearCompletedFromQueue} + onRetryFailed={handleRetryFailed} onSelectItem={selectQueueItem} selectedItemId={selectedQueueItemId} disabled={isTranscribing} diff --git a/src/renderer/contexts/AppContext.tsx b/src/renderer/contexts/AppContext.tsx index b8b6f83..19248a1 100644 --- a/src/renderer/contexts/AppContext.tsx +++ b/src/renderer/contexts/AppContext.tsx @@ -60,6 +60,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { removeFile, clearCompleted, startProcessing, + retryFailed, cancelProcessing, getCompletedTranscription, } = useBatchQueue({ @@ -102,6 +103,10 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { await startProcessing(); }, [startProcessing]); + const handleRetryFailed = useCallback(async (): Promise => { + await retryFailed(); + }, [retryFailed]); + const handleCancel = useCallback(async (): Promise => { await cancelProcessing(); }, [cancelProcessing]); @@ -225,6 +230,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { setSettings, setModelDownloaded, handleTranscribe, + handleRetryFailed, handleCancel, handleSave, handleCopy: onCopy, @@ -238,6 +244,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { setSettings, setModelDownloaded, handleTranscribe, + handleRetryFailed, handleCancel, handleSave, onCopy, diff --git a/src/renderer/contexts/types.ts b/src/renderer/contexts/types.ts index 55fa002..bff7042 100644 --- a/src/renderer/contexts/types.ts +++ b/src/renderer/contexts/types.ts @@ -41,6 +41,7 @@ export interface TranscriptionActionsContextValue { setSettings: (settings: TranscriptionSettings) => void; setModelDownloaded: (downloaded: boolean) => void; handleTranscribe: () => Promise; + handleRetryFailed: () => Promise; handleCancel: () => Promise; handleSave: (format?: OutputFormat) => Promise; handleCopy: () => Promise; diff --git a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts index 5a6c8fb..86f1184 100644 --- a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts +++ b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts @@ -555,6 +555,76 @@ describe('useBatchQueue', () => { expect(result.current.queue[0]!.status).toBe('completed'); }); + + it('should retry only failed and cancelled items', async () => { + const startTranscriptionMock = vi + .fn() + .mockResolvedValueOnce({ success: false, error: 'First failed' }) + .mockResolvedValueOnce({ success: true, cancelled: true }) + .mockResolvedValueOnce({ success: true, text: 'third-complete' }) + .mockResolvedValueOnce({ success: true, text: 'retry-first' }) + .mockResolvedValueOnce({ success: true, text: 'retry-second' }); + + overrideElectronAPI({ + startTranscription: startTranscriptionMock, + onTranscriptionProgress: vi.fn().mockReturnValue(() => {}), + }); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([ + createMockSelectedFile('audio1.mp3'), + createMockSelectedFile('audio2.mp3'), + createMockSelectedFile('audio3.mp3'), + ]); + }); + + await act(async () => { + await result.current.startProcessing(); + }); + + expect(result.current.queue[0]!.status).toBe('error'); + expect(result.current.queue[1]!.status).toBe('cancelled'); + expect(result.current.queue[2]!.status).toBe('completed'); + + act(() => { + result.current.addFiles([createMockSelectedFile('audio4.mp3')]); + }); + + expect(result.current.queue[3]!.status).toBe('pending'); + + await act(async () => { + await result.current.retryFailed(); + }); + + expect(startTranscriptionMock).toHaveBeenCalledTimes(5); + expect(result.current.queue[0]!.status).toBe('completed'); + expect(result.current.queue[1]!.status).toBe('completed'); + expect(result.current.queue[2]!.status).toBe('completed'); + expect(result.current.queue[3]!.status).toBe('pending'); + }); + + it('should not retry pending items when using retryFailed', async () => { + const startTranscriptionMock = vi.fn().mockResolvedValue({ success: true, text: 'text' }); + overrideElectronAPI({ + startTranscription: startTranscriptionMock, + onTranscriptionProgress: vi.fn().mockReturnValue(() => {}), + }); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); + }); + + await act(async () => { + await result.current.retryFailed(); + }); + + expect(startTranscriptionMock).not.toHaveBeenCalled(); + expect(result.current.queue[0]!.status).toBe('pending'); + }); }); describe('progress and cancellation flow', () => { diff --git a/src/renderer/features/transcription/components/FileQueue/FileQueue.css b/src/renderer/features/transcription/components/FileQueue/FileQueue.css index dfc8b5f..29c3fb7 100644 --- a/src/renderer/features/transcription/components/FileQueue/FileQueue.css +++ b/src/renderer/features/transcription/components/FileQueue/FileQueue.css @@ -26,6 +26,12 @@ gap: 6px; } +.file-queue-header-actions { + display: flex; + align-items: center; + gap: 6px; +} + .file-queue-list { min-height: 44px; max-height: 200px; diff --git a/src/renderer/features/transcription/components/FileQueue/FileQueue.tsx b/src/renderer/features/transcription/components/FileQueue/FileQueue.tsx index ee78764..50ade8d 100644 --- a/src/renderer/features/transcription/components/FileQueue/FileQueue.tsx +++ b/src/renderer/features/transcription/components/FileQueue/FileQueue.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { CheckCircle, Loader, Clock, XCircle, Slash, X, Trash2 } from 'lucide-react'; +import { CheckCircle, Loader, Clock, XCircle, Slash, X, Trash2, RotateCcw } from 'lucide-react'; import { Button } from '../../../../components/ui'; import { formatFileSize } from '../../../../utils'; import type { QueueItem, QueueItemStatus } from '../../../../types'; @@ -9,6 +9,7 @@ export interface FileQueueProps { queue: QueueItem[]; onRemove: (id: string) => void; onClearCompleted: () => void; + onRetryFailed: () => void; onSelectItem?: (id: string) => void; selectedItemId?: string | null; disabled?: boolean; @@ -35,6 +36,7 @@ function FileQueue({ queue, onRemove, onClearCompleted, + onRetryFailed, onSelectItem, selectedItemId, disabled = false, @@ -47,6 +49,9 @@ function FileQueue({ const processingCount = queue.filter((item) => item.status === 'processing').length; const pendingCount = queue.filter((item) => item.status === 'pending').length; const errorCount = queue.filter((item) => item.status === 'error').length; + const cancelledCount = queue.filter((item) => item.status === 'cancelled').length; + const retryCount = errorCount + cancelledCount; + const hasRetryItems = retryCount > 0; const hasCompletedItems = completedCount > 0 || queue.some((item) => item.status === 'cancelled'); const handleItemClick = (id: string): void => { @@ -63,18 +68,32 @@ function FileQueue({
FILES ({queue.length}) - {hasCompletedItems && ( - - )} +
+ {hasRetryItems && ( + + )} + {hasCompletedItems && ( + + )} +
diff --git a/src/renderer/features/transcription/components/FileQueue/__tests__/FileQueue.test.tsx b/src/renderer/features/transcription/components/FileQueue/__tests__/FileQueue.test.tsx index a051715..fa94378 100644 --- a/src/renderer/features/transcription/components/FileQueue/__tests__/FileQueue.test.tsx +++ b/src/renderer/features/transcription/components/FileQueue/__tests__/FileQueue.test.tsx @@ -20,6 +20,7 @@ describe('FileQueue', () => { queue: [createMockQueueItem()], onRemove: vi.fn(), onClearCompleted: vi.fn(), + onRetryFailed: vi.fn(), onSelectItem: vi.fn(), selectedItemId: null, disabled: false, @@ -171,6 +172,35 @@ describe('FileQueue', () => { }); }); + describe('retry failed button', () => { + it('should show retry button when there are failed items', () => { + const queue = [createMockQueueItem({ status: 'error', error: 'Failed' })]; + render(); + expect(screen.getByText('Retry Failed')).toBeInTheDocument(); + }); + + it('should show retry button when there are cancelled items', () => { + const queue = [createMockQueueItem({ status: 'cancelled' })]; + render(); + expect(screen.getByText('Retry Failed')).toBeInTheDocument(); + }); + + it('should not show retry button when there are no failed or cancelled items', () => { + const queue = [createMockQueueItem({ status: 'pending' })]; + render(); + expect(screen.queryByText('Retry Failed')).not.toBeInTheDocument(); + }); + + it('should call onRetryFailed when clicking retry button', () => { + const onRetryFailed = vi.fn(); + const queue = [createMockQueueItem({ status: 'error', error: 'Failed' })]; + render(); + + fireEvent.click(screen.getByText('Retry Failed')); + expect(onRetryFailed).toHaveBeenCalled(); + }); + }); + describe('summary', () => { it('should show completed count', () => { const queue = [ diff --git a/src/renderer/features/transcription/hooks/useBatchQueue.ts b/src/renderer/features/transcription/hooks/useBatchQueue.ts index ed7c446..b3fd2f4 100644 --- a/src/renderer/features/transcription/hooks/useBatchQueue.ts +++ b/src/renderer/features/transcription/hooks/useBatchQueue.ts @@ -32,6 +32,7 @@ interface UseBatchQueueReturn { clearAll: () => void; startProcessing: () => Promise; + retryFailed: () => Promise; cancelProcessing: () => Promise; getCompletedTranscription: (id: string) => string | undefined; @@ -60,6 +61,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur const hasCalledFirstCompleteRef = useRef(false); const progressUnsubscribeRef = useRef<(() => void) | null>(null); const queueRef = useRef([]); + const activeRunItemIdsRef = useRef>(new Set()); useEffect(() => { queueRef.current = queue; @@ -259,51 +261,71 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur [settings, onHistoryAdd, onFirstComplete] ); - const startProcessing = useCallback(async () => { - if (isProcessing) return; + const runProcessing = useCallback( + async (targetStatuses: QueueItemStatus[], noItemsLogMessage: string) => { + if (isProcessing) return; - const itemsToProcess = queue.filter( - (item) => item.status === 'pending' || item.status === 'cancelled' || item.status === 'error' - ); + const itemsToProcess = queue.filter((item) => targetStatuses.includes(item.status)); - if (itemsToProcess.length === 0) { - logger.warn('No items to process'); - return; - } + if (itemsToProcess.length === 0) { + logger.warn(noItemsLogMessage); + return; + } - setQueue((prev) => - prev.map((item) => - item.status === 'cancelled' || item.status === 'error' - ? { ...item, status: 'pending' as QueueItemStatus, error: undefined, endTime: undefined } - : item - ) - ); + const activeIds = new Set(itemsToProcess.map((item) => item.id)); + activeRunItemIdsRef.current = activeIds; + + setQueue((prev) => + prev.map((item) => + activeIds.has(item.id) && (item.status === 'cancelled' || item.status === 'error') + ? { + ...item, + status: 'pending' as QueueItemStatus, + error: undefined, + endTime: undefined, + } + : item + ) + ); + + setIsProcessing(true); + isCancelledRef.current = false; + hasCalledFirstCompleteRef.current = false; - setIsProcessing(true); - isCancelledRef.current = false; - hasCalledFirstCompleteRef.current = false; + logger.info('Starting batch processing', { count: itemsToProcess.length }); - logger.info('Starting batch processing', { count: itemsToProcess.length }); + for (const item of itemsToProcess) { + if (isCancelledRef.current) { + setQueue((prev) => + prev.map((q) => + q.status === 'pending' && activeIds.has(q.id) + ? { ...q, status: 'cancelled' as QueueItemStatus } + : q + ) + ); + break; + } - for (const item of itemsToProcess) { - if (isCancelledRef.current) { - setQueue((prev) => - prev.map((q) => - q.status === 'pending' ? { ...q, status: 'cancelled' as QueueItemStatus } : q - ) - ); - break; + const resetItem = { ...item, status: 'pending' as QueueItemStatus, error: undefined }; + const processedItem = await processItem(resetItem); + setQueue((prev) => prev.map((q) => (q.id === processedItem.id ? processedItem : q))); } - const resetItem = { ...item, status: 'pending' as QueueItemStatus, error: undefined }; - const processedItem = await processItem(resetItem); - setQueue((prev) => prev.map((q) => (q.id === processedItem.id ? processedItem : q))); - } + setIsProcessing(false); + setCurrentItemId(null); + activeRunItemIdsRef.current = new Set(); + logger.info('Batch processing complete'); + }, + [isProcessing, queue, processItem] + ); - setIsProcessing(false); - setCurrentItemId(null); - logger.info('Batch processing complete'); - }, [isProcessing, queue, processItem]); + const startProcessing = useCallback(async () => { + await runProcessing(['pending', 'cancelled', 'error'], 'No items to process'); + }, [runProcessing]); + + const retryFailed = useCallback(async () => { + await runProcessing(['cancelled', 'error'], 'No failed items to retry'); + }, [runProcessing]); const cancelProcessing = useCallback(async () => { if (!isProcessing) return; @@ -316,7 +338,8 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur setQueue((prev) => prev.map((q) => - q.status === 'processing' || q.status === 'pending' + q.status === 'processing' || + (q.status === 'pending' && activeRunItemIdsRef.current.has(q.id)) ? { ...q, status: 'cancelled' as QueueItemStatus, endTime: Date.now() } : q ) @@ -353,6 +376,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur clearAll, startProcessing, + retryFailed, cancelProcessing, getCompletedTranscription, From ca1973bbacf5f010066deaee7db62b7597ff8a3c Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 19 Feb 2026 22:59:13 -0300 Subject: [PATCH 05/18] feat: implement showItemInFolder functionality and related tests --- src/main/ipc/index.ts | 22 +++++ src/preload/index.ts | 1 + .../__tests__/useBatchQueue.test.ts | 29 ++++++ .../__tests__/useTranscription.test.ts | 94 +++++++++++++++++++ .../__tests__/FileDropZone.test.tsx | 43 ++++++++- .../transcription/hooks/useTranscription.ts | 34 ++++++- .../services/__tests__/electronAPI.test.ts | 17 ++++ src/renderer/services/electronAPI.ts | 14 +++ src/renderer/test/electronAPIMocks.ts | 2 + src/renderer/test/setup.ts | 1 + src/renderer/types/electron.d.ts | 1 + 11 files changed, 256 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index ad0b9a5..f727e80 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -236,4 +236,26 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { } await shell.openExternal(url); }); + + ipcMain.handle('shell:showItemInFolder', async (_event, filePath: string) => { + try { + if (typeof filePath !== 'string' || filePath.trim().length === 0) { + return { success: false, error: 'Invalid file path' }; + } + + const resolvedPath = path.resolve(filePath); + if (!path.isAbsolute(resolvedPath)) { + return { success: false, error: 'Invalid file path' }; + } + + if (!fs.existsSync(resolvedPath)) { + return { success: false, error: 'File not found' }; + } + + shell.showItemInFolder(resolvedPath); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 155c7dc..abbe5e0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('analytics:track', eventName, properties), openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), + showItemInFolder: (filePath: string) => ipcRenderer.invoke('shell:showItemInFolder', filePath), onMenuOpenFile: (callback: () => void) => { ipcRenderer.on('menu:openFile', () => callback()); diff --git a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts index 86f1184..dc794b7 100644 --- a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts +++ b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts @@ -214,6 +214,35 @@ describe('useBatchQueue', () => { expect(result.current.queue).toHaveLength(0); }); + + it('should not clear items while processing', async () => { + const warnSpy = vi.spyOn(logger, 'warn'); + const pendingPromise = new Promise(() => {}); + + overrideElectronAPI({ + startTranscription: vi.fn().mockReturnValue(pendingPromise), + onTranscriptionProgress: vi.fn().mockReturnValue(() => {}), + }); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); + }); + + act(() => { + result.current.startProcessing(); + }); + + expect(result.current.isProcessing).toBe(true); + + act(() => { + result.current.clearAll(); + }); + + expect(result.current.queue).toHaveLength(1); + expect(warnSpy).toHaveBeenCalledWith('Cannot clear queue while processing'); + }); }); describe('startProcessing', () => { diff --git a/src/renderer/features/transcription/__tests__/useTranscription.test.ts b/src/renderer/features/transcription/__tests__/useTranscription.test.ts index 82fbbaf..7d4c21c 100644 --- a/src/renderer/features/transcription/__tests__/useTranscription.test.ts +++ b/src/renderer/features/transcription/__tests__/useTranscription.test.ts @@ -3,6 +3,7 @@ import { renderHook, act } from '@testing-library/react'; import { useTranscription } from '@/features/transcription'; import { overrideElectronAPI } from '@/test/utils'; import { createMockFile } from '@/test/fixtures'; +import { logger } from '@/services/logger'; describe('useTranscription', () => { beforeEach(() => { @@ -71,8 +72,11 @@ describe('useTranscription', () => { const mockSaveFile = vi .fn() .mockResolvedValue({ success: true, filePath: '/path/to/saved.vtt' }); + const mockShowItemInFolder = vi.fn().mockResolvedValue({ success: true }); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); overrideElectronAPI({ saveFile: mockSaveFile, + showItemInFolder: mockShowItemInFolder, }); const { result } = renderHook(() => useTranscription()); @@ -91,7 +95,97 @@ describe('useTranscription', () => { content: 'Test transcription', format: 'vtt', }); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); expect(result.current.error).toBeNull(); + confirmSpy.mockRestore(); + }); + + it('should reveal saved file in Finder when confirmed', async () => { + const mockSaveFile = vi + .fn() + .mockResolvedValue({ success: true, filePath: '/path/to/saved.vtt' }); + const mockShowItemInFolder = vi.fn().mockResolvedValue({ success: true }); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + overrideElectronAPI({ + saveFile: mockSaveFile, + showItemInFolder: mockShowItemInFolder, + }); + + const { result } = renderHook(() => useTranscription()); + + act(() => { + result.current.setSelectedFile(mockFile); + result.current.setTranscription('Test transcription'); + }); + + await act(async () => { + await result.current.handleSave('vtt'); + }); + + expect(mockShowItemInFolder).toHaveBeenCalledWith('/path/to/saved.vtt'); + confirmSpy.mockRestore(); + }); + + it('should log warning when revealing saved file fails', async () => { + const mockSaveFile = vi + .fn() + .mockResolvedValue({ success: true, filePath: '/path/to/saved.vtt' }); + const mockShowItemInFolder = vi + .fn() + .mockResolvedValue({ success: false, error: 'No handler registered' }); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + overrideElectronAPI({ + saveFile: mockSaveFile, + showItemInFolder: mockShowItemInFolder, + }); + + const { result } = renderHook(() => useTranscription()); + + act(() => { + result.current.setSelectedFile(mockFile); + result.current.setTranscription('Test transcription'); + }); + + await act(async () => { + await result.current.handleSave('vtt'); + }); + + expect(logger.warn).toHaveBeenCalledWith('Failed to reveal saved file in Finder', { + path: 'saved.vtt', + error: 'No handler registered', + }); + confirmSpy.mockRestore(); + }); + + it('should skip reveal when confirm throws', async () => { + const mockSaveFile = vi + .fn() + .mockResolvedValue({ success: true, filePath: '/path/to/saved.vtt' }); + const mockShowItemInFolder = vi.fn().mockResolvedValue({ success: true }); + const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => { + throw new Error('confirm unavailable'); + }); + + overrideElectronAPI({ + saveFile: mockSaveFile, + showItemInFolder: mockShowItemInFolder, + }); + + const { result } = renderHook(() => useTranscription()); + + act(() => { + result.current.setSelectedFile(mockFile); + result.current.setTranscription('Test transcription'); + }); + + await act(async () => { + await result.current.handleSave('vtt'); + }); + + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + confirmSpy.mockRestore(); }); it('should handle save file error', async () => { diff --git a/src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx b/src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx index 7ed66f5..662f284 100644 --- a/src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx +++ b/src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, createEvent } from '@testing-library/react'; import { FileDropZone } from '@/features/transcription'; import { overrideElectronAPI } from '@/test/utils'; import { createMockFile } from '@/test/fixtures'; @@ -102,6 +102,47 @@ describe('FileDropZone', () => { expect(window.electronAPI?.openMultipleFiles).not.toHaveBeenCalled(); }); + it('should prevent default on drag over', () => { + render(); + + const dropzone = screen.getByRole('button'); + const event = createEvent.dragOver(dropzone); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + fireEvent(dropzone, event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('should open file dialog on Enter key', async () => { + overrideElectronAPI({ + openMultipleFiles: vi.fn().mockResolvedValue(['/path/to/test1.mp3']), + getFileInfo: vi.fn().mockResolvedValue({ + name: 'test1.mp3', + path: '/path/to/test1.mp3', + size: 1024, + }), + }); + + render(); + const dropzone = screen.getByRole('button'); + + fireEvent.keyDown(dropzone, { key: 'Enter' }); + + await waitFor(() => { + expect(window.electronAPI?.openMultipleFiles).toHaveBeenCalled(); + expect(onFilesSelect).toHaveBeenCalledTimes(1); + }); + }); + + it('should ignore non-activation keys', () => { + render(); + const dropzone = screen.getByRole('button'); + + fireEvent.keyDown(dropzone, { key: 'ArrowDown' }); + + expect(window.electronAPI?.openMultipleFiles).not.toHaveBeenCalled(); + }); + it('should show queue count badge', () => { render(); diff --git a/src/renderer/features/transcription/hooks/useTranscription.ts b/src/renderer/features/transcription/hooks/useTranscription.ts index 6cd88eb..3aa206b 100644 --- a/src/renderer/features/transcription/hooks/useTranscription.ts +++ b/src/renderer/features/transcription/hooks/useTranscription.ts @@ -1,9 +1,31 @@ import { useState, useCallback } from 'react'; import type { SelectedFile, TranscriptionSettings, OutputFormat } from '../../../types'; -import { saveFile } from '../../../services/electronAPI'; +import { saveFile, showItemInFolder } from '../../../services/electronAPI'; import { logger } from '../../../services/logger'; import { sanitizePath } from '../../../../shared/utils'; +function shouldRevealInFinderAfterSave(): boolean { + if (typeof window === 'undefined' || typeof window.confirm !== 'function') { + return false; + } + + const confirmFn = window.confirm as unknown as { + (message?: string): boolean; + mock?: unknown; + }; + const isJSDOM = typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent || ''); + + if (isJSDOM && !confirmFn.mock) { + return false; + } + + try { + return confirmFn('File saved successfully. Show in Finder?'); + } catch { + return false; + } +} + export interface UseTranscriptionReturn { selectedFile: SelectedFile | null; settings: TranscriptionSettings; @@ -76,6 +98,16 @@ export function useTranscription(): UseTranscriptionReturn { if (result?.success && result.filePath) { logger.info('File saved', { path: sanitizePath(result.filePath), format }); + + if (shouldRevealInFinderAfterSave()) { + const revealResult = await showItemInFolder(result.filePath); + if (!revealResult.success) { + logger.warn('Failed to reveal saved file in Finder', { + path: sanitizePath(result.filePath), + error: revealResult.error || 'Unknown error', + }); + } + } } else if (result?.error) { setError(`Failed to save: ${result.error}`); logger.error('Failed to save file', { error: result.error, format }); diff --git a/src/renderer/services/__tests__/electronAPI.test.ts b/src/renderer/services/__tests__/electronAPI.test.ts index c634372..ef4c3cc 100644 --- a/src/renderer/services/__tests__/electronAPI.test.ts +++ b/src/renderer/services/__tests__/electronAPI.test.ts @@ -22,6 +22,7 @@ import { installUpdate, onUpdateStatus, openExternal, + showItemInFolder, onMenuOpenFile, onMenuSaveFile, onMenuCopyTranscription, @@ -112,6 +113,8 @@ describe('electronAPI wrapper', () => { unsubscribeUpdate(); await expect(openExternal('https://example.com')).resolves.toBeUndefined(); + const reveal = await showItemInFolder('/tmp/test.txt'); + expect(reveal.success).toBe(false); const unsubscribeMenuOpen = onMenuOpenFile(() => {}); expect(typeof unsubscribeMenuOpen).toBe('function'); @@ -211,6 +214,9 @@ describe('electronAPI wrapper', () => { await openExternal('https://example.com'); expect(api.openExternal).toHaveBeenCalledWith('https://example.com'); + await showItemInFolder('/tmp/test.txt'); + expect(api.showItemInFolder).toHaveBeenCalledWith('/tmp/test.txt'); + onMenuOpenFile(() => {}); expect(api.onMenuOpenFile).toHaveBeenCalled(); @@ -229,4 +235,15 @@ describe('electronAPI wrapper', () => { onMenuToggleHistory(() => {}); expect(api.onMenuToggleHistory).toHaveBeenCalled(); }); + + it('showItemInFolder returns failure when underlying API throws', async () => { + const api = createFullElectronAPIMock(); + api.showItemInFolder = vi.fn().mockRejectedValue(new Error('No handler registered')); + window.electronAPI = api; + + const result = await showItemInFolder('/tmp/test.txt'); + + expect(result.success).toBe(false); + expect(result.error).toContain('No handler registered'); + }); }); diff --git a/src/renderer/services/electronAPI.ts b/src/renderer/services/electronAPI.ts index ec8d8fb..e9395f3 100644 --- a/src/renderer/services/electronAPI.ts +++ b/src/renderer/services/electronAPI.ts @@ -132,6 +132,20 @@ export async function openExternal(url: string): Promise { await window.electronAPI?.openExternal(url); } +export async function showItemInFolder( + filePath: string +): Promise<{ success: boolean; error?: string }> { + try { + const result = await window.electronAPI?.showItemInFolder(filePath); + return result ?? { success: false, error: 'Electron API not available' }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + export function onMenuOpenFile(callback: () => void): Unsubscribe { return window.electronAPI?.onMenuOpenFile(callback) ?? (() => {}); } diff --git a/src/renderer/test/electronAPIMocks.ts b/src/renderer/test/electronAPIMocks.ts index a9593a3..689e50e 100644 --- a/src/renderer/test/electronAPIMocks.ts +++ b/src/renderer/test/electronAPIMocks.ts @@ -34,6 +34,7 @@ export const createDefaultElectronAPIMock = (): ElectronAPI => ({ }), trackEvent: vi.fn().mockResolvedValue(undefined), openExternal: vi.fn().mockResolvedValue(undefined), + showItemInFolder: vi.fn().mockResolvedValue({ success: true }), onMenuOpenFile: vi.fn().mockReturnValue(() => {}), onMenuSaveFile: vi.fn().mockReturnValue(() => {}), onMenuCopyTranscription: vi.fn().mockReturnValue(() => {}), @@ -67,6 +68,7 @@ export const createFullElectronAPIMock = (): ElectronAPI => ({ .mockResolvedValue({ heapUsed: 1, heapTotal: 2, rss: 3, external: 4, isTranscribing: false }), trackEvent: vi.fn().mockResolvedValue(undefined), openExternal: vi.fn().mockResolvedValue(undefined), + showItemInFolder: vi.fn().mockResolvedValue({ success: true }), onMenuOpenFile: vi.fn().mockReturnValue(() => {}), onMenuSaveFile: vi.fn().mockReturnValue(() => {}), onMenuCopyTranscription: vi.fn().mockReturnValue(() => {}), diff --git a/src/renderer/test/setup.ts b/src/renderer/test/setup.ts index f21f02a..da91729 100644 --- a/src/renderer/test/setup.ts +++ b/src/renderer/test/setup.ts @@ -36,6 +36,7 @@ const mockElectronAPI: ElectronAPI = { }), trackEvent: vi.fn().mockResolvedValue(undefined), openExternal: vi.fn().mockResolvedValue(undefined), + showItemInFolder: vi.fn().mockResolvedValue({ success: true }), onMenuOpenFile: vi.fn().mockReturnValue(() => {}), onMenuSaveFile: vi.fn().mockReturnValue(() => {}), onMenuCopyTranscription: vi.fn().mockReturnValue(() => {}), diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 6e02ef7..07d46d6 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -53,6 +53,7 @@ export interface ElectronAPI { properties?: Record ) => Promise; openExternal: (url: string) => Promise; + showItemInFolder: (filePath: string) => Promise<{ success: boolean; error?: string }>; onMenuOpenFile: (callback: () => void) => Unsubscribe; onMenuSaveFile: (callback: () => void) => Unsubscribe; onMenuCopyTranscription: (callback: () => void) => Unsubscribe; From 51d51ad1252e516f52f734e900a57280f2b0ac0e Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 19 Feb 2026 23:18:30 -0300 Subject: [PATCH 06/18] feat: add estimated time remaining feature to file queue and batch processing --- src/main/index.ts | 17 ++ .../components/layout/LeftPanel/LeftPanel.tsx | 2 + src/renderer/contexts/AppContext.tsx | 3 + src/renderer/contexts/types.ts | 1 + .../__tests__/useBatchQueue.test.ts | 287 ++++++++++++++++-- .../components/FileQueue/FileQueue.css | 4 + .../components/FileQueue/FileQueue.tsx | 27 ++ .../FileQueue/__tests__/FileQueue.test.tsx | 42 +++ .../transcription/hooks/useBatchQueue.ts | 99 +++++- 9 files changed, 464 insertions(+), 18 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 8a151a1..c20382e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,6 +8,9 @@ import packageJson from '../../package.json'; initAnalytics(); +const APP_DISPLAY_NAME = 'WhisperDesk'; +const APP_USER_MODEL_ID = 'com.whisperdesk.app'; + let mainWindow: BrowserWindow | null = null; let ipcHandlersRegistered = false; @@ -16,6 +19,19 @@ const appVersion = packageJson.version; const UPDATE_CHECK_DELAY_MS = 3000; +app.setName(APP_DISPLAY_NAME); + +if (process.platform === 'win32') { + app.setAppUserModelId(APP_USER_MODEL_ID); +} + +if (process.platform === 'darwin') { + app.setAboutPanelOptions({ + applicationName: APP_DISPLAY_NAME, + applicationVersion: appVersion, + }); +} + function createMenu() { if (!mainWindow) return; @@ -201,6 +217,7 @@ const createWindow = () => { sandbox: true, }, titleBarStyle: 'hiddenInset', + title: APP_DISPLAY_NAME, trafficLightPosition: { x: 20, y: 20 }, }); diff --git a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx index 249a656..6679910 100644 --- a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx +++ b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx @@ -16,6 +16,7 @@ function LeftPanel(): React.JSX.Element { setModelDownloaded, queue, duplicateFilesSkipped, + estimatedTimeRemainingSec, selectedQueueItemId, handleFilesSelect, removeFromQueue, @@ -50,6 +51,7 @@ function LeftPanel(): React.JSX.Element { onRetryFailed={handleRetryFailed} onSelectItem={selectQueueItem} selectedItemId={selectedQueueItemId} + estimatedTimeRemainingSec={estimatedTimeRemainingSec} disabled={isTranscribing} /> )} diff --git a/src/renderer/contexts/AppContext.tsx b/src/renderer/contexts/AppContext.tsx index 19248a1..1af12cc 100644 --- a/src/renderer/contexts/AppContext.tsx +++ b/src/renderer/contexts/AppContext.tsx @@ -56,6 +56,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { queue, isProcessing, duplicateFilesSkipped, + estimatedTimeRemainingSec, addFiles, removeFile, clearCompleted, @@ -206,6 +207,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { error, modelDownloaded, duplicateFilesSkipped, + estimatedTimeRemainingSec, copySuccess, queue, selectedQueueItemId, @@ -218,6 +220,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { error, modelDownloaded, duplicateFilesSkipped, + estimatedTimeRemainingSec, copySuccess, queue, selectedQueueItemId, diff --git a/src/renderer/contexts/types.ts b/src/renderer/contexts/types.ts index bff7042..ba8557e 100644 --- a/src/renderer/contexts/types.ts +++ b/src/renderer/contexts/types.ts @@ -31,6 +31,7 @@ export interface TranscriptionStateContextValue { error: string | null; modelDownloaded: boolean; duplicateFilesSkipped: number; + estimatedTimeRemainingSec: number | null; copySuccess: boolean; queue: QueueItem[]; selectedQueueItemId: string | null; diff --git a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts index dc794b7..3154523 100644 --- a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts +++ b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; import { useBatchQueue } from '../hooks/useBatchQueue'; import { overrideElectronAPI } from '@/test/utils'; import type { @@ -11,6 +11,8 @@ import type { import { logger } from '@/services/logger'; describe('useBatchQueue', () => { + const originalNotification = globalThis.Notification; + const mockSettings: TranscriptionSettings = { model: 'base', language: 'en', @@ -43,6 +45,40 @@ describe('useBatchQueue', () => { }); }); + afterEach(() => { + if (originalNotification) { + Object.defineProperty(globalThis, 'Notification', { + configurable: true, + writable: true, + value: originalNotification, + }); + } else { + Reflect.deleteProperty(globalThis, 'Notification'); + } + }); + + const mockNotificationApi = (permission: NotificationPermission = 'granted') => { + const notificationSpy = vi.fn(); + const requestPermissionSpy = vi.fn().mockResolvedValue(permission); + + class MockNotification { + static permission: NotificationPermission = permission; + static requestPermission = requestPermissionSpy; + + constructor(title: string, options?: NotificationOptions) { + notificationSpy({ title, options }); + } + } + + Object.defineProperty(globalThis, 'Notification', { + configurable: true, + writable: true, + value: MockNotification, + }); + + return { notificationSpy, requestPermissionSpy }; + }; + describe('initialization', () => { it('should initialize with empty queue', () => { const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); @@ -50,6 +86,7 @@ describe('useBatchQueue', () => { expect(result.current.queue).toEqual([]); expect(result.current.isProcessing).toBe(false); expect(result.current.currentItemId).toBe(null); + expect(result.current.estimatedTimeRemainingSec).toBeNull(); }); }); @@ -322,18 +359,12 @@ describe('useBatchQueue', () => { }); it('should not start if already processing', async () => { + let resolveTranscription: ((value: TranscriptionResult) => void) | undefined; const startTranscriptionMock = vi.fn().mockImplementation( () => - new Promise((resolve) => - setTimeout( - () => - resolve({ - success: true, - text: 'text', - }), - 100 - ) - ) + new Promise((resolve) => { + resolveTranscription = resolve; + }) ); overrideElectronAPI({ @@ -344,14 +375,12 @@ describe('useBatchQueue', () => { const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); act(() => { - result.current.addFiles([ - createMockSelectedFile('audio1.mp3'), - createMockSelectedFile('audio2.mp3'), - ]); + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); }); + let processingPromise: Promise; act(() => { - result.current.startProcessing(); + processingPromise = result.current.startProcessing(); }); expect(result.current.isProcessing).toBe(true); @@ -361,6 +390,11 @@ describe('useBatchQueue', () => { }); expect(startTranscriptionMock).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveTranscription?.({ success: true, text: 'text' }); + await processingPromise; + }); }); it('should handle transcription errors', async () => { @@ -460,6 +494,57 @@ describe('useBatchQueue', () => { expect(result.current.isProcessing).toBe(false); }); + + it('should expose estimated time remaining while processing subsequent items', async () => { + let now = 1000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + + let resolveSecond: ((value: TranscriptionResult) => void) | undefined; + const startTranscriptionMock = vi + .fn() + .mockImplementationOnce(async () => { + now = 6000; + return { success: true, text: 'first' }; + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }) + ); + + overrideElectronAPI({ + startTranscription: startTranscriptionMock, + onTranscriptionProgress: vi.fn().mockReturnValue(() => {}), + }); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([ + createMockSelectedFile('audio1.mp3'), + createMockSelectedFile('audio2.mp3'), + ]); + }); + + let processingPromise: Promise; + act(() => { + processingPromise = result.current.startProcessing(); + }); + + await waitFor(() => { + expect(result.current.estimatedTimeRemainingSec).toBe(5); + }); + + await act(async () => { + now = 11000; + resolveSecond?.({ success: true, text: 'second' }); + await processingPromise; + }); + + expect(result.current.estimatedTimeRemainingSec).toBeNull(); + nowSpy.mockRestore(); + }); }); describe('cancelProcessing', () => { @@ -744,4 +829,172 @@ describe('useBatchQueue', () => { expect(result.current.queue[1]!.status).toBe('cancelled'); }); }); + + describe('completion notifications', () => { + it('should notify once when processing completes and permission is granted', async () => { + const { notificationSpy } = mockNotificationApi('granted'); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([ + createMockSelectedFile('audio1.mp3'), + createMockSelectedFile('audio2.mp3'), + ]); + }); + + await act(async () => { + await result.current.startProcessing(); + }); + + expect(notificationSpy).toHaveBeenCalledTimes(1); + expect(notificationSpy).toHaveBeenCalledWith({ + title: 'Batch transcription complete', + options: { body: '2 completed' }, + }); + }); + + it('should request permission and notify when permission starts as default', async () => { + const { notificationSpy, requestPermissionSpy } = mockNotificationApi('default'); + requestPermissionSpy.mockResolvedValueOnce('granted'); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); + }); + + await act(async () => { + await result.current.startProcessing(); + }); + + await waitFor(() => { + expect(requestPermissionSpy).toHaveBeenCalledTimes(1); + expect(notificationSpy).toHaveBeenCalledWith({ + title: 'Transcription complete', + options: { body: '1 completed' }, + }); + }); + }); + + it('should not notify if permission is denied after requesting', async () => { + const { notificationSpy, requestPermissionSpy } = mockNotificationApi('default'); + requestPermissionSpy.mockResolvedValueOnce('denied'); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); + }); + + await act(async () => { + await result.current.startProcessing(); + }); + + await waitFor(() => { + expect(requestPermissionSpy).toHaveBeenCalledTimes(1); + }); + expect(notificationSpy).not.toHaveBeenCalled(); + }); + + it('should not notify when processing was cancelled', async () => { + const { notificationSpy } = mockNotificationApi('granted'); + let resolveTranscription: ((value: TranscriptionResult) => void) | undefined; + + overrideElectronAPI({ + startTranscription: vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveTranscription = resolve; + }) + ), + cancelTranscription: vi.fn().mockResolvedValue({ success: true }), + onTranscriptionProgress: vi.fn().mockReturnValue(() => {}), + }); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + act(() => { + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); + }); + + let processingPromise: Promise; + act(() => { + processingPromise = result.current.startProcessing(); + }); + + await act(async () => { + await result.current.cancelProcessing(); + }); + + await act(async () => { + resolveTranscription?.({ success: true, text: 'Done' }); + await processingPromise; + }); + + expect(notificationSpy).not.toHaveBeenCalled(); + }); + + it('should log warning when notification creation throws', async () => { + const requestPermissionSpy = vi.fn().mockResolvedValue('granted'); + + class MockNotification { + static permission: NotificationPermission = 'granted'; + static requestPermission = requestPermissionSpy; + + constructor() { + throw new Error('constructor failed'); + } + } + + Object.defineProperty(globalThis, 'Notification', { + configurable: true, + writable: true, + value: MockNotification, + }); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + act(() => { + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); + }); + + await act(async () => { + await result.current.startProcessing(); + }); + + expect(logger.warn).toHaveBeenCalledWith('Failed to create completion notification', { + error: 'constructor failed', + }); + }); + + it('should log warning when notification permission lookup throws', async () => { + class MockNotification { + static get permission(): NotificationPermission { + throw new Error('permission failed'); + } + + static requestPermission = vi.fn().mockResolvedValue('granted'); + + constructor() {} + } + + Object.defineProperty(globalThis, 'Notification', { + configurable: true, + writable: true, + value: MockNotification, + }); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + act(() => { + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); + }); + + await act(async () => { + await result.current.startProcessing(); + }); + + expect(logger.warn).toHaveBeenCalledWith('Failed to show completion notification', { + error: 'permission failed', + }); + }); + }); }); diff --git a/src/renderer/features/transcription/components/FileQueue/FileQueue.css b/src/renderer/features/transcription/components/FileQueue/FileQueue.css index 29c3fb7..c5421db 100644 --- a/src/renderer/features/transcription/components/FileQueue/FileQueue.css +++ b/src/renderer/features/transcription/components/FileQueue/FileQueue.css @@ -198,6 +198,10 @@ color: var(--danger); } +.file-queue-summary span.eta { + color: var(--accent); +} + .file-queue-summary span.hint { color: var(--accent); font-style: italic; diff --git a/src/renderer/features/transcription/components/FileQueue/FileQueue.tsx b/src/renderer/features/transcription/components/FileQueue/FileQueue.tsx index 50ade8d..c1a6c0d 100644 --- a/src/renderer/features/transcription/components/FileQueue/FileQueue.tsx +++ b/src/renderer/features/transcription/components/FileQueue/FileQueue.tsx @@ -12,6 +12,7 @@ export interface FileQueueProps { onRetryFailed: () => void; onSelectItem?: (id: string) => void; selectedItemId?: string | null; + estimatedTimeRemainingSec?: number | null; disabled?: boolean; } @@ -32,6 +33,23 @@ function getStatusIcon(status: QueueItemStatus): React.ReactNode { } } +function formatEstimatedTime(seconds: number): string { + const totalSeconds = Math.max(0, Math.round(seconds)); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + + if (totalSeconds < 3600) { + const minutes = Math.floor(totalSeconds / 60); + const remainingSeconds = totalSeconds % 60; + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; + } + + const hours = Math.floor(totalSeconds / 3600); + const remainingMinutes = Math.floor((totalSeconds % 3600) / 60); + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + function FileQueue({ queue, onRemove, @@ -39,6 +57,7 @@ function FileQueue({ onRetryFailed, onSelectItem, selectedItemId, + estimatedTimeRemainingSec = null, disabled = false, }: FileQueueProps): React.JSX.Element | null { if (queue.length === 0) { @@ -152,6 +171,14 @@ function FileQueue({
{completedCount > 0 && {completedCount} completed} {processingCount > 0 && {processingCount} processing} + {processingCount > 0 && ( + + ETA{' '} + {typeof estimatedTimeRemainingSec === 'number' + ? formatEstimatedTime(estimatedTimeRemainingSec) + : 'calculating...'} + + )} {pendingCount > 0 && {pendingCount} pending} {errorCount > 0 && {errorCount} failed} {completedCount > 0 && !processingCount && ( diff --git a/src/renderer/features/transcription/components/FileQueue/__tests__/FileQueue.test.tsx b/src/renderer/features/transcription/components/FileQueue/__tests__/FileQueue.test.tsx index fa94378..d819abb 100644 --- a/src/renderer/features/transcription/components/FileQueue/__tests__/FileQueue.test.tsx +++ b/src/renderer/features/transcription/components/FileQueue/__tests__/FileQueue.test.tsx @@ -227,6 +227,48 @@ describe('FileQueue', () => { expect(screen.getByText('1 processing')).toBeInTheDocument(); }); + it('should show eta in seconds for short remaining time', () => { + const queue = [createMockQueueItem({ status: 'processing' })]; + render(); + expect(screen.getByText('ETA 45s')).toBeInTheDocument(); + }); + + it('should show eta in minutes and seconds', () => { + const queue = [createMockQueueItem({ status: 'processing' })]; + render(); + expect(screen.getByText('ETA 1m 30s')).toBeInTheDocument(); + }); + + it('should show eta in whole minutes when no remaining seconds', () => { + const queue = [createMockQueueItem({ status: 'processing' })]; + render(); + expect(screen.getByText('ETA 2m')).toBeInTheDocument(); + }); + + it('should show eta in hours when needed', () => { + const queue = [createMockQueueItem({ status: 'processing' })]; + render(); + expect(screen.getByText('ETA 2h')).toBeInTheDocument(); + }); + + it('should show eta in hours and minutes when needed', () => { + const queue = [createMockQueueItem({ status: 'processing' })]; + render(); + expect(screen.getByText('ETA 2h 1m')).toBeInTheDocument(); + }); + + it('should show eta as calculating while processing if estimate is not ready', () => { + const queue = [createMockQueueItem({ status: 'processing' })]; + render(); + expect(screen.getByText('ETA calculating...')).toBeInTheDocument(); + }); + + it('should not show eta when no items are processing', () => { + const queue = [createMockQueueItem({ status: 'pending' })]; + render(); + expect(screen.queryByText(/ETA/)).not.toBeInTheDocument(); + }); + it('should show failed count', () => { const queue = [createMockQueueItem({ status: 'error', error: 'Failed' })]; render(); diff --git a/src/renderer/features/transcription/hooks/useBatchQueue.ts b/src/renderer/features/transcription/hooks/useBatchQueue.ts index b3fd2f4..1b15d4e 100644 --- a/src/renderer/features/transcription/hooks/useBatchQueue.ts +++ b/src/renderer/features/transcription/hooks/useBatchQueue.ts @@ -25,6 +25,7 @@ interface UseBatchQueueReturn { isProcessing: boolean; currentItemId: string | null; duplicateFilesSkipped: number; + estimatedTimeRemainingSec: number | null; addFiles: (files: SelectedFile[]) => void; removeFile: (id: string) => void; @@ -49,6 +50,56 @@ function getFileIdentityKey(file: SelectedFile): string { return `path:${file.path}`; } +function showBatchCompletionNotification(items: QueueItem[]): void { + if (typeof Notification === 'undefined' || items.length === 0) { + return; + } + + const completedCount = items.filter((item) => item.status === 'completed').length; + const failedCount = items.filter((item) => item.status === 'error').length; + const cancelledCount = items.filter((item) => item.status === 'cancelled').length; + + const title = items.length > 1 ? 'Batch transcription complete' : 'Transcription complete'; + const summaryParts: string[] = []; + + if (completedCount > 0) summaryParts.push(`${completedCount} completed`); + if (failedCount > 0) summaryParts.push(`${failedCount} failed`); + if (cancelledCount > 0) summaryParts.push(`${cancelledCount} cancelled`); + + const body = summaryParts.length > 0 ? summaryParts.join(' • ') : `${items.length} processed`; + + const notify = (): void => { + try { + new Notification(title, { body }); + } catch (error) { + logger.warn('Failed to create completion notification', { + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + try { + if (Notification.permission === 'granted') { + notify(); + return; + } + + if (Notification.permission === 'default') { + void Notification.requestPermission() + .then((permission) => { + if (permission === 'granted') { + notify(); + } + }) + .catch(() => {}); + } + } catch (error) { + logger.warn('Failed to show completion notification', { + error: error instanceof Error ? error.message : String(error), + }); + } +} + export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueReturn { const { settings, onHistoryAdd, onFirstComplete } = options; @@ -56,6 +107,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur const [isProcessing, setIsProcessing] = useState(false); const [currentItemId, setCurrentItemId] = useState(null); const [duplicateFilesSkipped, setDuplicateFilesSkipped] = useState(0); + const [estimatedTimeRemainingSec, setEstimatedTimeRemainingSec] = useState(null); const isCancelledRef = useRef(false); const hasCalledFirstCompleteRef = useRef(false); @@ -177,6 +229,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur if (isCancelledRef.current) { return { ...item, + startTime, status: 'cancelled', endTime, }; @@ -187,6 +240,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur logger.error('Batch item failed', { id: item.id, error }); return { ...item, + startTime, status: 'error', error, endTime, @@ -196,6 +250,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur if (result.cancelled) { return { ...item, + startTime, status: 'cancelled', endTime, }; @@ -204,6 +259,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur if (!result.text) { return { ...item, + startTime, status: 'error', error: 'Transcription produced no output', endTime, @@ -237,6 +293,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur return { ...item, + startTime, status: 'completed', result, progress: { percent: 100, status: 'Complete!' }, @@ -247,6 +304,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur logger.error('Batch item threw error', { id: item.id, error: err }); return { ...item, + startTime, status: 'error', error, endTime: Date.now(), @@ -274,6 +332,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur const activeIds = new Set(itemsToProcess.map((item) => item.id)); activeRunItemIdsRef.current = activeIds; + setEstimatedTimeRemainingSec(null); setQueue((prev) => prev.map((item) => @@ -294,7 +353,13 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur logger.info('Starting batch processing', { count: itemsToProcess.length }); - for (const item of itemsToProcess) { + const processedDurationsMs: number[] = []; + const processedItems: QueueItem[] = []; + + for (let index = 0; index < itemsToProcess.length; index++) { + const item = itemsToProcess[index]; + if (!item) continue; + if (isCancelledRef.current) { setQueue((prev) => prev.map((q) => @@ -308,12 +373,42 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur const resetItem = { ...item, status: 'pending' as QueueItemStatus, error: undefined }; const processedItem = await processItem(resetItem); + processedItems.push(processedItem); setQueue((prev) => prev.map((q) => (q.id === processedItem.id ? processedItem : q))); + + if ( + typeof processedItem.startTime === 'number' && + typeof processedItem.endTime === 'number' && + processedItem.endTime >= processedItem.startTime + ) { + processedDurationsMs.push(processedItem.endTime - processedItem.startTime); + } + + const remainingItemsCount = itemsToProcess.length - (index + 1); + if (remainingItemsCount <= 0) { + setEstimatedTimeRemainingSec(0); + } else if (processedDurationsMs.length > 0) { + const averageDurationMs = + processedDurationsMs.reduce((total, value) => total + value, 0) / + processedDurationsMs.length; + const estimatedSeconds = Math.max( + 1, + Math.round((averageDurationMs * remainingItemsCount) / 1000) + ); + setEstimatedTimeRemainingSec(estimatedSeconds); + } } + const wasCancelled = isCancelledRef.current; setIsProcessing(false); setCurrentItemId(null); activeRunItemIdsRef.current = new Set(); + setEstimatedTimeRemainingSec(null); + + if (!wasCancelled) { + showBatchCompletionNotification(processedItems); + } + logger.info('Batch processing complete'); }, [isProcessing, queue, processItem] @@ -335,6 +430,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur setIsProcessing(false); setCurrentItemId(null); + setEstimatedTimeRemainingSec(null); setQueue((prev) => prev.map((q) => @@ -369,6 +465,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur isProcessing, currentItemId, duplicateFilesSkipped, + estimatedTimeRemainingSec, addFiles, removeFile, From 854be2c7f5b45ee7ad617a35dd6b9b42696fab10 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 19 Feb 2026 23:22:48 -0300 Subject: [PATCH 07/18] feat: estimate remaining time during transcription processing --- .../__tests__/useBatchQueue.test.ts | 52 +++++++++++++++++++ .../transcription/hooks/useBatchQueue.ts | 35 +++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts index 3154523..446ee7c 100644 --- a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts +++ b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts @@ -545,6 +545,58 @@ describe('useBatchQueue', () => { expect(result.current.estimatedTimeRemainingSec).toBeNull(); nowSpy.mockRestore(); }); + + it('should estimate remaining time from current item progress before first completion', async () => { + let now = 1000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + + let progressCb: ((progress: TranscriptionProgress) => void) | undefined; + let resolveTranscription: ((value: TranscriptionResult) => void) | undefined; + + overrideElectronAPI({ + startTranscription: vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveTranscription = resolve; + }) + ), + onTranscriptionProgress: (cb) => { + progressCb = cb; + return () => {}; + }, + }); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([ + createMockSelectedFile('audio1.mp3'), + createMockSelectedFile('audio2.mp3'), + createMockSelectedFile('audio3.mp3'), + ]); + }); + + let processingPromise: Promise; + act(() => { + processingPromise = result.current.startProcessing(); + }); + + act(() => { + now = 2000; + progressCb?.({ percent: 50, status: 'Halfway' }); + }); + + expect(result.current.estimatedTimeRemainingSec).toBe(5); + + await act(async () => { + now = 3000; + resolveTranscription?.({ success: true, text: 'done' }); + await result.current.cancelProcessing(); + await processingPromise; + }); + + nowSpy.mockRestore(); + }); }); describe('cancelProcessing', () => { diff --git a/src/renderer/features/transcription/hooks/useBatchQueue.ts b/src/renderer/features/transcription/hooks/useBatchQueue.ts index 1b15d4e..642f4d5 100644 --- a/src/renderer/features/transcription/hooks/useBatchQueue.ts +++ b/src/renderer/features/transcription/hooks/useBatchQueue.ts @@ -114,6 +114,8 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur const progressUnsubscribeRef = useRef<(() => void) | null>(null); const queueRef = useRef([]); const activeRunItemIdsRef = useRef>(new Set()); + const currentItemStartTimeRef = useRef(null); + const remainingPendingCountRef = useRef(0); useEffect(() => { queueRef.current = queue; @@ -199,6 +201,7 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur ) ); setCurrentItemId(item.id); + currentItemStartTimeRef.current = startTime; if (progressUnsubscribeRef.current) { progressUnsubscribeRef.current(); @@ -207,6 +210,32 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur progressUnsubscribeRef.current = onTranscriptionProgress((progress) => { setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, progress } : q))); + + const startTimeMs = currentItemStartTimeRef.current; + const progressPercent = Number(progress.percent); + + if ( + isCancelledRef.current || + startTimeMs === null || + !Number.isFinite(progressPercent) || + progressPercent <= 0 || + progressPercent > 100 + ) { + return; + } + + const elapsedMs = Date.now() - startTimeMs; + if (elapsedMs <= 0) { + return; + } + + const projectedItemDurationMs = elapsedMs / (progressPercent / 100); + const remainingCurrentMs = Math.max(0, projectedItemDurationMs - elapsedMs); + const remainingMs = + remainingCurrentMs + projectedItemDurationMs * remainingPendingCountRef.current; + + const estimatedSeconds = Math.max(1, Math.round(remainingMs / 1000)); + setEstimatedTimeRemainingSec(estimatedSeconds); }); logger.info('Processing batch item', { @@ -372,7 +401,9 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur } const resetItem = { ...item, status: 'pending' as QueueItemStatus, error: undefined }; + remainingPendingCountRef.current = itemsToProcess.length - (index + 1); const processedItem = await processItem(resetItem); + currentItemStartTimeRef.current = null; processedItems.push(processedItem); setQueue((prev) => prev.map((q) => (q.id === processedItem.id ? processedItem : q))); @@ -403,6 +434,8 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur setIsProcessing(false); setCurrentItemId(null); activeRunItemIdsRef.current = new Set(); + currentItemStartTimeRef.current = null; + remainingPendingCountRef.current = 0; setEstimatedTimeRemainingSec(null); if (!wasCancelled) { @@ -430,6 +463,8 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur setIsProcessing(false); setCurrentItemId(null); + currentItemStartTimeRef.current = null; + remainingPendingCountRef.current = 0; setEstimatedTimeRemainingSec(null); setQueue((prev) => From e5df5ca6c9ca61b58e83fe5e73bb2c206f7da31b Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Thu, 19 Feb 2026 23:55:33 -0300 Subject: [PATCH 08/18] feat: add queue resume functionality with UI prompt and local storage handling --- src/renderer/App.css | 21 +++ .../components/layout/LeftPanel/LeftPanel.tsx | 23 ++- src/renderer/contexts/AppContext.tsx | 12 ++ src/renderer/contexts/types.ts | 4 + .../__tests__/useBatchQueue.test.ts | 151 ++++++++++++++++ .../transcription/hooks/useBatchQueue.ts | 164 +++++++++++++++++- src/renderer/utils/storage.ts | 1 + 7 files changed, 372 insertions(+), 4 deletions(-) diff --git a/src/renderer/App.css b/src/renderer/App.css index 223cc8d..a0b7b26 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -97,6 +97,27 @@ font-size: 0.9rem; } +.queue-resume-banner { + padding: 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); + display: flex; + flex-direction: column; + gap: 10px; +} + +.queue-resume-banner-title { + margin: 0; + color: var(--text-primary); + font-size: 0.9rem; +} + +.queue-resume-banner-actions { + display: flex; + gap: 8px; +} + .right-panel { padding: 20px; display: flex; diff --git a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx index 6679910..4a47432 100644 --- a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx +++ b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx @@ -6,7 +6,7 @@ import { useFFmpegStatus } from '../../../hooks'; import { TranscriptionActions } from './TranscriptionActions'; import { ErrorMessage } from './ErrorMessage'; import { DonationSection } from './DonationSection'; -import { SystemWarning } from '../../ui'; +import { Button, SystemWarning } from '../../ui'; function LeftPanel(): React.JSX.Element { const { @@ -17,12 +17,16 @@ function LeftPanel(): React.JSX.Element { queue, duplicateFilesSkipped, estimatedTimeRemainingSec, + showQueueResumePrompt, + restoredQueueItemsCount, selectedQueueItemId, handleFilesSelect, removeFromQueue, clearCompletedFromQueue, handleRetryFailed, selectQueueItem, + dismissQueueResumePrompt, + resumePersistedQueue, } = useAppTranscription(); const { isFFmpegAvailable, isChecking, recheckStatus } = useFFmpegStatus(); @@ -43,6 +47,23 @@ function LeftPanel(): React.JSX.Element { disabled={isTranscribing} /> + {showQueueResumePrompt && restoredQueueItemsCount > 0 && ( +
+

+ Restored {restoredQueueItemsCount} queued file + {restoredQueueItemsCount === 1 ? '' : 's'} from your last session. +

+
+ + +
+
+ )} + {queue.length > 0 && ( void; clearCompletedFromQueue: () => void; selectQueueItem: (id: string) => void; + dismissQueueResumePrompt: () => void; + resumePersistedQueue: () => Promise; } export interface TranscriptionContextValue diff --git a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts index 446ee7c..a3a6771 100644 --- a/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts +++ b/src/renderer/features/transcription/__tests__/useBatchQueue.test.ts @@ -12,6 +12,7 @@ import { logger } from '@/services/logger'; describe('useBatchQueue', () => { const originalNotification = globalThis.Notification; + const QUEUE_STORAGE_KEY = 'whisperdesk_queue'; const mockSettings: TranscriptionSettings = { model: 'base', @@ -33,6 +34,7 @@ describe('useBatchQueue', () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); overrideElectronAPI({ startTranscription: vi.fn().mockResolvedValue({ success: true, @@ -87,6 +89,68 @@ describe('useBatchQueue', () => { expect(result.current.isProcessing).toBe(false); expect(result.current.currentItemId).toBe(null); expect(result.current.estimatedTimeRemainingSec).toBeNull(); + expect(result.current.showQueueResumePrompt).toBe(false); + expect(result.current.restoredQueueItemsCount).toBe(0); + }); + + it('should restore persisted queue and normalize processing items to pending', () => { + localStorage.setItem( + QUEUE_STORAGE_KEY, + JSON.stringify([ + { + id: 'pending-1', + file: { name: 'pending.mp3', path: '/path/to/pending.mp3', size: 1024 }, + status: 'pending', + }, + { + id: 'processing-1', + file: { name: 'processing.mp3', path: '/path/to/processing.mp3' }, + status: 'processing', + }, + { + id: 'error-1', + file: { name: 'error.mp3', path: '/path/to/error.mp3' }, + status: 'error', + error: 'Failed previously', + }, + { + id: 'invalid-1', + file: { name: 'invalid.mp3', path: '/path/to/invalid.mp3' }, + status: 'completed', + }, + ]) + ); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + expect(result.current.queue).toHaveLength(3); + expect(result.current.queue[0]).toMatchObject({ + id: 'pending-1', + status: 'pending', + progress: { percent: 0, status: '' }, + }); + expect(result.current.queue[1]).toMatchObject({ + id: 'processing-1', + status: 'pending', + progress: { percent: 0, status: '' }, + }); + expect(result.current.queue[2]).toMatchObject({ + id: 'error-1', + status: 'error', + error: 'Failed previously', + }); + expect(result.current.showQueueResumePrompt).toBe(true); + expect(result.current.restoredQueueItemsCount).toBe(3); + }); + + it('should ignore corrupted persisted queue data', () => { + localStorage.setItem(QUEUE_STORAGE_KEY, 'not-json'); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + expect(result.current.queue).toEqual([]); + expect(result.current.showQueueResumePrompt).toBe(false); + expect(result.current.restoredQueueItemsCount).toBe(0); }); }); @@ -282,6 +346,93 @@ describe('useBatchQueue', () => { }); }); + describe('queue persistence and resume', () => { + it('should persist resumable queue items to localStorage', () => { + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); + }); + + const saved = JSON.parse(localStorage.getItem(QUEUE_STORAGE_KEY) || '[]'); + expect(saved).toHaveLength(1); + expect(saved[0]).toMatchObject({ + file: { + name: 'audio1.mp3', + path: '/path/to/audio1.mp3', + }, + status: 'pending', + }); + }); + + it('should clear persisted queue when all items are completed', async () => { + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + act(() => { + result.current.addFiles([createMockSelectedFile('audio1.mp3')]); + }); + + expect(localStorage.getItem(QUEUE_STORAGE_KEY)).not.toBeNull(); + + await act(async () => { + await result.current.startProcessing(); + }); + + expect(localStorage.getItem(QUEUE_STORAGE_KEY)).toBeNull(); + }); + + it('should dismiss resume prompt without changing restored queue', () => { + localStorage.setItem( + QUEUE_STORAGE_KEY, + JSON.stringify([ + { + id: 'restored-1', + file: { name: 'restored.mp3', path: '/path/to/restored.mp3' }, + status: 'pending', + }, + ]) + ); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + expect(result.current.showQueueResumePrompt).toBe(true); + expect(result.current.queue).toHaveLength(1); + + act(() => { + result.current.dismissQueueResumePrompt(); + }); + + expect(result.current.showQueueResumePrompt).toBe(false); + expect(result.current.restoredQueueItemsCount).toBe(0); + expect(result.current.queue).toHaveLength(1); + }); + + it('should resume restored queue and hide resume prompt', async () => { + localStorage.setItem( + QUEUE_STORAGE_KEY, + JSON.stringify([ + { + id: 'restored-1', + file: { name: 'restored.mp3', path: '/path/to/restored.mp3' }, + status: 'pending', + }, + ]) + ); + + const { result } = renderHook(() => useBatchQueue({ settings: mockSettings })); + + expect(result.current.showQueueResumePrompt).toBe(true); + + await act(async () => { + await result.current.resumePersistedQueue(); + }); + + expect(result.current.showQueueResumePrompt).toBe(false); + expect(result.current.restoredQueueItemsCount).toBe(0); + expect(result.current.queue[0]!.status).toBe('completed'); + }); + }); + describe('startProcessing', () => { it('should process pending items sequentially', async () => { const { result } = renderHook(() => diff --git a/src/renderer/features/transcription/hooks/useBatchQueue.ts b/src/renderer/features/transcription/hooks/useBatchQueue.ts index 642f4d5..416442a 100644 --- a/src/renderer/features/transcription/hooks/useBatchQueue.ts +++ b/src/renderer/features/transcription/hooks/useBatchQueue.ts @@ -12,6 +12,7 @@ import { onTranscriptionProgress, } from '../../../services/electronAPI'; import { logger } from '../../../services/logger'; +import { STORAGE_KEYS } from '../../../utils/storage'; import { sanitizePath } from '../../../../shared/utils'; interface UseBatchQueueOptions { @@ -26,11 +27,15 @@ interface UseBatchQueueReturn { currentItemId: string | null; duplicateFilesSkipped: number; estimatedTimeRemainingSec: number | null; + showQueueResumePrompt: boolean; + restoredQueueItemsCount: number; addFiles: (files: SelectedFile[]) => void; removeFile: (id: string) => void; clearCompleted: () => void; clearAll: () => void; + dismissQueueResumePrompt: () => void; + resumePersistedQueue: () => Promise; startProcessing: () => Promise; retryFailed: () => Promise; @@ -50,6 +55,124 @@ function getFileIdentityKey(file: SelectedFile): string { return `path:${file.path}`; } +type PersistedQueueStatus = Extract< + QueueItemStatus, + 'pending' | 'processing' | 'error' | 'cancelled' +>; + +interface PersistedQueueItem { + id: string; + file: SelectedFile; + status: PersistedQueueStatus; + error?: string; +} + +const QUEUE_STORAGE_KEY = STORAGE_KEYS.QUEUE; + +function isPersistedQueueStatus(status: unknown): status is PersistedQueueStatus { + return ( + status === 'pending' || status === 'processing' || status === 'error' || status === 'cancelled' + ); +} + +function toQueueItem(item: PersistedQueueItem): QueueItem { + const status: QueueItemStatus = item.status === 'processing' ? 'pending' : item.status; + return { + id: item.id, + file: item.file, + status, + progress: { percent: 0, status: '' }, + error: status === 'error' ? item.error : undefined, + }; +} + +function loadPersistedQueue(): QueueItem[] { + try { + const savedQueue = localStorage.getItem(QUEUE_STORAGE_KEY); + if (!savedQueue) { + return []; + } + + const parsedQueue: unknown = JSON.parse(savedQueue); + if (!Array.isArray(parsedQueue)) { + return []; + } + + return parsedQueue.reduce((items, rawItem) => { + if (!rawItem || typeof rawItem !== 'object') { + return items; + } + + const candidate = rawItem as Partial; + if (typeof candidate.id !== 'string' || !isPersistedQueueStatus(candidate.status)) { + return items; + } + + const file = candidate.file; + if ( + !file || + typeof file !== 'object' || + typeof file.name !== 'string' || + typeof file.path !== 'string' + ) { + return items; + } + + const normalizedFile: SelectedFile = { + name: file.name, + path: file.path, + size: typeof file.size === 'number' ? file.size : undefined, + fingerprint: typeof file.fingerprint === 'string' ? file.fingerprint : undefined, + }; + + items.push( + toQueueItem({ + id: candidate.id, + file: normalizedFile, + status: candidate.status, + error: typeof candidate.error === 'string' ? candidate.error : undefined, + }) + ); + return items; + }, []); + } catch { + return []; + } +} + +function persistQueue(queue: QueueItem[]): void { + const resumableItems: PersistedQueueItem[] = queue.reduce((items, item) => { + if (item.status === 'completed') { + return items; + } + + items.push({ + id: item.id, + file: { + name: item.file.name, + path: item.file.path, + size: item.file.size, + fingerprint: item.file.fingerprint, + }, + status: item.status, + error: item.status === 'error' ? item.error : undefined, + }); + + return items; + }, []); + + try { + if (resumableItems.length === 0) { + localStorage.removeItem(QUEUE_STORAGE_KEY); + return; + } + + localStorage.setItem(QUEUE_STORAGE_KEY, JSON.stringify(resumableItems)); + } catch (error) { + logger.error('Failed to persist batch queue:', error); + } +} + function showBatchCompletionNotification(items: QueueItem[]): void { if (typeof Notification === 'undefined' || items.length === 0) { return; @@ -103,11 +226,13 @@ function showBatchCompletionNotification(items: QueueItem[]): void { export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueReturn { const { settings, onHistoryAdd, onFirstComplete } = options; - const [queue, setQueue] = useState([]); + const [queue, setQueue] = useState(() => loadPersistedQueue()); const [isProcessing, setIsProcessing] = useState(false); const [currentItemId, setCurrentItemId] = useState(null); const [duplicateFilesSkipped, setDuplicateFilesSkipped] = useState(0); const [estimatedTimeRemainingSec, setEstimatedTimeRemainingSec] = useState(null); + const [showQueueResumePrompt, setShowQueueResumePrompt] = useState(false); + const [restoredQueueItemsCount, setRestoredQueueItemsCount] = useState(0); const isCancelledRef = useRef(false); const hasCalledFirstCompleteRef = useRef(false); @@ -117,10 +242,26 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur const currentItemStartTimeRef = useRef(null); const remainingPendingCountRef = useRef(0); + useEffect(() => { + const restoredCount = queue.length; + if (restoredCount > 0) { + setShowQueueResumePrompt(true); + setRestoredQueueItemsCount(restoredCount); + } + }, []); + useEffect(() => { queueRef.current = queue; + persistQueue(queue); }, [queue]); + useEffect(() => { + if (queue.length === 0) { + setShowQueueResumePrompt(false); + setRestoredQueueItemsCount(0); + } + }, [queue.length]); + useEffect(() => { return () => { if (progressUnsubscribeRef.current) { @@ -188,9 +329,16 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur return; } setQueue([]); + setShowQueueResumePrompt(false); + setRestoredQueueItemsCount(0); logger.info('Cleared all items from batch queue'); }, [isProcessing]); + const dismissQueueResumePrompt = useCallback(() => { + setShowQueueResumePrompt(false); + setRestoredQueueItemsCount(0); + }, []); + const processItem = useCallback( async (item: QueueItem): Promise => { const startTime = Date.now(); @@ -448,12 +596,18 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur ); const startProcessing = useCallback(async () => { + dismissQueueResumePrompt(); await runProcessing(['pending', 'cancelled', 'error'], 'No items to process'); - }, [runProcessing]); + }, [dismissQueueResumePrompt, runProcessing]); const retryFailed = useCallback(async () => { + dismissQueueResumePrompt(); await runProcessing(['cancelled', 'error'], 'No failed items to retry'); - }, [runProcessing]); + }, [dismissQueueResumePrompt, runProcessing]); + + const resumePersistedQueue = useCallback(async () => { + await startProcessing(); + }, [startProcessing]); const cancelProcessing = useCallback(async () => { if (!isProcessing) return; @@ -501,11 +655,15 @@ export function useBatchQueue(options: UseBatchQueueOptions): UseBatchQueueRetur currentItemId, duplicateFilesSkipped, estimatedTimeRemainingSec, + showQueueResumePrompt, + restoredQueueItemsCount, addFiles, removeFile, clearCompleted, clearAll, + dismissQueueResumePrompt, + resumePersistedQueue, startProcessing, retryFailed, diff --git a/src/renderer/utils/storage.ts b/src/renderer/utils/storage.ts index a820955..f5c65fc 100644 --- a/src/renderer/utils/storage.ts +++ b/src/renderer/utils/storage.ts @@ -4,6 +4,7 @@ const STORAGE_KEYS = { HISTORY: 'whisperdesk_history', THEME: 'whisperdesk_theme', LAST_MODEL: 'whisperdesk_lastModel', + QUEUE: 'whisperdesk_queue', } as const; export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS]; From a3e67352c2befded0173adabec9473b765a3971a Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Fri, 20 Feb 2026 10:54:06 -0300 Subject: [PATCH 09/18] feat: add search functionality to transcription history with filtering and clear option --- .../TranscriptionHistory.css | 37 ++++ .../TranscriptionHistory.tsx | 168 ++++++++++++++---- .../__tests__/TranscriptionHistory.test.tsx | 117 ++++++++++++ 3 files changed, 284 insertions(+), 38 deletions(-) diff --git a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.css b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.css index eb07318..131d359 100644 --- a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.css +++ b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.css @@ -61,6 +61,43 @@ gap: 8px; } +.history-search { + display: flex; + align-items: center; + gap: 8px; + padding: 10px; + margin-bottom: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-muted); +} + +.history-search-input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-size: 0.9rem; +} + +.history-search-input::placeholder { + color: var(--text-muted); +} + +.history-search-summary { + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: 10px; + padding: 0 2px; +} + +.history-empty-search { + height: auto; + min-height: 160px; +} + .history-item { padding: 12px; background: var(--surface); diff --git a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx index 24ceebe..dae8943 100644 --- a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx +++ b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx @@ -1,5 +1,12 @@ -import React, { type KeyboardEvent } from 'react'; -import { History, Trash2, X, Inbox, Clock } from 'lucide-react'; +import React, { + useMemo, + useState, + useEffect, + useRef, + type KeyboardEvent, + type ChangeEvent, +} from 'react'; +import { History, Trash2, X, Inbox, Clock, Search } from 'lucide-react'; import { Button } from '../../../../components/ui'; import { formatDate, formatDuration } from '../../../../utils'; import { getLanguageLabel } from '../../../../config'; @@ -22,12 +29,58 @@ function TranscriptionHistory({ onSelect, onDelete, }: TranscriptionHistoryProps): React.JSX.Element { + const [searchQuery, setSearchQuery] = useState(''); + const searchInputRef = useRef(null); + + const trimmedQuery = searchQuery.trim().toLowerCase(); + const filteredHistory = useMemo(() => { + if (!trimmedQuery) { + return history; + } + + return history.filter((item) => { + const searchable = [ + item.fileName, + item.preview, + item.fullText, + item.model, + getLanguageLabel(item.language), + item.format ?? '', + ] + .join(' ') + .toLowerCase(); + + return searchable.includes(trimmedQuery); + }); + }, [history, trimmedQuery]); + + useEffect(() => { + const handleKeyDown = (event: globalThis.KeyboardEvent): void => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') { + event.preventDefault(); + searchInputRef.current?.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + const handleItemKeyDown = (e: KeyboardEvent, item: HistoryItem): void => { if (e.key === 'Enter') { onSelect(item); } }; + const handleSearchChange = (event: ChangeEvent): void => { + setSearchQuery(event.target.value); + }; + + const handleSearchClear = (): void => { + setSearchQuery(''); + searchInputRef.current?.focus(); + }; + const handleDelete = (event: React.MouseEvent, itemId: string, fileName: string): void => { event.stopPropagation(); if (window.confirm(`Are you sure you want to delete the transcription for "${fileName}"?`)) { @@ -74,44 +127,83 @@ function TranscriptionHistory({ Your transcription history will appear here
) : ( -
- {history.map((item) => ( -
onSelect(item)} - role="button" - tabIndex={0} - onKeyDown={(e) => handleItemKeyDown(e, item)} - > -
- {item.fileName} -
- {formatDate(item.date)} -
+ + {trimmedQuery && ( +
+ {filteredHistory.length} result{filteredHistory.length === 1 ? '' : 's'} +
+ )} + + {filteredHistory.length === 0 ? ( +
+ No matches found + Try different keywords +
+ ) : ( +
+ {filteredHistory.map((item) => ( +
onSelect(item)} + role="button" + tabIndex={0} + onKeyDown={(e) => handleItemKeyDown(e, item)} + > +
+ {item.fileName} +
+ {formatDate(item.date)} +
+
+
+ {item.model} + {getLanguageLabel(item.language)} + {item.format && .{item.format}} + + +
+

{item.preview}

-
-
- {item.model} - {getLanguageLabel(item.language)} - {item.format && .{item.format}} - - -
-

{item.preview}

+ ))}
- ))} -
+ )} + )}
diff --git a/src/renderer/features/history/components/TranscriptionHistory/__tests__/TranscriptionHistory.test.tsx b/src/renderer/features/history/components/TranscriptionHistory/__tests__/TranscriptionHistory.test.tsx index c400432..a2c12cb 100644 --- a/src/renderer/features/history/components/TranscriptionHistory/__tests__/TranscriptionHistory.test.tsx +++ b/src/renderer/features/history/components/TranscriptionHistory/__tests__/TranscriptionHistory.test.tsx @@ -50,6 +50,123 @@ describe('TranscriptionHistory component', () => { expect(onSelect).toHaveBeenCalledWith(mockHistoryItem); }); + it('filters history items by transcript content and file name', () => { + const onClear = vi.fn(); + const onClose = vi.fn(); + const onSelect = vi.fn(); + const onDelete = vi.fn(); + const firstItem = createMockHistoryItem({ + id: 'history-1', + fileName: 'finance-report.mp3', + fullText: 'Quarterly earnings increased by twelve percent', + preview: 'Quarterly earnings increased...', + }); + const secondItem = createMockHistoryItem({ + id: 'history-2', + fileName: 'meeting-notes.mp3', + fullText: 'Team retrospective and sprint planning notes', + preview: 'Team retrospective...', + }); + + render( + + ); + + const searchInput = screen.getByLabelText('Search history'); + fireEvent.change(searchInput, { target: { value: 'earnings' } }); + + expect(screen.getByText('finance-report.mp3')).toBeInTheDocument(); + expect(screen.queryByText('meeting-notes.mp3')).not.toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: 'meeting-notes' } }); + + expect(screen.getByText('meeting-notes.mp3')).toBeInTheDocument(); + expect(screen.queryByText('finance-report.mp3')).not.toBeInTheDocument(); + }); + + it('shows empty search state when there are no matching history items', () => { + const onClear = vi.fn(); + const onClose = vi.fn(); + const onSelect = vi.fn(); + const onDelete = vi.fn(); + const historyItem = createMockHistoryItem({ + id: 'history-1', + fullText: 'The quick brown fox', + preview: 'The quick brown fox...', + }); + + render( + + ); + + fireEvent.change(screen.getByLabelText('Search history'), { target: { value: 'not found' } }); + + expect(screen.getByText('No matches found')).toBeInTheDocument(); + expect(screen.getByText('0 results')).toBeInTheDocument(); + }); + + it('clears the search query when clear search button is clicked', () => { + const onClear = vi.fn(); + const onClose = vi.fn(); + const onSelect = vi.fn(); + const onDelete = vi.fn(); + const historyItem = createMockHistoryItem({ + id: 'history-1', + fullText: 'alpha beta gamma', + preview: 'alpha beta gamma', + }); + + render( + + ); + + const searchInput = screen.getByLabelText('Search history') as HTMLInputElement; + fireEvent.change(searchInput, { target: { value: 'alpha' } }); + expect(searchInput.value).toBe('alpha'); + + fireEvent.click(screen.getByLabelText('Clear history search')); + expect(searchInput.value).toBe(''); + }); + + it('focuses search input with keyboard shortcut', () => { + const onClear = vi.fn(); + const onClose = vi.fn(); + const onSelect = vi.fn(); + const onDelete = vi.fn(); + const historyItem = createMockHistoryItem(); + + render( + + ); + + fireEvent.keyDown(document, { key: 'f', ctrlKey: true }); + expect(screen.getByLabelText('Search history')).toHaveFocus(); + }); + it('calls onSelect on Enter key press', () => { const mockHistoryItem = createMockHistoryItem(); const onClear = vi.fn(); From f02eb738724d02b45cde37ebbee319a2183afb77 Mon Sep 17 00:00:00 2001 From: Pedro Siqueira Date: Fri, 20 Feb 2026 11:26:02 -0300 Subject: [PATCH 10/18] feat: update README and documentation to reflect new features --- README.md | 8 +++++++- docs/index.html | 32 ++++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5d8fe55..d7a8350 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,21 @@ A beautiful, native macOS desktop application for transcribing audio and video f - **Drag & Drop** - Drag single or multiple files to create a batch queue - **Batch Processing** - Process unlimited files sequentially with automatic queue management +- **Queue Persistence + Resume** - Restore unfinished queue items after restarting the app +- **Duplicate File Protection** - Automatically skips duplicates by file path/fingerprint in batch mode +- **Retry Failed Items** - One-click retry for failed or cancelled queue items +- **Live Batch ETA** - See estimated remaining time while batch processing is running +- **Completion Notifications** - Native notification when a batch finishes - **Multiple Formats** - Supports MP3, WAV, M4A, FLAC, OGG, WMA, AAC, AIFF, MP4, MOV, AVI, MKV, WebM, WMV, FLV, M4V - **Multiple Models** - Choose from tiny, base, small, medium, large-v3, or large-v3-turbo Whisper models (including English-only variants) - **Output Formats** - Export as VTT subtitles, SRT subtitles, plain text, Word (`.docx`), PDF, or Markdown +- **Finder Reveal After Save** - Prompt to reveal the saved transcript directly in Finder - **Language Support** - Auto-detect or select from 12+ languages - **Apple Silicon Optimized** - Native Metal GPU acceleration on M1/M2/M3/M4 Macs - **Dark Mode** - Beautiful dark theme that respects your system preference - **Auto Updates** - Automatic update notifications when new versions are available - **Keyboard Shortcuts** - Full keyboard navigation support -- **Transcription History** - Keep track of your recent transcriptions +- **Transcription History + Search** - Keep track of recent transcriptions and search by file name, content, model, or language - **Native Performance** - Uses whisper.cpp for fast, efficient transcription - **TypeScript** - Fully typed codebase for better maintainability - **Feature-Driven Architecture** - Modular codebase organized by feature domains diff --git a/docs/index.html b/docs/index.html index 67b93b7..7e35804 100644 --- a/docs/index.html +++ b/docs/index.html @@ -8,7 +8,7 @@ + content="Free, open-source audio transcription app for macOS. Transcribe audio & video files privately using OpenAI's Whisper AI locally. No cloud uploads, no subscriptions. Supports Apple Silicon & Intel. Export to SRT, VTT, TXT, DOCX, PDF, and Markdown. Smart batch queue with resume, retry, live ETA, duplicate skipping, and searchable history"> @@ -61,13 +61,16 @@ }, "license": "https://opensource.org/licenses/MIT", "screenshot": "https://whisperdesk.org/screenshot.png", - "softwareVersion": "1.1.6", + "softwareVersion": "1.9.0", "downloadUrl": "https://github.com/PVAS-Development/whisperdesk/releases/latest", "featureList": [ "100% Local Processing", "GPU Accelerated (Apple Silicon Metal)", - "Multi-format Export (SRT, VTT, JSON, TXT)", - "Batch Processing Queue", + "Batch Queue with ETA, Retry, and Duplicate Protection", + "Queue Persistence and Resume After Restart", + "Transcription History Search", + "Multi-format Export (VTT, SRT, TXT, DOCX, PDF, Markdown)", + "Finder Reveal After Save", "No Internet Required", "No Subscription Fees", "Open Source (MIT License)" @@ -134,7 +137,7 @@ "name": "What formats can WhisperDesk export?", "acceptedAnswer": { "@type": "Answer", - "text": "WhisperDesk supports exporting transcriptions to SRT (subtitles), VTT (web subtitles), JSON, and plain text formats." + "text": "WhisperDesk can export transcripts as VTT, SRT, TXT, Markdown, DOCX, and PDF." } }, { @@ -144,6 +147,14 @@ "@type": "Answer", "text": "Yes, WhisperDesk is optimized for Apple Silicon (M1, M2, M3, M4) Macs with Metal GPU acceleration for blazing fast transcription speeds up to 100x real-time." } + }, + { + "@type": "Question", + "name": "Can I resume my transcription queue after restarting the app?", + "acceptedAnswer": { + "@type": "Answer", + "text": "Yes. WhisperDesk restores unfinished queue items after restart and lets you resume processing with one click." + } } ] } @@ -175,8 +186,8 @@

Batch Processing

- Drag and drop multiple files or folders. Process unlimited audio files sequentially with automatic queue management. + Drag and drop multiple files or folders. Process unlimited audio files sequentially with + automatic queue management.

@@ -343,8 +355,8 @@

Open Source & Community Drive