YT-AI-Automation / frontend /src /pages /Processes.tsx
github-actions
Sync Docker Space
5f3e9f5
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<Run> {
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<string, { label: string; icon: typeof FileText }> = {
'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<string, string> = {
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 (
<span className="badge-running">
<Loader2 size={12} className="animate-spin" /> Running
</span>
)
}
if (status === 'error') {
return (
<span className="badge-error">
<XCircle size={12} /> Failed
</span>
)
}
if (status === 'cancelled') {
return (
<span className="badge-warning">
<XCircle size={12} /> Cancelled
</span>
)
}
return (
<span className="badge-success">
<CheckCircle2 size={12} /> Done
</span>
)
}
function useNow(enabled: boolean, tickMs = 1000): number {
const [now, setNow] = useState<number>(() => 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 (
<div className="mt-1.5 flex items-center gap-1" aria-label="Pipeline stages">
{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 (
<div
key={seg.key}
title={seg.label}
className={
'flex h-1.5 min-w-0 flex-1 items-center justify-center rounded-full transition-colors ' +
(failed
? 'bg-rose-500/80'
: active
? 'bg-brand-500 animate-pulse'
: done
? 'bg-brand-500/70'
: 'bg-slate-200 dark:bg-white/[0.08]')
}
/>
)
})}
</div>
)
}
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<string | null>(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<HTMLDivElement | null>(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 (
<div
ref={rowRef}
className={
selected
? 'glass overflow-hidden !p-0 ring-2 ring-brand-400 dark:ring-brand-500/60'
: highlight
? 'glass overflow-hidden !p-0 ring-2 ring-brand-400 dark:ring-brand-500/60'
: 'glass overflow-hidden !p-0'
}
>
<button
type="button"
className="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-slate-50 dark:hover:bg-white/[0.03]"
onClick={() => {
if (run.status === 'running') onSelectRunning?.(run)
setUserOpen((o) => !o)
}}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-500/10 dark:text-brand-300">
<Icon size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-display text-sm font-semibold text-slate-900 dark:text-slate-50">
{meta.label}
</span>
<StatusBadge status={run.status} />
<span className="text-xs text-slate-500 dark:text-slate-400">
{formatRelative(run.startedAt, now)}
</span>
</div>
<p className="mt-1 truncate text-sm text-slate-600 dark:text-slate-300">
{run.inputPreview || '(no input)'}
</p>
{run.status === 'running' && (
<div className="mt-2 flex items-center gap-2 text-[11px] font-medium text-slate-500 dark:text-slate-400">
<span className="min-w-0 flex-1 truncate">
{stageStatusLabel(run.stage)}
{run.message ? ` - ${run.message}` : ''}
</span>
<span className="shrink-0 tabular-nums">
{Math.round(progress)}%
</span>
</div>
)}
<StageStrip
stage={run.stage}
status={run.status}
outputFormat={run.settings?.output_format}
/>
</div>
<div className="hidden w-40 shrink-0 text-right sm:block">
<div className="flex items-center justify-end gap-1.5 text-sm font-medium text-slate-700 dark:text-slate-200">
<Clock size={14} /> {formatRuntime(runtime)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{etaRemainingMs != null
? `~${formatRuntime(etaRemainingMs)} remaining`
: `${run.screenshotFiles?.length ?? 0} screenshot${run.screenshotFiles?.length === 1 ? '' : 's'}`}
</div>
</div>
</button>
{open && (
<div className="space-y-4 border-t border-slate-200 px-5 py-4 dark:border-white/10">
<div className="grid gap-4 md:grid-cols-3">
<Section title="Input">
<div className="rounded-md border border-slate-200 bg-slate-50 dark:border-white/10 dark:bg-white/[0.03]">
<div className="flex items-center justify-between gap-2 border-b border-slate-200 px-3 py-2 dark:border-white/10">
<span className="truncate text-xs font-medium text-slate-600 dark:text-slate-300">
{inputText.length.toLocaleString()} characters
</span>
<button
type="button"
className="btn-ghost btn-sm"
onClick={copyInput}
disabled={!inputText}
>
{copiedInput ? <Check size={12} /> : <Copy size={12} />}
{copiedInput ? 'Copied' : 'Copy all'}
</button>
</div>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] text-slate-700 dark:text-slate-200">
{inputText || '(empty)'}
</pre>
</div>
{run.inputFiles && run.inputFiles.length > 0 && (
<ul className="mt-2 space-y-1 text-xs text-slate-600 dark:text-slate-300">
{run.inputFiles.map((f) => (
<li key={f}>· {f}</li>
))}
</ul>
)}
</Section>
<Section title="Runtime">
<KV label="Started" value={new Date(run.startedAt).toLocaleString()} />
<KV
label="Ended"
value={run.endedAt ? new Date(run.endedAt).toLocaleString() : '—'}
/>
{run.status === 'running' && run.message && (
<KV label="Current" value={run.message} />
)}
{run.status === 'running' && (
<KV label="Stage" value={stageStatusLabel(run.stage)} />
)}
{run.status === 'running' && run.progress != null && (
<KV label="Progress" value={`${Math.round(run.progress)}%`} />
)}
{etaRemainingMs != null && (
<KV label="Estimated left" value={`~${formatRuntime(etaRemainingMs)}`} />
)}
<KV label="Duration" value={formatRuntime(runtime)} />
{run.settings?.model_choice && (
<KV label="Model" value={run.settings.model_choice} />
)}
{run.settings?.output_format && (
<KV label="Output format" value={String(run.settings.output_format)} />
)}
{(run.settings?.class_name || run.settings?.subject || run.settings?.title) && (
<KV
label="Project"
value={[run.settings?.class_name, run.settings?.subject, run.settings?.title]
.filter(Boolean)
.join(' · ')}
/>
)}
{run.settings && (
<KV
label="Viewport"
value={`${run.settings.viewport_width ?? '—'}×${run.settings.viewport_height ?? '—'}`}
/>
)}
{run.settings?.zoom != null && <KV label="Zoom" value={`${run.settings.zoom}×`} />}
</Section>
<Section title="Output">
{run.status === 'error' && run.error && (
<p className="text-sm text-red-600 dark:text-red-300">{run.error}</p>
)}
{run.htmlFilename && (
<button
type="button"
onClick={() => setHtmlPreview(true)}
className="block max-w-full truncate text-left text-xs text-brand-600 hover:underline dark:text-brand-300"
title={run.htmlFilename}
>
HTML · {run.htmlFilename}
</button>
)}
{run.videoFile && (
<a
href={api.downloadUrl(run.videoFile)}
className="block truncate text-xs text-brand-600 hover:underline dark:text-brand-300"
title={run.videoFile}
>
MP4 - {run.videoFile}
</a>
)}
{run.presentationFile && (
<a
href={api.downloadUrl(run.presentationFile)}
className="block truncate text-xs text-brand-600 hover:underline dark:text-brand-300"
title={run.presentationFile}
>
PPTX - {run.presentationFile}
</a>
)}
<KV
label="Screenshots"
value={`${run.screenshotFiles?.length ?? 0}`}
/>
{run.operationId && (
<KV label="Op ID" value={<code className="text-[10px]">{run.operationId}</code>} />
)}
</Section>
</div>
{(run.videoFile || (hasOutputs && run.screenshotFiles && run.screenshotFiles.length > 0)) && (
<div className="grid gap-3 lg:grid-cols-2">
{run.videoFile && (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-white/[0.03]">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Film size={16} className="shrink-0 text-brand-500" />
<div className="min-w-0">
<div className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Video
</div>
<div className="truncate text-xs text-slate-600 dark:text-slate-300">
{run.videoFile}
</div>
</div>
</div>
<button type="button" className="btn-secondary btn-sm" onClick={() => setVideoPreview(true)}>
Maximize
</button>
</div>
<button
type="button"
onClick={() => setVideoPreview(true)}
className="block aspect-video w-full overflow-hidden rounded-md bg-black"
title="Open video preview"
>
<video
src={api.downloadUrl(run.videoFile)}
preload="metadata"
muted
className="h-full w-full object-contain"
/>
</button>
<a href={api.downloadUrl(run.videoFile)} className="btn-secondary btn-sm mt-2 w-full">
<Download size={12} /> Download MP4
</a>
</div>
)}
{hasOutputs && run.screenshotFiles && run.screenshotFiles.length > 0 && (
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Screenshots
</div>
{selectedScreenshotUrl && (
<button
type="button"
className="btn-secondary btn-sm"
onClick={() => setPreview(selectedScreenshotUrl)}
>
Preview
</button>
)}
</div>
{selectedScreenshotUrl && (
<button
type="button"
onClick={() => setPreview(selectedScreenshotUrl)}
className="mb-3 block aspect-video w-full overflow-hidden rounded-md border border-slate-200 bg-white text-left dark:border-white/10 dark:bg-slate-950"
title="Open screenshot preview"
>
<img
src={selectedScreenshotUrl}
alt={selectedScreenshot ?? 'Screenshot'}
className="h-full w-full object-contain"
/>
</button>
)}
<div className="grid grid-cols-6 gap-1">
{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 (
<button
key={f}
type="button"
onClick={() => openScreenshot(index)}
className={
index === previewIndex
? 'block aspect-video overflow-hidden rounded border border-brand-400 bg-brand-50 text-left ring-1 ring-brand-400 dark:border-brand-400 dark:bg-brand-500/10'
: 'block aspect-video overflow-hidden rounded border border-slate-200 bg-slate-50 text-left dark:border-white/10 dark:bg-white/[0.03]'
}
title={`Preview ${f.split('/').pop() ?? f}`}
>
<img src={url} alt={f} loading="lazy" className="h-full w-full object-cover" />
</button>
)
})}
</div>
</div>
)}
</div>
)}
{preview && selectedScreenshotUrl && (
<AssetPreviewModal
kind="image"
src={preview}
title={selectedScreenshot?.split('/').pop() ?? 'Screenshot'}
subtitle={`${previewIndex + 1} of ${screenshots.length}`}
onClose={() => setPreview(null)}
onPrevious={screenshots.length > 1 ? () => movePreview(-1) : undefined}
onNext={screenshots.length > 1 ? () => movePreview(1) : undefined}
/>
)}
{htmlPreview && run.htmlFilename && (
<AssetPreviewModal
kind="html"
src={api.htmlUrl(run.htmlFilename)}
title={run.htmlFilename.split('/').pop() ?? run.htmlFilename}
subtitle="HTML file"
onClose={() => setHtmlPreview(false)}
/>
)}
{videoPreview && run.videoFile && (
<AssetPreviewModal
kind="video"
src={api.downloadUrl(run.videoFile)}
title={run.videoFile.split('/').pop() ?? 'Video'}
onClose={() => setVideoPreview(false)}
/>
)}
{onRemove && (
<div className="flex flex-wrap justify-end gap-2">
{canRegenerate && (
<>
<button className="btn-secondary btn-sm" onClick={() => onEditRegenerate?.(run)}>
<Pencil size={12} /> Edit
</button>
<button className="btn-primary btn-sm" onClick={() => onRegenerate?.(run)}>
<RefreshCw size={12} /> Regenerate
</button>
</>
)}
<button className="btn-ghost text-xs" onClick={() => onRemove(run.id)}>
<Trash2 size={12} /> Remove from log
</button>
</div>
)}
</div>
)}
</div>
)
}
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 (
<div className="glass flex items-center gap-4 !py-4">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-500 dark:bg-white/[0.05] dark:text-slate-300">
<Icon size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-display text-sm font-semibold text-slate-900 dark:text-slate-50">
{meta.label}
</span>
<StatusBadge status="completed" />
{(entry.datetime || entry.timestamp) && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{entry.datetime ?? formatHistoryTimestamp(entry.timestamp)}
</span>
)}
</div>
<p className="mt-1 truncate text-sm text-slate-600 dark:text-slate-300">
{entry.input_preview || '(no input recorded)'}
</p>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
{entry.screenshot_count ?? 0} screenshots · {entry.html_file ?? '—'}
</p>
</div>
{entry.html_file && (
<a
href={api.htmlUrl(entry.html_file)}
target="_blank"
rel="noreferrer"
className="btn-secondary hidden shrink-0 sm:inline-flex"
>
<Code2 size={14} /> HTML
</a>
)}
{entry.video_file && (
<a
href={api.downloadUrl(entry.video_file)}
className="btn-secondary hidden shrink-0 sm:inline-flex"
>
MP4
</a>
)}
{entry.presentation_file && (
<a
href={api.downloadUrl(entry.presentation_file)}
className="btn-secondary hidden shrink-0 sm:inline-flex"
>
PPTX
</a>
)}
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{title}
</div>
<div className="space-y-1.5 text-sm">{children}</div>
</div>
)
}
function KV({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3 text-xs">
<span className="text-slate-500 dark:text-slate-400">{label}</span>
<span className="truncate text-right text-slate-700 dark:text-slate-200">{value}</span>
</div>
)
}
function LiveRunCard({
liveState,
trackedRun,
onCancel,
}: {
liveState: ReturnType<typeof useGenerationQueue>['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 (
<div className="card ring-2 ring-brand-400/40 dark:ring-brand-500/40">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-400 opacity-75"></span>
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-brand-500"></span>
</span>
<div className="font-display text-sm font-semibold text-slate-900 dark:text-slate-50">
Running
</div>
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-semibold text-brand-700 dark:bg-brand-500/10 dark:text-brand-200">
{stageStatusLabel(stage)}
</span>
{operationId && (
<code className="text-[10px] text-slate-500 dark:text-slate-400">
{operationId}
</code>
)}
</div>
{isRunning && (
<button type="button" className="btn-danger" onClick={onCancel}>
<StopCircle size={14} /> Cancel run
</button>
)}
</div>
<ProgressBar
progress={progress ?? 0}
stage={stage}
message={message}
etaSeconds={etaSeconds}
active
/>
</div>
)
}
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(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-white/10 dark:bg-slate-950">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-50">Cancel process?</h2>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">
Choose whether to stop immediately or let the current step finish first.
</p>
</div>
<button type="button" className="btn-ghost btn-sm" onClick={onClose} aria-label="Close">
<X size={14} />
</button>
</div>
<div className="mt-4 rounded-md border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600 dark:border-white/10 dark:bg-white/[0.03] dark:text-slate-300">
<div className="font-medium text-slate-800 dark:text-slate-100">{run.inputPreview || run.id}</div>
<div className="mt-1">{stageStatusLabel(run.stage)}{run.message ? ` - ${run.message}` : ''}</div>
</div>
<div className="mt-4 space-y-3">
<button
type="button"
className="btn-secondary w-full justify-start"
onClick={() => onCancelAfterStep(soft.mode)}
>
<FileText size={14} /> {soft.label}
</button>
<p className="-mt-2 px-1 text-xs text-slate-500 dark:text-slate-400">{soft.detail}</p>
<div className="rounded-md border border-rose-200 bg-rose-50 p-3 dark:border-rose-500/30 dark:bg-rose-500/10">
<button
type="button"
className="btn-danger w-full justify-start"
onClick={() => onCancelNow(deleteOutputs)}
>
<StopCircle size={14} /> Cancel now
</button>
<button
type="button"
className="mt-2 flex items-center gap-2 text-left text-xs font-medium text-rose-800 dark:text-rose-100"
onClick={() => setDeleteOutputs((v) => !v)}
>
<span className={`flex h-4 w-4 items-center justify-center rounded border ${deleteOutputs ? 'border-rose-600 bg-rose-600 text-white' : 'border-rose-300 bg-white dark:bg-slate-950'}`}>
{deleteOutputs && <Check size={12} />}
</span>
Delete all generated data for this process
</button>
</div>
</div>
</div>
</div>,
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 (
<div className="card">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ListOrdered size={16} className="text-slate-500" />
<div className="font-display text-sm font-semibold text-slate-900 dark:text-slate-50">
Queue
</div>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600 dark:bg-white/[0.05] dark:text-slate-300">
{items.length} pending
</span>
</div>
<button type="button" className="btn-secondary btn-sm" onClick={paused ? onResume : onPause}>
{paused ? <Play size={12} /> : <Pause size={12} />}
{paused ? 'Resume' : 'Pause'}
</button>
</div>
<ul className="space-y-2">
{items.map((q, idx) => {
const meta = toolMeta(q.tool)
const Icon = meta.icon
return (
<li
key={q.id}
draggable
onDragStart={(event) => 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]"
>
<GripVertical size={14} className="shrink-0 cursor-grab text-slate-400" />
<span className="shrink-0 rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-semibold text-slate-600 dark:bg-white/10 dark:text-slate-300">
{idx === 0 ? 'Next' : `Position ${idx + 1}`}
</span>
<Icon size={14} className="shrink-0 text-slate-500" />
<span className="shrink-0 text-xs font-medium text-slate-700 dark:text-slate-200">
{meta.label}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-slate-600 dark:text-slate-300">
{q.inputPreview || '(no preview)'}
</span>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => onEditQueued(q)}
title="Edit queued item"
disabled={q.kind === 'image'}
>
<Pencil size={12} /> Edit
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => onCancelQueued(q.id)}
title="Remove from queue"
>
<X size={12} /> Remove
</button>
</li>
)
})}
</ul>
</div>
)
}
function ProcessEditModal({
process,
onClose,
onSave,
}: {
process: EditableProcess
onClose: () => void
onSave: (process: EditableProcess) => void
}) {
const [text, setText] = useState(process.text)
const [settings, setSettings] = useState<GenerateSettings>(process.settings)
const dialogRef = useFocusTrap<HTMLDivElement>(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 = <K extends keyof GenerateSettings>(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(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-950/75 backdrop-blur-sm" onClick={onClose} aria-hidden />
<div
ref={dialogRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label={process.title}
className="glass-strong relative flex max-h-[92vh] w-full max-w-3xl flex-col overflow-hidden"
>
<div className="flex items-center justify-between gap-3 border-b border-slate-200 px-4 py-3 dark:border-white/10">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
{process.title}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{toolMeta(process.tool).label}
</div>
</div>
<button type="button" className="btn-ghost !px-2" onClick={onClose} aria-label="Close editor">
<X size={16} />
</button>
</div>
<div className="space-y-4 overflow-auto p-4">
<label className="block">
<span className="label">Input</span>
<textarea
className="textarea h-56 resize-y font-mono text-xs"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</label>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="label">Class</span>
<input className="input" value={settings.class_name ?? ''} onChange={(e) => set('class_name', e.target.value)} />
</label>
<label className="block">
<span className="label">Subject</span>
<input className="input" value={settings.subject ?? ''} onChange={(e) => set('subject', e.target.value)} />
</label>
<label className="block">
<span className="label">Title</span>
<input className="input" value={settings.title ?? ''} onChange={(e) => set('title', e.target.value)} />
</label>
<label className="block">
<span className="label">Output</span>
<select className="select" value={settings.output_format ?? 'images'} onChange={(e) => set('output_format', e.target.value as GenerateSettings['output_format'])}>
<option value="html">HTML</option>
<option value="images">Screenshots</option>
<option value="pptx">PowerPoint</option>
<option value="video">MP4 video</option>
</select>
</label>
<label className="block">
<span className="label">Model</span>
<select className="select" value={settings.model_choice ?? 'default'} onChange={(e) => set('model_choice', e.target.value)}>
<option value="default">Default</option>
<option value="fast">Fast</option>
<option value="short">Short</option>
<option value="balanced">Balanced</option>
<option value="quality">Quality</option>
<option value="long">Long context</option>
</select>
</label>
<label className="block">
<span className="label">Zoom</span>
<input className="input" type="number" step={0.1} value={settings.zoom ?? ''} onChange={(e) => set('zoom', numberValue(e.target.value))} />
</label>
<label className="block">
<span className="label">Width</span>
<input className="input" type="number" value={settings.viewport_width ?? ''} onChange={(e) => set('viewport_width', numberValue(e.target.value))} />
</label>
<label className="block">
<span className="label">Height</span>
<input className="input" type="number" value={settings.viewport_height ?? ''} onChange={(e) => set('viewport_height', numberValue(e.target.value))} />
</label>
<label className="block">
<span className="label">Max screenshots</span>
<input className="input" type="number" value={settings.max_screenshots ?? ''} onChange={(e) => set('max_screenshots', numberValue(e.target.value))} />
</label>
</div>
<label className="block">
<span className="label">System prompt</span>
<textarea
className="textarea h-24 resize-y"
value={settings.system_prompt ?? ''}
onChange={(e) => set('system_prompt', e.target.value)}
/>
</label>
</div>
<div className="flex justify-end gap-2 border-t border-slate-200 px-4 py-3 dark:border-white/10">
<button type="button" className="btn-ghost" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="btn-primary"
onClick={() => onSave({ ...process, text, settings })}
disabled={!text.trim()}
>
{process.mode === 'queue' ? <Check size={14} /> : <RefreshCw size={14} />}
{process.mode === 'queue' ? 'Save changes' : 'Regenerate'}
</button>
</div>
</div>
</div>,
document.body,
)
}
export default function Processes() {
const nav = useNavigate()
const { runs, clear, remove, update, finish } = useRuns()
const { settings: appSettings } = useSettings()
const {
queue,
cancelQueued,
cancel: cancelLive,
state: liveState,
paused: queuePaused,
pausedReason: queuePausedReason,
queueModeNotice,
dismissQueueModeNotice,
pauseQueue,
resumeQueue,
reorderQueued,
updateQueued,
enqueueText,
enqueueHtml,
} = useGenerationQueue()
const [searchParams] = useSearchParams()
const highlightOp = searchParams.get('op')
const highlightQueue = searchParams.get('queue')
const [cache, setCache] = useState<CacheStats | null>(null)
const [loading, setLoading] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [filter, setFilter] = useState<'all' | RunTool>('all')
const [editingProcess, setEditingProcess] = useState<EditableProcess | null>(null)
const [cancelTarget, setCancelTarget] = useState<Run | null>(null)
const [selectedRunId, setSelectedRunId] = useState<string | null>(() => readSelectedProcessId())
const toast = useToast()
const confirmDialog = useConfirm()
const runsRef = useRef(runs)
const recoveredTerminalRefs = useRef<Set<string>>(new Set())
useEffect(() => {
runsRef.current = runs
}, [runs])
useEffect(() => {
const syncSelected = () => setSelectedRunId(readSelectedProcessId())
window.addEventListener(SELECTED_PROCESS_EVENT, syncSelected)
window.addEventListener('storage', syncSelected)
return () => {
window.removeEventListener(SELECTED_PROCESS_EVENT, syncSelected)
window.removeEventListener('storage', syncSelected)
}
}, [])
const refresh = useCallback(async () => {
setLoading(true)
setErr(null)
try {
const c = await api.cacheStats()
setCache(c)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
const t = setTimeout(() => {
void refresh()
}, 0)
return () => clearTimeout(t)
}, [refresh])
// D1: Server-Sent Events drive backend state in real-time. We subscribe
// to /runs/<id>/events for every active operationId we know about and
// mirror events into the local runs store. A low-frequency fallback poll
// keeps stale terminal rows in sync (e.g. after an app restart, when
// events were missed entirely).
const sseHandlesRef = useRef(new Map<string, AbortController>())
useEffect(() => {
let stopped = false
const applyBackendDetail = (
localRun: Run,
backendRun: BackendRunDetail['run'],
nextOperationId: string,
) => {
const backendStatus = String(backendRun.status ?? '')
if (backendStatus === 'completed') {
recoveredTerminalRefs.current.add(localRun.id)
finish(localRun.id, {
status: 'success',
...trackedOutputsFromBackendRun(backendRun, nextOperationId),
stage: 'complete',
message: backendRun.message,
progress: 100,
})
} else if (backendStatus === 'failed') {
recoveredTerminalRefs.current.add(localRun.id)
finish(localRun.id, {
status: 'error',
error: backendRun.message ?? 'Process failed',
operationId: nextOperationId,
stage: backendRun.stage,
message: backendRun.message,
progress: backendRun.progress ?? 100,
})
} else if (backendStatus === 'cancelled') {
recoveredTerminalRefs.current.add(localRun.id)
finish(localRun.id, {
status: 'cancelled',
operationId: nextOperationId,
stage: backendRun.stage ?? 'cancelled',
message: backendRun.message ?? 'Cancelled',
progress: backendRun.progress ?? 100,
})
} else if (backendStatus === 'queued' || backendStatus === 'running') {
update(localRun.id, {
status: 'running',
operationId: nextOperationId,
stage: backendRun.stage,
message: backendRun.message,
progress: backendRun.progress,
etaSeconds: trackedOutputsFromBackendRun(backendRun, nextOperationId).etaSeconds,
})
}
}
const subscribeToRun = (localRunId: string, operationId: string) => {
// Already subscribed?
if (sseHandlesRef.current.has(operationId)) return
const ctrl = new AbortController()
sseHandlesRef.current.set(operationId, ctrl)
void api
.streamRunEvents(operationId, {
signal: ctrl.signal,
onEvent: (ev) => {
if (stopped) return
switch (ev.type) {
case 'queued':
update(localRunId, {
status: 'running',
stage: 'queued',
message: ev.message,
progress: ev.progress ?? 0,
})
break
case 'started':
case 'progress':
update(localRunId, {
status: 'running',
stage: ev.type === 'progress' ? ev.stage : ev.stage ?? 'running',
message: ev.message,
progress:
ev.type === 'progress' ? ev.progress : ev.progress ?? 0,
etaSeconds:
ev.type === 'progress' ? ev.eta_seconds : ev.estimated_total_seconds,
})
break
case 'complete':
case 'error':
case 'cancelled':
// SSE only carries summary fields. Fall back to one detail
// fetch so we capture all output filenames.
void (async () => {
try {
const detail = await api.getRun(operationId)
if (stopped) return
const localRun = runsRef.current.find((r) => r.id === localRunId)
if (localRun) {
applyBackendDetail(localRun, detail.run, detail.run.operation_id ?? operationId)
}
} catch {
// ignore — fallback poll will cover it
}
})()
break
default:
break
}
},
})
.catch(() => {
// Network drop / abort — let the fallback poll re-establish on next tick.
})
.finally(() => {
sseHandlesRef.current.delete(operationId)
})
}
const reconcileSubscriptions = () => {
const wanted = new Set<string>()
const now = Date.now()
for (const r of runsRef.current) {
if (!r.operationId) continue
if (r.status === 'running') {
wanted.add(r.operationId)
subscribeToRun(r.id, r.operationId)
} else if (
(r.status === 'cancelled' || r.status === 'error') &&
!recoveredTerminalRefs.current.has(r.id) &&
now - r.startedAt < 2 * 60 * 60_000
) {
wanted.add(r.operationId)
subscribeToRun(r.id, r.operationId)
}
}
// Cancel any subscriptions for ops we no longer care about.
for (const [opId, ctrl] of sseHandlesRef.current) {
if (!wanted.has(opId)) {
ctrl.abort()
sseHandlesRef.current.delete(opId)
}
}
}
// Slow fallback poll (15s) so stale terminal-but-recoverable rows still
// get caught even if the SSE stream drops or the run finished before we
// managed to subscribe.
const fallbackSync = async () => {
const now = Date.now()
const candidates = runsRef.current.filter((r) => {
if (!r.operationId) return false
if (r.status === 'running') return true
if (recoveredTerminalRefs.current.has(r.id)) return false
return (
(r.status === 'cancelled' || r.status === 'error') &&
now - r.startedAt < 2 * 60 * 60_000
)
})
await Promise.all(
candidates.map(async (localRun) => {
const operationId = localRun.operationId
if (!operationId) return
try {
const detail = await api.getRun(operationId)
if (stopped) return
applyBackendDetail(
localRun,
detail.run,
detail.run.operation_id ?? operationId,
)
} catch {
// Try again on the next tick.
}
}),
)
}
reconcileSubscriptions()
void fallbackSync()
const reconcileId = window.setInterval(reconcileSubscriptions, 2_000)
const fallbackId = window.setInterval(fallbackSync, 15_000)
const handles = sseHandlesRef.current
return () => {
stopped = true
window.clearInterval(reconcileId)
window.clearInterval(fallbackId)
for (const [, ctrl] of handles) ctrl.abort()
handles.clear()
}
}, [finish, update])
const runRows = useMemo(() => {
const filtered = filter === 'all' ? runs : runs.filter((r) => r.tool === filter)
// Dedupe history entries against the tracked runs. Match on any of
// (operation_id, html_file, or input_preview + tight time window) —
// the backend now emits operation_id on history entries (primary key)
// but older entries only have html_file, and a very recently completed
// run may briefly have neither populated on the tracked side. The
// input+timestamp fuzzy match is the fallback that was missing in #7.
const runSeenOpIds = new Set(
runs.map((r) => r.operationId).filter(Boolean) as string[],
)
const runSeenHtml = new Set(runs.map((r) => r.htmlFilename).filter(Boolean) as string[])
const runFingerprints = runs
.map((r) => ({
preview: (r.inputPreview || '').slice(0, 120),
endedAt: r.endedAt ?? r.startedAt,
}))
.filter((r) => r.preview)
const remainingHistory = ([] as HistoryEntry[])
.filter((h) => {
if (h.operation_id && runSeenOpIds.has(h.operation_id)) return false
if (h.html_file && runSeenHtml.has(h.html_file)) return false
if (h.input_preview && h.timestamp) {
const hPreview = String(h.input_preview).slice(0, 120)
const hTsMs = typeof h.timestamp === 'number'
? h.timestamp * 1000
: Date.parse(String(h.timestamp))
if (!Number.isNaN(hTsMs)) {
const match = runFingerprints.find(
(rf) => rf.preview === hPreview && Math.abs(rf.endedAt - hTsMs) < 5 * 60_000,
)
if (match) return false
}
}
return true
})
.filter((h) => {
if (filter === 'all') return true
const t = h.tool
// Accept both the legacy backend labels (`text-to-image`) and the
// newer ones (`text-to-video`) so history entries show up under
// their matching filter regardless of which codepath produced them.
if (filter === 'text-to-video') return t === 'text-to-image' || t === 'text-to-video'
if (filter === 'html-to-video') return t === 'html-to-image' || t === 'html-to-video'
if (filter === 'image-to-video') return t === 'image-to-screenshots' || t === 'image-to-video'
if (filter === 'screenshots-to-video') return t === 'screenshots-to-video'
return false
})
.slice()
.reverse()
void remainingHistory
return filtered
}, [runs, filter])
const clearCache = async () => {
const ok = await confirmDialog({
title: 'Clear the AI response cache?',
message: 'Subsequent generations will hit the AI provider again until the cache warms up.',
confirmLabel: 'Clear cache',
variant: 'danger',
})
if (!ok) return
try {
await api.clearCache()
await refresh()
toast.push({ variant: 'success', message: 'AI response cache cleared.' })
} catch (e) {
toast.push({
variant: 'error',
title: 'Clear cache failed',
message: e instanceof Error ? e.message : String(e),
})
}
}
const queueEditorForItem = (item: QueueItem) => {
if (item.kind === 'image') {
toast.push({ variant: 'info', message: 'Image jobs need their original uploaded file, so edit is unavailable.' })
return
}
setEditingProcess({
id: item.id,
title: 'Edit queued process',
tool: item.tool,
kind: item.kind,
text: item.kind === 'html' ? item.html ?? item.inputText ?? '' : item.text ?? item.inputText ?? '',
settings: item.settings ?? {},
mode: 'queue',
})
}
const regenerateRun = (run: Run, override?: { text: string; settings: GenerateSettings }) => {
const text = override?.text ?? run.inputText ?? run.inputPreview ?? ''
const settings = toGenerateSettings(override?.settings ?? run.settings)
if (!text.trim()) {
toast.push({ variant: 'error', message: 'This process has no saved input to regenerate.' })
return
}
if (run.tool === 'html-to-video') {
enqueueHtml(run.tool, text, settings)
} else if (run.tool === 'text-to-video') {
enqueueText(run.tool, text, settings)
} else if (run.tool === 'screenshots-to-video') {
toast.push({ variant: 'error', message: 'Screenshots → Video processes need their original uploads, so regenerate is unavailable.' })
return
} else {
toast.push({ variant: 'error', message: 'Image processes cannot be regenerated after the original file is gone.' })
return
}
toast.push({ variant: 'success', message: 'Process queued for regeneration.' })
}
const editRegenerateRun = (run: Run) => {
const text = run.inputText ?? run.inputPreview ?? ''
if (!text.trim()) {
toast.push({ variant: 'error', message: 'This process has no saved input to edit.' })
return
}
if (run.tool === 'image-to-video') {
toast.push({ variant: 'error', message: 'Image processes cannot be edited after the original file is gone.' })
return
}
if (run.tool === 'screenshots-to-video') {
toast.push({ variant: 'error', message: 'Screenshots → Video processes cannot be edited after the originals are gone.' })
return
}
window.sessionStorage.setItem(PROCESS_EDIT_HANDOFF_KEY, JSON.stringify({
tool: run.tool,
text,
settings: toGenerateSettings(run.settings),
replaceTargets: {
runId: run.id,
htmlFilename: run.htmlFilename,
screenshotFiles: run.screenshotFiles ?? [],
presentationFile: run.presentationFile,
videoFile: run.videoFile,
},
}))
nav(run.tool === 'html-to-video' ? '/workspace/html' : '/workspace/text')
}
const saveEditedProcess = (process: EditableProcess) => {
if (process.mode === 'queue') {
updateQueued(process.id, {
text: process.kind === 'text' ? process.text : undefined,
html: process.kind === 'html' ? process.text : undefined,
settings: process.settings,
})
toast.push({ variant: 'success', message: 'Queued process updated.' })
} else {
regenerateRun(
{
id: process.id,
tool: process.tool,
status: 'success',
startedAt: Date.now(),
inputPreview: process.text.slice(0, 200),
inputText: process.text,
settings: process.settings,
},
{ text: process.text, settings: process.settings },
)
}
setEditingProcess(null)
}
// D9: per-tool count badges so the user can see distribution at a glance.
const filterCounts = useMemo(() => {
const counts: Record<'all' | RunTool, number> = {
all: runs.length,
'text-to-video': 0,
'html-to-video': 0,
'image-to-video': 0,
'screenshots-to-video': 0,
}
for (const r of runs) counts[r.tool] += 1
return counts
}, [runs])
const filters: Array<{ key: 'all' | RunTool; label: string }> = [
{ key: 'all', label: 'All' },
{ key: 'text-to-video', label: 'Text' },
{ key: 'html-to-video', label: 'HTML' },
{ key: 'image-to-video', label: 'Image' },
{ key: 'screenshots-to-video', label: 'Screenshots' },
]
const totalRuntime = runs
.filter((r) => r.endedAt)
.reduce((sum, r) => sum + (r.endedAt! - r.startedAt), 0)
const runningRuns = runs.filter((r) => r.status === 'running')
const currentRun =
runningRuns.find((r) => r.id === selectedRunId || r.operationId === selectedRunId) ??
runningRuns[0]
const selectRunningRun = (run: Run) => {
writeSelectedProcessId(run.id)
setSelectedRunId(run.id)
}
const requestCancelRun = async (mode: 'now' | SoftCancelMode, deleteOutputs = false) => {
const run = cancelTarget ?? currentRun
if (!run) return
setCancelTarget(null)
const targetId = run.operationId ?? run.id
update(run.id, {
status: 'running',
stage: 'cancelling',
message:
mode !== 'now'
? 'Cancellation requested. Waiting for the current step to finish...'
: 'Cancellation requested. Waiting for the running step to stop.',
})
try {
if (liveState.status === 'running' && run.operationId && run.operationId === liveState.operationId) {
cancelLive({ mode, delete_outputs: deleteOutputs })
} else {
await api.cancelRun(targetId, { mode, delete_outputs: deleteOutputs })
}
toast.push({
variant: 'success',
message: mode !== 'now' ? 'Process will stop after the current step finishes.' : 'Cancellation requested.',
})
} catch (e) {
toast.push({
variant: 'error',
title: 'Cancel failed',
message: e instanceof Error ? e.message : String(e),
})
}
}
return (
<div className="container-page space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="eyebrow">
<span className="h-1 w-1 rounded-full bg-brand-500" />
Activity
</div>
<h1 className="h-page mt-2">Processes</h1>
<p className="mt-2 text-sm text-muted">
Every generation run, its input, how long it took, and the files it produced — all
in one place.
</p>
</div>
<div className="flex gap-2">
<button className="btn-secondary" onClick={refresh} disabled={loading}>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} /> Refresh
</button>
<button className="btn-secondary" onClick={clearCache}>
<Database size={16} /> Clear AI cache
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Tracked runs" value={runs.length} />
<Stat
label="Succeeded"
value={runs.filter((r) => r.status === 'success').length}
/>
<Stat
label="Total runtime"
value={totalRuntime > 0 ? formatRuntime(totalRuntime) : '—'}
/>
<Stat
label="Cache entries"
value={
typeof cache?.total_entries === 'number'
? String(cache.total_entries)
: typeof cache?.active_entries === 'number'
? String(cache.active_entries)
: '—'
}
/>
</div>
<LiveRunCard
liveState={liveState}
trackedRun={currentRun}
onCancel={() => currentRun && setCancelTarget(currentRun)}
/>
{cancelTarget && (
<CancelRunDialog
run={cancelTarget}
onClose={() => setCancelTarget(null)}
onCancelAfterStep={(mode) => void requestCancelRun(mode)}
onCancelNow={(deleteOutputs) => void requestCancelRun('now', deleteOutputs)}
/>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-1.5">
{filters.map((f) => {
const count = filterCounts[f.key]
const active = filter === f.key
return (
<button
key={f.key}
type="button"
onClick={() => setFilter(f.key)}
className={
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors ' +
(active
? 'border-brand-200 bg-brand-50 text-brand-700 dark:border-brand-500/30 dark:bg-brand-500/10 dark:text-brand-200'
: 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-white/10 dark:bg-white/[0.03] dark:text-slate-300')
}
aria-label={`${f.label} (${count})`}
>
<span>{f.label}</span>
<span
className={
'inline-flex min-w-[1.25rem] items-center justify-center rounded-full px-1.5 text-[10px] font-semibold tabular-nums ' +
(active
? 'bg-brand-500/15 text-brand-700 dark:bg-brand-400/20 dark:text-brand-100'
: 'bg-slate-100 text-slate-500 dark:bg-white/10 dark:text-slate-300')
}
>
{count}
</span>
</button>
)
})}
</div>
{runs.length > 0 && (
<button
className="btn-ghost text-xs"
onClick={async () => {
const ok = await confirmDialog({
title: 'Clear the local process log?',
message: 'Only your browser-local list of runs is cleared. Backend history is not affected.',
confirmLabel: 'Clear log',
})
if (ok) clear()
}}
>
<Trash2 size={12} /> Clear log
</button>
)}
</div>
{err && <div className="card text-sm text-red-600 dark:text-red-300">{err}</div>}
{(queueModeNotice || queue.length > 0) && (
<Banner
tone="info"
icon={<Activity size={16} />}
title={appSettings.concurrentPipelineRuns ? 'Concurrent queue mode' : 'Serial queue mode'}
actions={
queueModeNotice && (
<button
type="button"
className="btn-ghost btn-sm shrink-0 self-center"
onClick={dismissQueueModeNotice}
>
<X size={12} /> Dismiss
</button>
)
}
>
{queueModeNotice ??
(appSettings.concurrentPipelineRuns
? 'Pending Text -> Video jobs can start in parallel; screenshot and PowerPoint stages still wait for their slots.'
: 'Pending jobs will run one at a time in the visible queue order.')}
</Banner>
)}
{queuePaused && (
<Banner
tone="warning"
icon={<Pause size={16} />}
title="Queue paused"
actions={
<button
type="button"
className="btn-secondary btn-sm shrink-0 self-center"
onClick={resumeQueue}
disabled={queue.length === 0}
>
Resume queue
</button>
}
>
{queuePausedReason === 'in_flight'
? 'The previous run was rejected because another run is already in progress on the backend. Resuming would just hit the same 409 — wait for the active run to finish, then resume.'
: queuePausedReason === 'duplicate'
? 'The previous run was rejected as a duplicate of a recent submission. Tweak the input or wait a few seconds before resuming.'
: queuePausedReason === 'unknown'
? 'The previous run was rejected by the backend. Investigate before resuming.'
: 'Pending jobs will wait here until you resume the queue.'}
</Banner>
)}
{/* `queue` now contains pending-only items (the currently-executing
run is tracked separately and appears as a tracked run row above),
so we render the full queue rather than `slice(1)`. */}
<QueueCard
items={queue}
paused={queuePaused}
onPause={pauseQueue}
onResume={resumeQueue}
onCancelQueued={cancelQueued}
onEditQueued={queueEditorForItem}
onReorderQueued={reorderQueued}
/>
{runRows.length === 0 && queue.length === 0 && liveState.status !== 'running' ? (
<EmptyState
icon={<Activity size={20} />}
title="No runs yet"
description="Generated jobs land here with input, runtime, and outputs side-by-side."
action={
<a className="btn-primary btn-sm" href="/workspace">
Start your first run
</a>
}
/>
) : (
<div className="space-y-3">
{runRows.map((r) => (
<RunRow
key={r.id}
run={r}
onRemove={remove}
onRegenerate={regenerateRun}
onEditRegenerate={editRegenerateRun}
onSelectRunning={selectRunningRun}
selected={
r.status === 'running' &&
!!currentRun &&
(r.id === currentRun.id || r.operationId === currentRun.operationId)
}
highlight={
(!!highlightOp &&
(r.operationId === highlightOp || r.id === highlightOp)) ||
// Highlight the newest running row when we landed via
// /processes?queue=the queue id is transient, so we key
// off status+recency instead of a direct match.
(!!highlightQueue &&
r.status === 'running' &&
r === runRows.find((x) => x.status === 'running'))
}
/>
))}
</div>
)}
{editingProcess && (
<ProcessEditModal
process={editingProcess}
onClose={() => setEditingProcess(null)}
onSave={saveEditedProcess}
/>
)}
</div>
)
}
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="glass !p-4">
<div className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{label}</div>
<div className="mt-1 font-display text-2xl font-semibold text-slate-900 dark:text-slate-50">
{value}
</div>
</div>
)
}