import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useNavigate, useSearchParams } from 'react-router-dom' import { Activity, Check, CheckCircle2, Clock, Code2, Copy, Database, Download, FileText, Film, GripVertical, ImageIcon, ListOrdered, Loader2, Pause, Pencil, Play, RefreshCw, StopCircle, Trash2, Wand2, X, XCircle, } from 'lucide-react' import { api } from '../api/client' import type { BackendRunDetail, CacheStats, GenerateSettings, HistoryEntry } from '../api/types' import { formatRelative, formatRuntime, useRuns } from '../store/runs' import type { Run, RunStatus, RunTool } from '../store/runs' import { useToast } from '../store/toast' import { useConfirm } from '../components/ConfirmDialog' import AssetPreviewModal from '../components/AssetPreviewModal' import Banner from '../components/Banner' import EmptyState from '../components/EmptyState' import ProgressBar from '../components/ProgressBar' import { useGenerationQueue } from '../hooks/useTrackedGenerate' import type { QueueItem } from '../hooks/useTrackedGenerate' import { useFocusTrap } from '../hooks/useFocusTrap' import { PROCESS_EDIT_HANDOFF_KEY } from '../lib/processEditHandoff' import { useSettings } from '../store/settings' import { readSelectedProcessId, SELECTED_PROCESS_EVENT, writeSelectedProcessId, } from '../lib/selectedProcess' type ToolLike = RunTool | 'regenerate' | 'text-to-image' | 'html-to-image' | 'image-to-screenshots' | string | undefined type EditableProcess = { id: string title: string tool: RunTool kind: 'text' | 'html' text: string settings: GenerateSettings mode: 'queue' | 'regenerate' } function trackedOutputsFromBackendRun( run: BackendRunDetail['run'], fallbackOperationId: string, ): Partial { const outputs = run.outputs ?? {} const rawEta = run.settings?.estimated_total_seconds ?? run.metrics?.estimated_total_seconds ?? run.metrics?.eta_seconds const etaSeconds = typeof rawEta === 'number' ? rawEta : Number(rawEta) return { htmlFilename: outputs.html_filename ?? outputs.html_file, screenshotFiles: outputs.screenshot_files ?? [], screenshotFolder: outputs.screenshot_folder, presentationFile: outputs.presentation_file ?? outputs.presentation_path, videoFile: outputs.video_file ?? outputs.video_path, operationId: run.operation_id ?? fallbackOperationId, etaSeconds: Number.isFinite(etaSeconds) && etaSeconds > 0 ? etaSeconds : undefined, } } const TOOL_META: Record = { 'text-to-video': { label: 'Text → Video', icon: FileText }, 'text-to-image': { label: 'Text → Video', icon: FileText }, 'html-to-video': { label: 'HTML → Video', icon: Code2 }, 'html-to-image': { label: 'HTML → Video', icon: Code2 }, 'image-to-video': { label: 'Image → Video', icon: ImageIcon }, 'image-to-screenshots': { label: 'Image → Video', icon: ImageIcon }, 'screenshots-to-video': { label: 'Screenshots → Video', icon: ImageIcon }, regenerate: { label: 'Regenerate', icon: Wand2 }, } function toolMeta(tool: ToolLike) { return TOOL_META[tool ?? ''] ?? { label: tool ?? 'Run', icon: Activity } } const STAGE_STATUS_LABELS: Record = { queued: 'Waiting in backend queue', running: 'Running', ai_waiting: 'Waiting for AI slot', ai: 'Generating HTML', html_saved: 'HTML saved', screenshot_waiting: 'Waiting for screenshot slot', screenshot: 'Capturing screenshots', screenshots_done: 'Screenshots ready', export_waiting: 'Waiting for PowerPoint export', powerpoint_cleanup: 'Closing PowerPoint', powerpoint_resume: 'Exporting from saved PPTX', powerpoint: 'Building PowerPoint export', pptx_built: 'PowerPoint deck saved', video_export: 'Exporting MP4', video_export_done: 'MP4 export finished', complete: 'Complete', cancelling: 'Cancelling', } function stageStatusLabel(stage?: string): string { if (!stage) return 'Working' return STAGE_STATUS_LABELS[stage] ?? stage.replace(/_/g, ' ') } function toGenerateSettings(settings: Run['settings'] | GenerateSettings | undefined): GenerateSettings { const raw = settings ?? {} const { resolution, ...rest } = raw const next: GenerateSettings = { ...rest } if (['720p', '1080p', '1440p', '4k'].includes(String(resolution))) { next.resolution = resolution as GenerateSettings['resolution'] } return next } function StatusBadge({ status }: { status: RunStatus | 'completed' }) { if (status === 'running') { return ( Running ) } if (status === 'error') { return ( Failed ) } if (status === 'cancelled') { return ( Cancelled ) } return ( Done ) } function useNow(enabled: boolean, tickMs = 1000): number { const [now, setNow] = useState(() => Date.now()) useEffect(() => { if (!enabled) return const id = setInterval(() => setNow(Date.now()), tickMs) return () => clearInterval(id) }, [enabled, tickMs]) return now } /** * D6: 5-segment stage strip — AI → Render → Screenshot → PPTX → MP4. Each * segment lights up as the run reaches that pipeline phase. Compact * enough to sit on the row above the progress bar. */ type StageSegment = { key: 'ai' | 'render' | 'screenshot' | 'pptx' | 'mp4' label: string } const PIPELINE_SEGMENTS: StageSegment[] = [ { key: 'ai', label: 'AI' }, { key: 'render', label: 'Render' }, { key: 'screenshot', label: 'Screenshot' }, { key: 'pptx', label: 'PPTX' }, { key: 'mp4', label: 'MP4' }, ] function stageToSegmentIndex(stage: string | undefined): number { if (!stage) return -1 const s = stage.toLowerCase() if (s.startsWith('ai')) return 0 if (s === 'init' || s === 'queued' || s === 'running') return 0 if (s === 'html_saved') return 1 if (s.startsWith('screenshot')) return 2 if (s.startsWith('powerpoint') || s === 'pptx' || s.startsWith('export')) return 3 if (s.startsWith('video') || s === 'mp4') return 4 if (s === 'complete' || s === 'screenshots_done') return 4 return -1 } function StageStrip({ stage, status, outputFormat, }: { stage: string | undefined status: Run['status'] outputFormat: string | undefined }) { const reached = stageToSegmentIndex(stage) // Trim segments that the run won't ever reach so the strip doesn't // pretend to have progress past the user's chosen output. ('html' caps // at Render; 'images' caps at Screenshot; 'pptx' caps at PPTX.) const cap = (() => { switch ((outputFormat ?? '').toLowerCase()) { case 'html': return 1 case 'images': return 2 case 'pptx': return 3 default: return 4 } })() const segments = PIPELINE_SEGMENTS.slice(0, cap + 1) return (
{segments.map((seg, i) => { const done = status === 'success' || status === 'cancelled' ? i <= reached || status === 'success' : i < reached const active = status === 'running' && i === reached const failed = status === 'error' && i === reached return (
) })}
) } function RunRow({ run, onRemove, onRegenerate, onEditRegenerate, onSelectRunning, selected = false, highlight = false, }: { run: Run onRemove?: (id: string) => void onRegenerate?: (run: Run) => void onEditRegenerate?: (run: Run) => void onSelectRunning?: (run: Run) => void selected?: boolean highlight?: boolean }) { const meta = toolMeta(run.tool) const Icon = meta.icon const now = useNow(!run.endedAt) const runtime = (run.endedAt ?? now) - run.startedAt const etaRemainingMs = run.status === 'running' && typeof run.etaSeconds === 'number' && run.etaSeconds > 0 ? Math.max(0, (run.etaSeconds * 1000) - runtime) : null const [userOpen, setUserOpen] = useState(false) // Derive `open` from (user click || highlight prop) so we don't need to // setState from an effect just because the prop flipped. const open = userOpen || highlight const progress = Math.max(0, Math.min(100, run.progress ?? 0)) const [preview, setPreview] = useState(null) const [videoPreview, setVideoPreview] = useState(false) const [htmlPreview, setHtmlPreview] = useState(false) const [previewIndex, setPreviewIndex] = useState(0) const [copiedInput, setCopiedInput] = useState(false) const toast = useToast() const scrolled = useRef(false) const rowRef = useRef(null) useEffect(() => { if (highlight && rowRef.current && !scrolled.current) { rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }) scrolled.current = true } }, [highlight]) const hasOutputs = (run.screenshotFiles?.length ?? 0) > 0 || !!run.htmlFilename || !!run.presentationFile || !!run.videoFile const inputText = run.inputText || run.inputPreview || '' const canRegenerate = run.status !== 'running' && run.tool !== 'image-to-video' && run.tool !== 'screenshots-to-video' && inputText.trim().length > 0 const screenshots = run.screenshotFiles ?? [] const selectedScreenshot = screenshots[previewIndex] const selectedScreenshotUrl = selectedScreenshot ? api.screenshotUrl(selectedScreenshot) : null const copyInput = async (event: React.MouseEvent) => { event.stopPropagation() if (!inputText) return try { await navigator.clipboard.writeText(inputText) setCopiedInput(true) toast.push({ variant: 'success', message: 'Input copied to clipboard.' }) window.setTimeout(() => setCopiedInput(false), 1500) } catch (e) { toast.push({ variant: 'error', title: 'Copy failed', message: e instanceof Error ? e.message : String(e), }) } } const openScreenshot = (index: number) => { setPreviewIndex(index) setPreview(api.screenshotUrl(screenshots[index])) } const movePreview = (direction: -1 | 1) => { if (screenshots.length === 0) return const next = (previewIndex + direction + screenshots.length) % screenshots.length setPreviewIndex(next) setPreview(api.screenshotUrl(screenshots[next])) } return (
{open && (
{inputText.length.toLocaleString()} characters
                  {inputText || '(empty)'}
                
{run.inputFiles && run.inputFiles.length > 0 && (
    {run.inputFiles.map((f) => (
  • · {f}
  • ))}
)}
{run.status === 'running' && run.message && ( )} {run.status === 'running' && ( )} {run.status === 'running' && run.progress != null && ( )} {etaRemainingMs != null && ( )} {run.settings?.model_choice && ( )} {run.settings?.output_format && ( )} {(run.settings?.class_name || run.settings?.subject || run.settings?.title) && ( )} {run.settings && ( )} {run.settings?.zoom != null && }
{run.status === 'error' && run.error && (

{run.error}

)} {run.htmlFilename && ( )} {run.videoFile && ( MP4 - {run.videoFile} )} {run.presentationFile && ( PPTX - {run.presentationFile} )} {run.operationId && ( {run.operationId}} /> )}
{(run.videoFile || (hasOutputs && run.screenshotFiles && run.screenshotFiles.length > 0)) && (
{run.videoFile && (
Video
{run.videoFile}
Download MP4
)} {hasOutputs && run.screenshotFiles && run.screenshotFiles.length > 0 && (
Screenshots
{selectedScreenshotUrl && ( )}
{selectedScreenshotUrl && ( )}
{run.screenshotFiles.slice(0, 12).map((f, index) => { // `f` is already a path relative to OUTPUT_FOLDER // (e.g. "5(1).png" or "batch 3/5(1).png"). Do NOT prepend // screenshotFolder — that double-prefixed the path and // silently fell back to a basename walk that could pick // the wrong batch. const url = api.screenshotUrl(f) return ( ) })}
)}
)} {preview && selectedScreenshotUrl && ( setPreview(null)} onPrevious={screenshots.length > 1 ? () => movePreview(-1) : undefined} onNext={screenshots.length > 1 ? () => movePreview(1) : undefined} /> )} {htmlPreview && run.htmlFilename && ( setHtmlPreview(false)} /> )} {videoPreview && run.videoFile && ( setVideoPreview(false)} /> )} {onRemove && (
{canRegenerate && ( <> )}
)}
)}
) } function formatHistoryTimestamp(ts: number | string | undefined): string { if (ts == null) return '' const num = typeof ts === 'number' ? ts : Number(ts) if (Number.isFinite(num)) { return new Date(num * 1000).toLocaleString() } return String(ts) } export function HistoryRow({ entry }: { entry: HistoryEntry }) { const meta = toolMeta(entry.tool) const Icon = meta.icon return (
{meta.label} {(entry.datetime || entry.timestamp) && ( {entry.datetime ?? formatHistoryTimestamp(entry.timestamp)} )}

{entry.input_preview || '(no input recorded)'}

{entry.screenshot_count ?? 0} screenshots · {entry.html_file ?? '—'}

{entry.html_file && ( HTML )} {entry.video_file && ( MP4 )} {entry.presentation_file && ( PPTX )}
) } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
{title}
{children}
) } function KV({ label, value }: { label: string; value: React.ReactNode }) { return (
{label} {value}
) } function LiveRunCard({ liveState, trackedRun, onCancel, }: { liveState: ReturnType['state'] trackedRun?: Run onCancel: () => void }) { const hasLiveState = liveState.status === 'running' const source = trackedRun ?? (hasLiveState ? liveState : undefined) if (!source) return null // The cancel button used to be gated on the SSE `liveState` matching the // tracked run's operationId. That hid the button whenever the user opened // the page after a run had already started (because liveState only fires // when the SSE channel attaches). The cancel handler itself already falls // back to the REST `cancelRun` endpoint when liveState doesn't match, so // we can show the button whenever there is an active run to cancel. const isRunning = (trackedRun?.status ?? liveState.status) === 'running' const operationId = trackedRun?.operationId ?? liveState.operationId const progress = trackedRun?.progress ?? liveState.progress ?? 0 const stage = trackedRun?.stage ?? liveState.stage const message = trackedRun?.message ?? liveState.message const now = useNow(Boolean(source)) const trackedRemainingSeconds = trackedRun && typeof trackedRun.etaSeconds === 'number' && trackedRun.etaSeconds > 0 ? Math.max(0, trackedRun.etaSeconds - ((trackedRun.endedAt ?? now) - trackedRun.startedAt) / 1000) : undefined // Only use the SSE `liveState.etaSeconds` if it actually corresponds to // the run we're displaying. Otherwise its ETA is for a different run. const liveMatchesTracked = hasLiveState && (!trackedRun || trackedRun.operationId === liveState.operationId) const etaSeconds = trackedRemainingSeconds ?? (liveMatchesTracked ? liveState.etaSeconds : undefined) return (
Running
{stageStatusLabel(stage)} {operationId && ( {operationId} )}
{isRunning && ( )}
) } type SoftCancelMode = 'after_html' | 'after_screenshots' | 'after_pptx' | 'after_video' function softCancelOption(stage?: string): { mode: SoftCancelMode; label: string; detail: string } { const s = String(stage || '').toLowerCase() if (s.includes('video_export') || s === 'powerpoint_resume') { return { mode: 'after_video', label: 'Cancel after MP4 export finishes', detail: 'The current PowerPoint video export will be allowed to finish.', } } if (s.includes('screenshot') || s === 'html_saved') { return { mode: 'after_screenshots', label: 'Cancel after screenshots finish', detail: 'The HTML and captured screenshot files will be kept.', } } if (s.includes('powerpoint') || s.includes('export_waiting')) { return { mode: 'after_pptx', label: 'Cancel after PPTX is made', detail: 'The PowerPoint file will be kept and MP4 export will not start.', } } return { mode: 'after_html', label: 'Cancel after HTML finishes', detail: 'The generated HTML file will be kept.', } } function CancelRunDialog({ run, onClose, onCancelNow, onCancelAfterStep, }: { run: Run onClose: () => void onCancelNow: (deleteOutputs: boolean) => void onCancelAfterStep: (mode: SoftCancelMode) => void }) { const [deleteOutputs, setDeleteOutputs] = useState(false) const soft = softCancelOption(run.stage) return createPortal(

Cancel process?

Choose whether to stop immediately or let the current step finish first.

{run.inputPreview || run.id}
{stageStatusLabel(run.stage)}{run.message ? ` - ${run.message}` : ''}

{soft.detail}

, document.body, ) } function QueueCard({ items, paused, onPause, onResume, onCancelQueued, onEditQueued, onReorderQueued, }: { items: QueueItem[] paused: boolean onPause: () => void onResume: () => void onCancelQueued: (id: string) => void onEditQueued: (item: QueueItem) => void onReorderQueued: (sourceId: string, targetId: string) => void }) { if (items.length === 0) return null return (
Queue
{items.length} pending
    {items.map((q, idx) => { const meta = toolMeta(q.tool) const Icon = meta.icon return (
  • event.dataTransfer.setData('text/plain', q.id)} onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault() const sourceId = event.dataTransfer.getData('text/plain') if (sourceId) onReorderQueued(sourceId, q.id) }} className="flex items-center gap-3 rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-sm dark:border-white/10 dark:bg-white/[0.03]" > {idx === 0 ? 'Next' : `Position ${idx + 1}`} {meta.label} {q.inputPreview || '(no preview)'}
  • ) })}
) } function ProcessEditModal({ process, onClose, onSave, }: { process: EditableProcess onClose: () => void onSave: (process: EditableProcess) => void }) { const [text, setText] = useState(process.text) const [settings, setSettings] = useState(process.settings) const dialogRef = useFocusTrap(true) useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } window.addEventListener('keydown', onKey) const prev = document.body.style.overflow document.body.style.overflow = 'hidden' return () => { window.removeEventListener('keydown', onKey) document.body.style.overflow = prev } }, [onClose]) const set = (key: K, value: GenerateSettings[K]) => { setSettings((prev) => ({ ...prev, [key]: value })) } const numberValue = (value: unknown): number | undefined => { const n = Number(value) return Number.isFinite(n) ? n : undefined } return createPortal(
{process.title}
{toolMeta(process.tool).label}