YT-AI-Automation / frontend /src /hooks /useTrackedGenerate.tsx
github-actions
Sync Docker Space
5f3e9f5
/**
* 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<Run, 'id' | 'status' | 'startedAt'>
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<Pick<QueueItem, 'text' | 'html' | 'settings'>>) => 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<TrackedGenerationContextValue | null>(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<string, unknown>))
: ''
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<string, unknown>): 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<void> {
const seen = new Set<string>()
const deletions: Array<Promise<unknown>> = []
const add = (type: Parameters<typeof api.deleteFile>[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<string | null>(null)
const pendingRef = useRef<PendingMeta | null>(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<QueueItem[]>([])
const persistedQueueLoadedRef = useRef(false)
const activeQueueIdRef = useRef<string | null>(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<string | null>(null)
const activeReplaceTargetsRef = useRef<ReplacementTargets | null>(null)
// Guards against accidentally dispatching the same queue id twice
// (StrictMode re-invoking effects, duplicate auto-dequeue firings, etc).
const dispatchedIdsRef = useRef<Set<string>>(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<string | null>(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<void> } | null = null
const requestWakeLock = async () => {
try {
const nav = navigator as Navigator & {
wakeLock?: { request: (type: 'screen') => Promise<{ release: () => Promise<void> }> }
}
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<Pick<QueueItem, 'text' | 'html' | 'settings'>>) => {
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<TrackedGenerationContextValue>(
() => ({
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 <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
// 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 <TrackedGenerationProvider>')
}
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 <TrackedGenerationProvider>')
}
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,
}
}