/** * Tracked-generation context + run queue. * * The SSE lifecycle (AbortController, event handlers, accumulated result) * lives in a React context mounted at the App level. That way navigating * between pages — e.g. from Text→Video's "Start Process" to the Processes * tab — doesn't unmount the component that owns the stream and doesn't * orphan the in-flight request. * * A FIFO **queue** sits on top of the single-run executor. Wizards call * `generate(...)`; if nothing is running it kicks off immediately, otherwise * the job is enqueued and the provider auto-dequeues when the current run * reaches a terminal state. This gives users "submit more work while the * first run is executing" without any extra UI on the wizards. */ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react' import type { ReactNode } from 'react' import { useGenerate } from './useGenerate' import type { GenerationState } from './useGenerate' import { useRuns } from '../store/runs' import type { Run, RunTool } from '../store/runs' import { useToast } from '../store/toast' import type { BackendRunDetail, GenerateSettings, PendingClientQueueItem } from '../api/types' import { api } from '../api/client' import type { ReplacementTargets } from '../lib/processEditHandoff' import { useSettings } from '../store/settings' type PendingMeta = Omit export type QueueItemKind = 'text' | 'html' | 'image' export interface QueueItem { id: string tool: RunTool kind: QueueItemKind inputPreview: string inputText?: string queuedAt: number settings?: GenerateSettings // payload variants — only one of these is populated per item text?: string html?: string formData?: FormData files?: File[] replaceTargets?: ReplacementTargets } export interface EnqueueResult { /** Stable queue id — clients can show this in UI or pass to `cancelQueued`. */ queueId: string /** True when this item started executing immediately (queue was empty). */ startedImmediately: boolean /** * When set, the new submission was a client-side duplicate of an already * queued or currently-running item; `queueId` points at that existing * item (so callers can still navigate to it) and no new work was * enqueued. Wizards show a toast and jump to Processes instead of * spawning a second identical run. */ duplicateOf?: 'active' | 'queued' } interface TrackedGenerationContextValue { state: GenerationState queue: QueueItem[] /** * True when the queue stopped auto-dispatching after a backend * rejection. `resumeQueue()` (or any new `enqueueX` call) clears it. */ paused: boolean /** Reason the queue is currently paused, when applicable. */ pausedReason: 'in_flight' | 'duplicate' | 'unknown' | null queueModeNotice: string | null dismissQueueModeNotice: () => void cancel: (options?: { mode?: 'now' | 'after_html' | 'after_screenshots' | 'after_pptx' | 'after_video' delete_outputs?: boolean }) => void cancelQueued: (queueId: string) => void pauseQueue: () => void /** Manually resume auto-dispatch after a 409-induced pause. */ resumeQueue: () => void reorderQueued: (queueId: string, targetQueueId: string) => void updateQueued: (queueId: string, patch: Partial>) => void reset: () => void enqueueText: ( tool: RunTool, text: string, settings: GenerateSettings, options?: { replaceTargets?: ReplacementTargets }, ) => EnqueueResult enqueueHtml: ( tool: RunTool, html: string, settings: GenerateSettings, options?: { replaceTargets?: ReplacementTargets }, ) => EnqueueResult enqueueImage: ( tool: RunTool, formData: FormData, meta: { files: File[]; settings?: GenerateSettings }, ) => EnqueueResult } const Ctx = createContext(null) let queueCounter = 0 function nextQueueId(): string { queueCounter += 1 return `queue-${Date.now().toString(36)}-${queueCounter}` } /** * Cheap deterministic fingerprint of a queue item's *meaningful* payload. * Used to short-circuit client-side duplicates BEFORE the POST reaches the * backend — the backend already blocks simultaneous duplicates via the * input_fingerprint check, but once the first run finishes the backend * happily accepts a second identical submission. A user who * re-opens the wizard and clicks Start again (or any transient form of * double-dispatch) would otherwise chain a second copy the moment the * first completes. * * djb2 on a normalized payload string. Only needs to be stable within a * single browser session, so we don't need crypto. */ function fingerprintItem(item: QueueItem): string { const settings = item.settings ? JSON.stringify(sortedEntries(item.settings as unknown as Record)) : '' let payload: string if (item.kind === 'text') { payload = `text|${item.tool}|${(item.text ?? '').trim()}|${settings}` } else if (item.kind === 'html') { payload = `html|${item.tool}|${(item.html ?? '').trim()}|${settings}` } else { const fileSig = (item.files ?? []) .map((f) => `${f.name}:${f.size}:${f.lastModified}`) .join(',') payload = `image|${item.tool}|${fileSig}|${settings}` } let hash = 5381 for (let i = 0; i < payload.length; i += 1) { hash = ((hash << 5) + hash + payload.charCodeAt(i)) | 0 } return hash.toString(36) } function sortedEntries(obj: Record): Array<[string, unknown]> { return Object.keys(obj) .sort() .map((k) => [k, obj[k]] as [string, unknown]) } function toPersistedQueueItem(item: QueueItem): PendingClientQueueItem | null { if (item.kind !== 'text' && item.kind !== 'html') return null if (item.tool !== 'text-to-video' && item.tool !== 'html-to-video') return null const payload = item.kind === 'text' ? item.text : item.html if (!payload?.trim()) return null return { id: item.id, tool: item.tool, kind: item.kind, inputPreview: item.inputPreview, inputText: item.inputText, queuedAt: item.queuedAt, settings: item.settings, text: item.kind === 'text' ? payload : undefined, html: item.kind === 'html' ? payload : undefined, } } function fromPersistedQueueItem(item: PendingClientQueueItem): QueueItem | null { const payload = item.kind === 'text' ? item.text : item.html if (!payload?.trim()) return null return { id: item.id || nextQueueId(), tool: item.tool, kind: item.kind, inputPreview: item.inputPreview || payload.slice(0, 200), inputText: item.inputText ?? payload, queuedAt: item.queuedAt || Date.now(), settings: item.settings, text: item.kind === 'text' ? payload : undefined, html: item.kind === 'html' ? payload : undefined, } } function etaFromBackendRun(run: BackendRunDetail['run']): number | undefined { const raw = run.settings?.estimated_total_seconds ?? run.metrics?.estimated_total_seconds ?? run.metrics?.eta_seconds const value = typeof raw === 'number' ? raw : Number(raw) return Number.isFinite(value) && value > 0 ? value : undefined } async function cleanupReplacementTargets(targets: ReplacementTargets): Promise { const seen = new Set() const deletions: Array> = [] const add = (type: Parameters[0], filename?: string) => { if (!filename) return const key = `${type}:${filename}` if (seen.has(key)) return seen.add(key) deletions.push(api.deleteFile(type, filename).catch(() => undefined)) } add('html', targets.htmlFilename) for (const file of targets.screenshotFiles ?? []) add('screenshot', file) add('presentation', targets.presentationFile) add('video', targets.videoFile) await Promise.all(deletions) } export function TrackedGenerationProvider({ children }: { children: ReactNode }) { const gen = useGenerate() const runs = useRuns() const toast = useToast() const { settings: appSettings } = useSettings() const runIdRef = useRef(null) const pendingRef = useRef(null) // Queue holds ONLY pending items (never the currently-executing one). // The active item is tracked separately via `activeQueueIdRef` / // `activeFingerprintRef`. Previously queue[0] was "the running one" and // the completion effect filtered it out by id — that created two racing // sources of truth (the ref claim in pushOrStart and the filter in the // terminal-state effect) and, together with React 19's concurrent // rendering, could let the same id get dispatched twice when the first // run finished. Keeping the active item out of the queue entirely // eliminates that class of races. const [queue, setQueue] = useState([]) const persistedQueueLoadedRef = useRef(false) const activeQueueIdRef = useRef(null) // Fingerprint of the currently-executing item, set by `dispatch` and // cleared on terminal-state. Used by pushOrStart to client-side-dedupe // "user resubmits the same content" before we ever POST. const activeFingerprintRef = useRef(null) const activeReplaceTargetsRef = useRef(null) // Guards against accidentally dispatching the same queue id twice // (StrictMode re-invoking effects, duplicate auto-dequeue firings, etc). const dispatchedIdsRef = useRef>(new Set()) // Set to true in `dispatch()` once we've actually fired the underlying // generate call, cleared once the run terminates. Without this flag the // auto-dequeue effect can race with `pushOrStart` and pop the just-claimed // item before the worker even started. const activeStartedRef = useRef(false) // When the backend rejects the active run with 409 the next item in the // queue would 409 too — pause auto-dispatch and surface a banner so the // user can decide. Cleared on `resumeQueue()` or the next manual enqueue. const [paused, setPaused] = useState(false) const [pausedReason, setPausedReason] = useState< 'in_flight' | 'duplicate' | 'unknown' | null >(null) const [queueModeNotice, setQueueModeNotice] = useState(null) const previousConcurrentRef = useRef(appSettings.concurrentPipelineRuns) const trackedRunInProgress = runs.runs.some((run) => run.status === 'running') useEffect(() => { const previous = previousConcurrentRef.current if (previous !== appSettings.concurrentPipelineRuns) { setQueueModeNotice( appSettings.concurrentPipelineRuns ? 'Concurrency turned on. Pending Text -> Video jobs can start in parallel while screenshot and PowerPoint stages stay gated.' : 'Concurrency turned off. Pending jobs will stay in serial order and start one at a time.', ) previousConcurrentRef.current = appSettings.concurrentPipelineRuns } }, [appSettings.concurrentPipelineRuns]) useEffect(() => { let cancelled = false void api.getPendingClientQueue() .then((response) => { if (cancelled) return const restored = response.items .map(fromPersistedQueueItem) .filter((item): item is QueueItem => !!item) if (restored.length > 0) { setQueue((prev) => { const existing = new Set(prev.map((item) => item.id)) return [...prev, ...restored.filter((item) => !existing.has(item.id))] }) } }) .catch(() => undefined) .finally(() => { if (!cancelled) persistedQueueLoadedRef.current = true }) return () => { cancelled = true } }, []) useEffect(() => { if (!persistedQueueLoadedRef.current) return const persisted = queue .map(toPersistedQueueItem) .filter((item): item is PendingClientQueueItem => !!item) void api.savePendingClientQueue(persisted).catch(() => undefined) }, [queue]) // Connect generation events → runs store. Lifts the "run row" into // existence as soon as the SSE stream reports `running`, finishes it on // terminal events. We also create the row on a *terminal* state when // `pendingRef` is still set — this catches the rare case where the POST // 409s before the SSE ever flips to running, so the run still appears // in Processes instead of vanishing silently. useEffect(() => { const s = gen.state if (s.status === 'running' && pendingRef.current && !runIdRef.current) { runIdRef.current = runs.start(pendingRef.current) pendingRef.current = null return } const terminal = s.status === 'success' || s.status === 'error' || s.status === 'cancelled' if (terminal && pendingRef.current && !runIdRef.current) { runIdRef.current = runs.start(pendingRef.current) pendingRef.current = null } if (!runIdRef.current) return if (s.status === 'running') { runs.update(runIdRef.current, { stage: s.stage, message: s.message, progress: s.progress, etaSeconds: s.etaSeconds, operationId: s.operationId, }) } if (s.status === 'success') { const replacement = activeReplaceTargetsRef.current runs.finish(runIdRef.current, { status: 'success', htmlFilename: s.result?.html_filename, screenshotFiles: s.result?.screenshot_files, screenshotFolder: s.result?.screenshot_folder, presentationFile: s.result?.presentation_file, videoFile: s.result?.video_file, operationId: s.result?.operation_id ?? s.operationId, etaSeconds: s.etaSeconds, }) if (replacement) { void cleanupReplacementTargets(replacement).then(() => { if (replacement.runId) runs.remove(replacement.runId) }) } runIdRef.current = null } else if (s.status === 'error') { runs.finish(runIdRef.current, { status: 'error', error: s.error }) runIdRef.current = null } else if (s.status === 'cancelled') { runs.finish(runIdRef.current, { status: 'cancelled' }) runIdRef.current = null } }, [gen.state, runs]) useEffect(() => { if (gen.state.status !== 'running') return let cancelled = false let wakeLock: { release: () => Promise } | null = null const requestWakeLock = async () => { try { const nav = navigator as Navigator & { wakeLock?: { request: (type: 'screen') => Promise<{ release: () => Promise }> } } if (!nav.wakeLock) return wakeLock = await nav.wakeLock.request('screen') if (cancelled) { await wakeLock.release().catch(() => undefined) wakeLock = null } } catch { /* Best effort: browser/OS can deny wake lock. */ } } void requestWakeLock() return () => { cancelled = true void wakeLock?.release().catch(() => undefined) } }, [gen.state.status]) // Dispatcher — starts a queue item's generate call. Safe to call with // any item kind; chooses the right hook under the hood. Idempotent: if // called twice with the same item id we silently ignore the second call, // which defends against double-dispatch from StrictMode / re-render // races without changing steady-state behaviour. const dispatch = useCallback( (item: QueueItem) => { if (dispatchedIdsRef.current.has(item.id)) return dispatchedIdsRef.current.add(item.id) activeQueueIdRef.current = item.id activeFingerprintRef.current = fingerprintItem(item) activeReplaceTargetsRef.current = item.replaceTargets ?? null activeStartedRef.current = true if (item.kind === 'text' && item.text !== undefined && item.settings) { pendingRef.current = { tool: item.tool, inputPreview: item.inputPreview, inputText: item.inputText ?? item.text, settings: item.settings, } void gen.generate(item.text, item.settings) } else if (item.kind === 'html' && item.html !== undefined && item.settings) { pendingRef.current = { tool: item.tool, inputPreview: item.inputPreview, inputText: item.inputText ?? item.html, settings: item.settings, } void gen.generateFromHtml(item.html, item.settings) } else if (item.kind === 'image' && item.formData) { pendingRef.current = { tool: item.tool, inputPreview: item.inputPreview, inputText: item.inputText ?? item.inputPreview, inputFiles: item.files?.map((f) => f.name), settings: item.settings, } void gen.generateFromImage(item.formData) } }, [gen], ) const dispatchBackgroundText = useCallback( (item: QueueItem): EnqueueResult => { if (item.kind !== 'text' || !item.text || !item.settings) { return { queueId: item.id, startedImmediately: false } } if (dispatchedIdsRef.current.has(item.id)) { return { queueId: item.id, startedImmediately: false, duplicateOf: 'active' } } dispatchedIdsRef.current.add(item.id) const localRunId = runs.start({ tool: item.tool, inputPreview: item.inputPreview, inputText: item.inputText ?? item.text, settings: item.settings, }) const replacement = item.replaceTargets ?? null void (async () => { try { const started = await api.startTextToVideoRun(item.text!, item.settings!) runs.update(localRunId, { operationId: started.operation_id, etaSeconds: started.estimated_total_seconds, }) let done = false while (!done) { await new Promise((resolve) => window.setTimeout(resolve, 1500)) const detail = await api.getRun(started.run_id) const run = detail.run const status = String(run.status ?? '') if (status === 'queued' || status === 'running') { runs.update(localRunId, { stage: run.stage, message: run.message, progress: run.progress, etaSeconds: etaFromBackendRun(run), operationId: run.operation_id ?? started.operation_id, }) } if (status === 'completed') { const outputs = run.outputs ?? {} runs.finish(localRunId, { status: 'success', 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 ?? started.operation_id, }) if (replacement) { await cleanupReplacementTargets(replacement) if (replacement.runId) runs.remove(replacement.runId) } done = true } else if (status === 'failed') { runs.finish(localRunId, { status: 'error', error: run.message ?? 'Process failed', operationId: run.operation_id ?? started.operation_id, }) done = true } else if (status === 'cancelled') { runs.finish(localRunId, { status: 'cancelled', operationId: run.operation_id ?? started.operation_id, }) done = true } } } catch (err) { runs.finish(localRunId, { status: 'error', error: err instanceof Error ? err.message : String(err), }) } })() return { queueId: item.id, startedImmediately: true } }, [runs], ) const canRunInBackground = useCallback( (item: QueueItem): boolean => appSettings.concurrentPipelineRuns && item.kind === 'text' && item.tool === 'text-to-video', [appSettings.concurrentPipelineRuns], ) // Auto-dequeue: when the current run terminates, pop the next queue // item and kick it off. The short delay gives React a tick to render the // "success"/"error" state before we flip back to "running" for the next // item — otherwise the UI never flashes the completion state for a run // whose successor is queued behind it. // // Guards: // 1. `activeStartedRef` — we only act on terminal states that follow a // run we actually dispatched. Without this, a fresh `pushOrStart` // committed in the same tick as a previous run's terminal state // can be clobbered here. // 2. Pause-on-rejection — when the run errored with a backend 409 we // don't auto-fire the next item; doing so would cascade-fail every // queued item with the same reason. // // Because `queue` now only contains pending items (the running one is // tracked separately via `activeQueueIdRef`), we no longer need to // filter the completed id out — `queue[0]` IS the next pending item. useEffect(() => { const s = gen.state if (s.status !== 'success' && s.status !== 'error' && s.status !== 'cancelled') { return } if (!activeStartedRef.current) return const rejected = s.status === 'error' && s.rejectedReason const rejectedReason = s.rejectedReason ?? null const timer = window.setTimeout(() => { activeStartedRef.current = false activeQueueIdRef.current = null activeFingerprintRef.current = null activeReplaceTargetsRef.current = null if (paused) return if (rejected) { setPaused(true) setPausedReason(rejectedReason ?? 'unknown') return } setQueue((prev) => { if (prev.length === 0) return prev const [next, ...rest] = prev activeQueueIdRef.current = next.id activeFingerprintRef.current = fingerprintItem(next) activeReplaceTargetsRef.current = next.replaceTargets ?? null window.setTimeout(() => dispatch(next), 0) return rest }) }, 250) return () => window.clearTimeout(timer) }, [dispatch, gen.state, paused]) // If a running backend job was restored from the process log, this hook can // be idle while the backend is still busy. Keep new submissions pending until // that tracked run finishes, then drain the queue. useEffect(() => { const idleNow = gen.state.status === 'idle' || gen.state.status === 'success' || gen.state.status === 'error' || gen.state.status === 'cancelled' if (!idleNow || paused || activeStartedRef.current || activeQueueIdRef.current) return if (!appSettings.concurrentPipelineRuns && trackedRunInProgress) return setQueue((prev) => { if (prev.length === 0) return prev const [next, ...rest] = prev activeQueueIdRef.current = next.id activeFingerprintRef.current = fingerprintItem(next) activeReplaceTargetsRef.current = next.replaceTargets ?? null window.setTimeout(() => dispatch(next), 0) return rest }) }, [ appSettings.concurrentPipelineRuns, dispatch, gen.state.status, paused, queue.length, trackedRunInProgress, ]) // When concurrency is enabled while a serial run is already active, promote // the existing pending text-to-video queue in FIFO order. New submissions // append behind these items and wait for this effect, so the last clicked // item cannot jump ahead of older queued work. useEffect(() => { if (!appSettings.concurrentPipelineRuns || paused || queue.length === 0) return setQueue((prev) => { const runnable: QueueItem[] = [] let index = 0 while (index < prev.length && canRunInBackground(prev[index])) { runnable.push(prev[index]) index += 1 } if (runnable.length === 0) return prev const rest = prev.slice(index) runnable.forEach((item, offset) => { window.setTimeout(() => dispatchBackgroundText(item), offset * 25) }) return rest }) }, [ appSettings.concurrentPipelineRuns, canRunInBackground, dispatchBackgroundText, paused, queue.length, ]) const pushOrStart = useCallback( (item: QueueItem): EnqueueResult => { // Any new manual submission resumes a paused queue — we assume the // user has waited out / handled whatever caused the previous 409. if (paused) { setPaused(false) setPausedReason(null) } // Client-side duplicate guard. Blocks the common "user resubmits the // same content while it's already running/queued" path so we don't // silently chain a second identical run the moment the first // completes. Match against both the active fingerprint and every // pending queue entry. const fp = fingerprintItem(item) if (activeFingerprintRef.current === fp && activeQueueIdRef.current) { toast.push({ variant: 'info', title: 'Already running', message: 'This exact content is already being processed — opening its progress view.', }) return { queueId: activeQueueIdRef.current, startedImmediately: false, duplicateOf: 'active', } } const existing = queue.find((q) => fingerprintItem(q) === fp) if (existing) { toast.push({ variant: 'info', title: 'Already queued', message: 'This exact content is already queued — opening its entry in Processes.', }) return { queueId: existing.id, startedImmediately: false, duplicateOf: 'queued', } } const idleNow = gen.state.status === 'idle' || gen.state.status === 'success' || gen.state.status === 'error' || gen.state.status === 'cancelled' const backendSlotBusy = !appSettings.concurrentPipelineRuns && trackedRunInProgress if (canRunInBackground(item) && !idleNow) { if (queue.length === 0) { return dispatchBackgroundText(item) } setQueue((prev) => [...prev, item]) return { queueId: item.id, startedImmediately: false } } if (idleNow && !activeQueueIdRef.current && !backendSlotBusy) { // Claim the active slot *synchronously* so a rapid second submission // in the same tick doesn't also see `activeQueueIdRef` as empty and // race to dispatch. The item is NOT appended to `queue` — queue is // for pending-only items now; the running one lives in the refs. activeQueueIdRef.current = item.id activeFingerprintRef.current = fp activeReplaceTargetsRef.current = item.replaceTargets ?? null window.setTimeout(() => dispatch(item), 0) return { queueId: item.id, startedImmediately: true } } setQueue((prev) => [...prev, item]) return { queueId: item.id, startedImmediately: false } }, [ appSettings.concurrentPipelineRuns, canRunInBackground, dispatch, dispatchBackgroundText, gen.state.status, paused, queue, toast, trackedRunInProgress, ], ) const enqueueText = useCallback( ( tool: RunTool, text: string, settings: GenerateSettings, options?: { replaceTargets?: ReplacementTargets }, ): EnqueueResult => pushOrStart({ id: nextQueueId(), tool, kind: 'text', inputPreview: text.slice(0, 200), inputText: text, queuedAt: Date.now(), text, settings, replaceTargets: options?.replaceTargets, }), [pushOrStart], ) const enqueueHtml = useCallback( ( tool: RunTool, html: string, settings: GenerateSettings, options?: { replaceTargets?: ReplacementTargets }, ): EnqueueResult => pushOrStart({ id: nextQueueId(), tool, kind: 'html', inputPreview: html.slice(0, 200), inputText: html, queuedAt: Date.now(), html, settings, replaceTargets: options?.replaceTargets, }), [pushOrStart], ) const enqueueImage = useCallback( ( tool: RunTool, formData: FormData, meta: { files: File[]; settings?: GenerateSettings }, ): EnqueueResult => pushOrStart({ id: nextQueueId(), tool, kind: 'image', inputPreview: meta.files.length ? meta.files.map((f) => f.name).join(', ') : '(image/pdf)', inputText: meta.files.length ? meta.files.map((f) => f.name).join('\n') : '(image/pdf)', queuedAt: Date.now(), formData, files: meta.files, settings: meta.settings, }), [pushOrStart], ) const cancelQueued = useCallback((queueId: string) => { // `queue` only contains pending items now, so a plain filter is safe — // no risk of accidentally yanking the in-flight item out of under the // dispatcher. setQueue((prev) => prev.filter((q) => q.id !== queueId)) }, []) const pauseQueue = useCallback(() => { setPaused(true) setPausedReason(null) }, []) const resumeQueue = useCallback(() => { setPaused(false) setPausedReason(null) // Pull the next queued item (if any) and fire it. We re-use the // dispatch path so all the pendingRef / runIdRef bookkeeping is // identical to a fresh submission. setQueue((prev) => { if (activeQueueIdRef.current) return prev if (gen.state.status === 'running') return prev if (!appSettings.concurrentPipelineRuns && trackedRunInProgress) return prev if (prev.length === 0) return prev const [next, ...rest] = prev activeQueueIdRef.current = next.id activeFingerprintRef.current = fingerprintItem(next) activeReplaceTargetsRef.current = next.replaceTargets ?? null window.setTimeout(() => dispatch(next), 0) return rest }) }, [appSettings.concurrentPipelineRuns, dispatch, gen.state.status, trackedRunInProgress]) const dismissQueueModeNotice = useCallback(() => { setQueueModeNotice(null) }, []) const reorderQueued = useCallback((queueId: string, targetQueueId: string) => { if (queueId === targetQueueId) return setQueue((prev) => { const from = prev.findIndex((q) => q.id === queueId) const to = prev.findIndex((q) => q.id === targetQueueId) if (from < 0 || to < 0) return prev const next = prev.slice() const [item] = next.splice(from, 1) next.splice(to, 0, item) return next }) }, []) const updateQueued = useCallback( (queueId: string, patch: Partial>) => { setQueue((prev) => prev.map((item) => { if (item.id !== queueId) return item const text = patch.text ?? item.text const html = patch.html ?? item.html return { ...item, ...patch, inputText: item.kind === 'html' ? html ?? item.inputText : text ?? item.inputText, inputPreview: item.kind === 'html' ? (html ?? item.inputPreview).slice(0, 200) : (text ?? item.inputPreview).slice(0, 200), } }), ) }, [], ) const value = useMemo( () => ({ state: gen.state, queue, paused, pausedReason, queueModeNotice, dismissQueueModeNotice, cancel: gen.cancel, cancelQueued, pauseQueue, resumeQueue, reorderQueued, updateQueued, reset: gen.reset, enqueueText, enqueueHtml, enqueueImage, }), [ gen.state, gen.cancel, gen.reset, queue, paused, pausedReason, queueModeNotice, dismissQueueModeNotice, cancelQueued, pauseQueue, resumeQueue, reorderQueued, updateQueued, enqueueText, enqueueHtml, enqueueImage, ], ) return {children} } // eslint-disable-next-line react-refresh/only-export-components export function useTrackedGenerate(tool: RunTool) { const ctx = useContext(Ctx) if (!ctx) { throw new Error('useTrackedGenerate must be used inside ') } return useMemo( () => ({ state: ctx.state, queue: ctx.queue, cancel: ctx.cancel, cancelQueued: ctx.cancelQueued, pauseQueue: ctx.pauseQueue, resumeQueue: ctx.resumeQueue, reorderQueued: ctx.reorderQueued, updateQueued: ctx.updateQueued, reset: ctx.reset, // Back-compat aliases so existing wizards keep working verbatim: all // three are now enqueues and return void (ignored by old callers). generate: ( text: string, settings: GenerateSettings, options?: { replaceTargets?: ReplacementTargets }, ) => ctx.enqueueText(tool, text, settings, options), generateFromHtml: ( html: string, settings: GenerateSettings, options?: { replaceTargets?: ReplacementTargets }, ) => ctx.enqueueHtml(tool, html, settings, options), generateFromImage: ( fd: FormData, meta?: { files?: File[]; settings?: GenerateSettings }, ) => ctx.enqueueImage(tool, fd, { files: meta?.files ?? [], settings: meta?.settings }), }), [ctx, tool], ) } // eslint-disable-next-line react-refresh/only-export-components export function useGenerationQueue() { const ctx = useContext(Ctx) if (!ctx) { throw new Error('useGenerationQueue must be used inside ') } return { queue: ctx.queue, cancelQueued: ctx.cancelQueued, cancel: ctx.cancel, pauseQueue: ctx.pauseQueue, state: ctx.state, paused: ctx.paused, pausedReason: ctx.pausedReason, queueModeNotice: ctx.queueModeNotice, dismissQueueModeNotice: ctx.dismissQueueModeNotice, resumeQueue: ctx.resumeQueue, reorderQueued: ctx.reorderQueued, updateQueued: ctx.updateQueued, enqueueText: ctx.enqueueText, enqueueHtml: ctx.enqueueHtml, } }