diff --git a/electron/main.js b/electron/main.js index cad5cb1..1f1784a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -14,6 +14,7 @@ const { let mainWindow = null; let pythonBackend = null; let backendStartupError = ''; +let backendStartupState = 'idle'; const isDev = !app.isPackaged; const PROJECT_ROOT = path.join(__dirname, '..'); @@ -151,14 +152,20 @@ app.whenReady().then(async () => { userDataPath: app.getPath('userData'), projectRoot: PROJECT_ROOT, }); + const rendererSmoke = process.env.SCRIPTCUT_RENDERER_SMOKE === '1'; + const creatorQualificationSmoke = process.env.SCRIPTCUT_CREATOR_QUALIFICATION_SMOKE === '1'; + if (!rendererSmoke && !creatorQualificationSmoke) createWindow(); + backendStartupState = 'starting'; try { await pythonBackend.start(); + backendStartupState = 'ready'; } catch (error) { backendStartupError = error instanceof Error ? error.message : String(error); + backendStartupState = 'error'; console.error('[backend] Startup failed:', backendStartupError); } - if (process.env.SCRIPTCUT_RENDERER_SMOKE === '1') { + if (rendererSmoke) { try { if (!app.isPackaged) throw new Error('renderer transport smoke requires a packaged app'); const { runRendererTransportSmoke } = require('./renderer-transport-smoke'); @@ -182,7 +189,7 @@ app.whenReady().then(async () => { return; } - if (process.env.SCRIPTCUT_CREATOR_QUALIFICATION_SMOKE === '1') { + if (creatorQualificationSmoke) { try { if (!app.isPackaged) throw new Error('creator qualification smoke requires a packaged app'); const { runCreatorQualificationSmoke } = require('./creator-qualification-smoke'); @@ -211,7 +218,6 @@ app.whenReady().then(async () => { return; } - createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); @@ -284,7 +290,10 @@ ipcMain.handle('file:getUrl', (event, filePath) => { assertTrustedSender(event); return createApprovedBackendFileUrl(filePath); }); -ipcMain.handle('app:getStartupStatus', (event) => { assertTrustedSender(event); return { backendError: backendStartupError }; }); +ipcMain.handle('app:getStartupStatus', (event) => { + assertTrustedSender(event); + return { backendError: backendStartupError, backendState: backendStartupState }; +}); ipcMain.handle('app:getInfo', (event) => { assertTrustedSender(event); return { version: app.getVersion(), platform: process.platform, arch: process.arch, packaged: app.isPackaged, electron: process.versions.electron }; diff --git a/frontend/scripts/smoke-settings-export-ux.mjs b/frontend/scripts/smoke-settings-export-ux.mjs index d49b23f..2d5d498 100644 --- a/frontend/scripts/smoke-settings-export-ux.mjs +++ b/frontend/scripts/smoke-settings-export-ux.mjs @@ -11,6 +11,8 @@ const readSource = (relativePath) => readFileSync(resolve(__dirname, relativePat const settingsSource = readSource('../src/components/SettingsPanel.tsx'); const exportSource = readSource('../src/components/ExportDialog.tsx'); +const exportControllerSource = readSource('../src/features/export/useFullVideoExportController.ts'); +const appSource = readSource('../src/App.tsx'); const releaseSource = readSource('../src/utils/releaseInfo.ts'); const editorStoreSource = readSource('../src/store/editorStore.ts'); const helperSource = readSource('../src/utils/settingsUx.ts'); @@ -65,17 +67,49 @@ for (const technicalControl of [ ]) { assert.match(exportSource, new RegExp(technicalControl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } -for (const contract of [ - 'getExportPreflight', - 'startExport', - '/jobs/export', - 'pollExportJob', - '/retry', - '/cancel', - 'getFriendlyExportError', -]) { +for (const contract of ['getExportPreflight', 'startExport', 'getFriendlyExportError', 'applyExportCompletion']) { assert.match(exportSource, new RegExp(contract.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } +for (const contract of ['/jobs/export', 'pollExportJob', '/retry', '/cancel']) { + assert.match(exportControllerSource, new RegExp(contract.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} +assert.match(appSource, /const fullVideoExportController = useFullVideoExportController\(\{ backendUrl, workspaceRevision \}\)/); +assert.match(appSource, /activePanel === 'export' && /); +assert.ok( + appSource.indexOf('useFullVideoExportController({ backendUrl, workspaceRevision })') < appSource.indexOf("activePanel === 'export'"), + 'full-video export controller must remain mounted above the conditional ExportDialog panel', +); +assert.match(exportControllerSource, /const activeRunRef = useRef\(null\)/); +assert.match(exportControllerSource, /Another full-video export is still running/); +assert.match(exportControllerSource, /Connection interrupted; checking export status[\s\S]*setExporting\(true, activeProgressRef\.current\)[\s\S]*continue/); +assert.match(exportControllerSource, /if \(!res\.ok\) \{[\s\S]*res\.status === 404[\s\S]*Export status unavailable; retrying[\s\S]*continue/); +assert.match(exportControllerSource, /Export status was unreadable; retrying[\s\S]*setExporting\(true, activeProgressRef\.current\)[\s\S]*continue/); +assert.match(exportControllerSource, /const mirroredIsExporting = useEditorStore\(\(state\) => state\.isExporting\)/); +assert.match(exportControllerSource, /const mirroredExportProgress = useEditorStore\(\(state\) => state\.exportProgress\)/); +assert.match(exportControllerSource, /if \(!mirroredIsExporting \|\| mirroredExportProgress !== activeProgressRef\.current\) \{\s*setExporting\(true, activeProgressRef\.current\);\s*\}/); +assert.match(exportControllerSource, /workspaceRevision: number/); +assert.match(exportControllerSource, /const currentWorkspaceRef = useRef\(\{ mediaPath: currentMediaPath, workspaceRevision \}\)/); +assert.match(exportControllerSource, /context\.workspaceRevision === currentWorkspaceRef\.current\.workspaceRevision/); +assert.match(exportControllerSource, /if \(!isCurrentWorkspace\(context\)\) return null;[\s\S]*const run = beginRun\(context\)/); +const cancelTransportBody = exportControllerSource.match(/const cancelExportJob = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[1] || ''; +assert.match(cancelTransportBody, /job\.runId !== run\.id \|\| !isCurrentRun\(run\)/); +assert.doesNotMatch(cancelTransportBody, /setExporting\(false/); +const retryTransportBody = exportControllerSource.match(/const retryExportJob = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[1] || ''; +assert.match(retryTransportBody, /\['failed', 'canceled'\]\.includes\(sourceJob\.status\)/); +assert.match(retryTransportBody, /!isCurrentWorkspace\(sourceJob\.context\)/); +assert.match(retryTransportBody, /const run = beginRun\(sourceJob\.context\)/); +assert.doesNotMatch(exportControllerSource, /getExportPreflight|localStorage|rememberExport|buildBackendFileUrl|setExportOptions/); +assert.doesNotMatch(exportSource, /\/jobs\/export|\/jobs\/\$\{[^}]+\}\/cancel|\/jobs\/\$\{[^}]+\}\/retry/); +assert.match(exportSource, /completion\.context\.preset/); +assert.match(exportSource, /completion\.context\.format/); +assert.match(exportSource, /!mountedRef\.current/); +assert.match(exportSource, /useEditorStore\.getState\(\)\.videoPath !== completion\.context\.mediaPath/); +assert.match(exportControllerSource, /setPendingCompletion\(\{ jobId, completion \}\)/); +assert.match(exportSource, /pendingCompletion\.completion\.context\.mediaPath !== videoPath/); +assert.match(exportSource, /pendingCompletion\.completion\.context\.workspaceRevision !== workspaceRevision/); +assert.match(exportSource, /workspaceRevision,\s*preset: exportOptions\.preset/); +assert.match(exportSource, /applyExportCompletion\(pendingCompletion\.completion\)/); +assert.match(exportSource, /acknowledgeCompletion\(pendingCompletion\.jobId\)/); assert.match(editorStoreSource, /preset: 'source'/); assert.match(editorStoreSource, /mode: 'fast'/); assert.match(editorStoreSource, /aspectRatio: 'source'/); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e48f49..5e0f8c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,9 @@ -import { useCallback, useEffect, useState, useRef } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useState, useRef } from 'react'; import { useEditorStore } from './store/editorStore'; import { useAIStore } from './store/aiStore'; import VideoPlayer from './components/VideoPlayer'; import TranscriptEditor from './components/TranscriptEditor'; import WaveformTimeline from './components/WaveformTimeline'; -import AIPanel from './components/AIPanel'; -import ExportDialog from './components/ExportDialog'; -import SettingsPanel from './components/SettingsPanel'; import EditorTaskHeader from './components/EditorTaskHeader'; import HomeScreen, { type SystemChecksResponse, @@ -21,6 +18,7 @@ import { type TranscriptionEngineStatus, } from './utils/transcriptionModels'; import { useTranscriptionController } from './features/transcription/useTranscriptionController'; +import { useFullVideoExportController } from './features/export/useFullVideoExportController'; import { saveProject, useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { getAutosaveCandidatePaths, @@ -62,6 +60,9 @@ import { resolveBackendFileUrl } from './utils/backendFile'; const IS_ELECTRON = !!window.electronAPI; const ONBOARDING_DISMISSED_KEY = 'scriptcut.onboarding.dismissed.v1'; +const AIPanel = lazy(() => import('./components/AIPanel')); +const ExportDialog = lazy(() => import('./components/ExportDialog')); +const SettingsPanel = lazy(() => import('./components/SettingsPanel')); type Panel = EditorPanel; @@ -98,12 +99,13 @@ export default function App() { const [systemChecks, setSystemChecks] = useState(null); const [systemChecksError, setSystemChecksError] = useState(''); const [backendStartupError, setBackendStartupError] = useState(''); + const [backendReady, setBackendReady] = useState(!IS_ELECTRON); const [creatorNotice, setCreatorNotice] = useState(null); const [autosaveRestoreRequest, setAutosaveRestoreRequest] = useState<{ data: ReturnType; resolve: (restore: boolean) => void; } | null>(null); - const [isCheckingSystem, setIsCheckingSystem] = useState(false); + const [isCheckingSystem, setIsCheckingSystem] = useState(IS_ELECTRON); const [onboardingDismissed, setOnboardingDismissed] = useState( () => window.localStorage.getItem(ONBOARDING_DISMISSED_KEY) === 'true', ); @@ -130,22 +132,56 @@ export default function App() { transcriptionModel, onCompleted: handleTranscriptionCompleted, }); + const fullVideoExportController = useFullVideoExportController({ backendUrl, workspaceRevision }); useKeyboardShortcuts(); const autosave = useProjectAutosave(); useEffect(() => { - if (IS_ELECTRON) { - window.electronAPI!.getBackendUrl().then(setBackendUrl); - window.electronAPI!.getStartupStatus().then(({ backendError }) => { - if (!backendError) return; - setBackendStartupError(String(backendError)); + if (!IS_ELECTRON) return; + let canceled = false; + let retryTimer: number | undefined; + + window.electronAPI!.getBackendUrl().then((url) => { + if (!canceled) setBackendUrl(url); + }); + + const syncStartupStatus = async () => { + try { + const { backendError, backendState } = await window.electronAPI!.getStartupStatus(); + if (canceled) return; + if (backendState === 'ready') { + setBackendStartupError(''); + setBackendReady(true); + return; + } + if (backendState === 'error' || backendError) { + setBackendReady(false); + setBackendStartupError(String(backendError || 'Local editing service could not start.')); + setOnboardingDismissed(false); + return; + } + retryTimer = window.setTimeout(() => void syncStartupStatus(), 250); + } catch (error) { + if (canceled) return; + setBackendReady(false); + setBackendStartupError(error instanceof Error ? error.message : String(error)); setOnboardingDismissed(false); - }); - } + } + }; + + void syncStartupStatus(); + return () => { + canceled = true; + if (retryTimer !== undefined) window.clearTimeout(retryTimer); + }; }, [setBackendUrl]); useEffect(() => { + if (!backendReady) { + setTranscriptionEngineStatus(null); + return; + } let canceled = false; fetch(`${backendUrl}/transcription/engines`) .then((res) => (res.ok ? res.json() : null)) @@ -159,9 +195,15 @@ export default function App() { return () => { canceled = true; }; - }, [backendUrl]); + }, [backendUrl, backendReady]); const refreshSystemChecks = useCallback(async () => { + if (IS_ELECTRON && !backendReady) { + setSystemChecks(null); + setSystemChecksError(''); + setIsCheckingSystem(!backendStartupError); + return; + } setIsCheckingSystem(true); setSystemChecksError(''); try { @@ -174,7 +216,7 @@ export default function App() { } finally { setIsCheckingSystem(false); } - }, [backendUrl]); + }, [backendStartupError, backendUrl, backendReady]); useEffect(() => { void refreshSystemChecks(); @@ -775,9 +817,11 @@ export default function App() { aria-label={sidePanelLabel} className="w-80 border-l border-editor-border overflow-y-auto shrink-0" > - {activePanel === 'ai' && } - {activePanel === 'export' && } - {activePanel === 'settings' && } + }> + {activePanel === 'ai' && } + {activePanel === 'export' && } + {activePanel === 'settings' && } + )} @@ -787,6 +831,15 @@ export default function App() { ); } +function PanelLoading({ label }: { label: string }) { + return ( +
+
+ ); +} + function AutosaveStatus({ autosave }: { autosave: ReturnType }) { if (autosave.status === 'idle') return null; if (autosave.status === 'unavailable') { diff --git a/frontend/src/components/ExportDialog.tsx b/frontend/src/components/ExportDialog.tsx index af78c80..65df758 100644 --- a/frontend/src/components/ExportDialog.tsx +++ b/frontend/src/components/ExportDialog.tsx @@ -1,9 +1,13 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useEditorStore } from '../store/editorStore'; import { Download, Loader2, Zap, Cog, Info, Monitor, Smartphone, Square, X, Image, FolderOpen, ExternalLink, RotateCcw, AlertTriangle, CheckCircle2 } from 'lucide-react'; import type { CaptionStyle, ExportOptions, ProjectExportOptions } from '../types/project'; import CaptionPreview from './CaptionPreview'; import { buildBackendFileUrl } from '../utils/backendFile'; +import type { + FullVideoExportCompletion, + FullVideoExportController, +} from '../features/export/useFullVideoExportController'; type ExportPreset = ExportOptions['preset']; type CaptionPreset = NonNullable; @@ -16,22 +20,6 @@ interface ExportResult { srtDownloadUrl?: string; } -interface ExportJob { - id: string; - status: 'queued' | 'running' | 'canceling' | 'succeeded' | 'failed' | 'canceled'; - progress: number; - message: string; - logs?: Array<{ time: string; message: string }>; - result?: { - output_path?: string; - srt_path?: string; - file_capability?: string; - srt_file_capability?: string; - warnings?: string[]; - }; - error?: string; -} - interface ExportHistoryItem { outputPath: string; srtPath?: string; @@ -312,7 +300,7 @@ function getFriendlyExportError(message: string) { return message; } -export default function ExportDialog() { +export default function ExportDialog({ controller }: { controller: FullVideoExportController }) { const { videoPath, words, @@ -321,7 +309,6 @@ export default function ExportDialog() { isExporting, exportProgress, backendUrl, - setExporting, getKeepSegments, getMutedRanges, getCaptionHiddenIndices, @@ -329,14 +316,24 @@ export default function ExportDialog() { exportOptions: options, setExportOptions, } = useEditorStore(); + const { + acknowledgeCompletion, + cancelExportJob, + canRetryExport, + exportFailure, + exportJobId, + exportJobStatus, + exportLogs, + exportMessage, + pendingCompletion, + retryExportJob, + startExportJob, + workspaceRevision, + } = controller; const hasCuts = deletedRanges.length > 0; const [exportError, setExportError] = useState(''); const [exportResult, setExportResult] = useState(null); - const [exportJobId, setExportJobId] = useState(''); - const [exportMessage, setExportMessage] = useState(''); - const [lastExportJobId, setLastExportJobId] = useState(''); - const [exportLogs, setExportLogs] = useState>([]); const [backgroundCapabilities, setBackgroundCapabilities] = useState(null); const [systemChecks, setSystemChecks] = useState(null); const [showAdvancedExport, setShowAdvancedExport] = useState(false); @@ -344,6 +341,8 @@ export default function ExportDialog() { const [exportDirectory, setExportDirectory] = useState(() => window.localStorage.getItem(EXPORT_DIRECTORY_KEY) || ''); const [exportPath, setExportPath] = useState(() => window.localStorage.getItem(EXPORT_PATH_KEY) || ''); const [exportHistory, setExportHistory] = useState(loadExportHistory); + const mountedRef = useRef(true); + const visibleExportError = exportError || (exportFailure ? getFriendlyExportError(exportFailure) : ''); const readiness = getExportReadiness(options, hasCuts, words.length, !!window.electronAPI, systemChecks); const preflight = getExportPreflight({ videoPath, @@ -358,6 +357,13 @@ export default function ExportDialog() { systemChecks, }); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + useEffect(() => { let canceled = false; fetch(`${backendUrl}/background/capabilities`) @@ -529,51 +535,37 @@ export default function ExportDialog() { window.localStorage.removeItem(EXPORT_HISTORY_KEY); }, []); - const pollExportJob = useCallback( - async (jobId: string) => { - for (;;) { - await new Promise((resolve) => window.setTimeout(resolve, 700)); - const res = await fetch(`${backendUrl}/jobs/${jobId}`); - if (!res.ok) throw new Error(`Could not read export job: ${res.statusText}`); - - const job = (await res.json()) as ExportJob; - setExportMessage(job.message || job.status); - setExportLogs(job.logs || []); - setExporting(job.status === 'queued' || job.status === 'running' || job.status === 'canceling', job.progress); - - if (job.status === 'succeeded') { - const outputPath = job.result?.output_path || ''; - const srtPath = job.result?.srt_path; - setExportResult({ - outputPath, - srtPath, - warnings: job.result?.warnings || [], - downloadUrl: buildBackendFileUrl(backendUrl, outputPath, job.result?.file_capability), - srtDownloadUrl: buildBackendFileUrl(backendUrl, srtPath, job.result?.srt_file_capability), - }); - if (outputPath) { - rememberExport({ - outputPath, - srtPath, - exportedAt: new Date().toISOString(), - preset: options.preset, - format: options.format, - }); - } - setExportJobId(''); - setLastExportJobId(jobId); - setExporting(false, 100); - return; - } - - if (job.status === 'failed' || job.status === 'canceled') { - setLastExportJobId(jobId); - throw new Error(job.error || job.message || `Export ${job.status}`); - } - } - }, - [backendUrl, options.format, options.preset, rememberExport, setExporting], - ); + const applyExportCompletion = useCallback((completion: FullVideoExportCompletion) => { + if (!mountedRef.current) return; + if (useEditorStore.getState().videoPath !== completion.context.mediaPath) return; + if (completion.context.workspaceRevision !== workspaceRevision) return; + const outputPath = completion.result.output_path || ''; + const srtPath = completion.result.srt_path; + setExportResult({ + outputPath, + srtPath, + warnings: completion.result.warnings || [], + downloadUrl: buildBackendFileUrl(backendUrl, outputPath, completion.result.file_capability), + srtDownloadUrl: buildBackendFileUrl(backendUrl, srtPath, completion.result.srt_file_capability), + }); + if (outputPath) { + rememberExport({ + outputPath, + srtPath, + exportedAt: new Date().toISOString(), + preset: completion.context.preset, + format: completion.context.format, + }); + } + }, [backendUrl, rememberExport, workspaceRevision]); + + useEffect(() => { + if (!pendingCompletion) return; + if (pendingCompletion.completion.context.mediaPath !== videoPath) return; + if (pendingCompletion.completion.context.workspaceRevision !== workspaceRevision) return; + applyExportCompletion(pendingCompletion.completion); + acknowledgeCompletion(pendingCompletion.jobId); + }, [acknowledgeCompletion, applyExportCompletion, pendingCompletion, videoPath, workspaceRevision]); const startExport = useCallback(async (exportOptions: ProjectExportOptions) => { if (!videoPath) return; @@ -611,6 +603,7 @@ export default function ExportDialog() { }) : undefined; if (window.electronAPI && !outputPath) return; + if (useEditorStore.getState().videoPath !== videoPath) return; if (window.electronAPI && outputPath && !exportPath && !exportDirectory) { setExportPath(outputPath); window.localStorage.setItem(EXPORT_PATH_KEY, outputPath); @@ -618,10 +611,6 @@ export default function ExportDialog() { setExportError(''); setExportResult(null); - setExportLogs([]); - setLastExportJobId(''); - setExporting(true, 1); - setExportMessage('Starting export'); try { const keepSegments = getKeepSegments(); @@ -632,10 +621,14 @@ export default function ExportDialog() { } for (const idx of getCaptionHiddenIndices()) deletedSet.add(idx); - const res = await fetch(`${backendUrl}/jobs/export`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + await startExportJob({ + context: { + mediaPath: videoPath, + workspaceRevision, + preset: exportOptions.preset, + format: exportOptions.format, + }, + body: { ...exportOptions, input_path: videoPath, output_path: outputPath || undefined, @@ -644,28 +637,14 @@ export default function ExportDialog() { words: exportOptions.captions !== 'none' ? words : undefined, deleted_indices: exportOptions.captions !== 'none' ? [...deletedSet] : undefined, captionStyle: exportOptions.captions === 'burn-in' ? exportOptions.captionStyle : undefined, - }), + }, }); - if (!res.ok) { - let detail = res.statusText; - try { - const data = await res.json(); - detail = data.detail || JSON.stringify(data); - } catch { - // Keep the HTTP status text if the backend did not return JSON. - } - throw new Error(`Export start failed: ${detail}`); - } - const { job_id: jobId } = await res.json(); - setExportJobId(jobId); - setLastExportJobId(jobId); - await pollExportJob(jobId); } catch (err) { console.error('Export error:', err); + if (!mountedRef.current || useEditorStore.getState().videoPath !== videoPath) return; setExportError(getFriendlyExportError(err instanceof Error ? err.message : String(err))); - setExporting(false, 0); } - }, [videoPath, duration, hasCuts, words, backgroundCapabilities, exportDirectory, exportPath, systemChecks, backendUrl, setExporting, getKeepSegments, getMutedRanges, getCaptionHiddenIndices, deletedRanges, pollExportJob]); + }, [videoPath, duration, hasCuts, words, backgroundCapabilities, exportDirectory, exportPath, systemChecks, getKeepSegments, getMutedRanges, getCaptionHiddenIndices, deletedRanges, startExportJob, workspaceRevision]); const handleExport = useCallback(() => { void startExport(options); @@ -678,35 +657,26 @@ export default function ExportDialog() { }, [options, setExportOptions, startExport]); const cancelExport = useCallback(async () => { - if (!exportJobId) return; - await fetch(`${backendUrl}/jobs/${exportJobId}/cancel`, { method: 'POST' }); - setExportJobId(''); - setExportMessage('Cancel requested'); - }, [backendUrl, exportJobId]); + try { + await cancelExportJob(); + } catch (err) { + if (!mountedRef.current) return; + setExportError(getFriendlyExportError(err instanceof Error ? err.message : String(err))); + } + }, [cancelExportJob]); const retryExport = useCallback(async () => { - if (!lastExportJobId) return; + if (!canRetryExport) return; setExportError(''); setExportResult(null); - setExporting(true, 1); - setExportMessage('Retrying export'); - const res = await fetch(`${backendUrl}/jobs/${lastExportJobId}/retry`, { method: 'POST' }); - if (!res.ok) { - setExporting(false, 0); - setExportError(`Retry failed: ${res.statusText}`); - return; - } - const { job_id: jobId } = await res.json(); - setExportJobId(jobId); - setLastExportJobId(jobId); try { - await pollExportJob(jobId); + await retryExportJob(); } catch (err) { console.error('Export retry error:', err); + if (!mountedRef.current) return; setExportError(getFriendlyExportError(err instanceof Error ? err.message : String(err))); - setExporting(false, 0); } - }, [backendUrl, lastExportJobId, pollExportJob, setExporting]); + }, [canRetryExport, retryExportJob]); return (
@@ -912,8 +882,9 @@ export default function ExportDialog() { {isExporting && exportJobId && ( @@ -1074,10 +1045,10 @@ export default function ExportDialog() {
)} - {exportError && ( + {visibleExportError && (
-
{exportError}
- {lastExportJobId && ( +
{visibleExportError}
+ {canRetryExport && (