From ba2c2e57ca6dfb5f6f9d29284abd250654d8c04a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 23 Jul 2026 15:35:24 +0000 Subject: [PATCH] jobs: modal creates jobs for non-inline tasks (Run/Queue -> POST /jobs -> /jobs/:id) Co-Authored-By: Claude Opus 4.8 --- docs/jobs-unification.md | 10 ++- .../components/TaskRunnerModal.tsx | 77 ++++++++++++++----- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/docs/jobs-unification.md b/docs/jobs-unification.md index d1c6d84e..e4f3780b 100644 --- a/docs/jobs-unification.md +++ b/docs/jobs-unification.md @@ -80,11 +80,13 @@ Favor power-user affordances over guardrails. See memory `sole-user-assume-compe (left, polls `GET /jobs`, highlights active) + a detail panel (right) that branches by `mode` — `ScriptJobDetail` terminal (polls log + status, Stop) or `PipelineJobDetail`. One page serves both `/jobs` and `/jobs/:id`; list click navigates. Replaced the old separate list/detail pages. - - [ ] 3b `/jobs/new` page — extract the input UI (TaskInputForm + per-group config + folder probing) - from `TaskRunnerModal` into a shared component; Run/Queue → `POST /jobs` → navigate. - [x] `inline` flag plumbed (parser + list/detail endpoints); 3 quick tasks flagged. - - [ ] 3c FileBrowser task action: `task.inline ? openModal : navigate('/jobs/new?...')`. Modal stays - for inline tasks (not retired); shared input form used by both. + - [x] 3b/3c task→job via the modal (pragmatic reuse). The task modal is now the job creator: on Run, + an **inline** task runs ephemerally in-modal; a **non-inline** task `POST /jobs` (start) → + navigates to `/jobs/:id`. When a job is already running, a red "Run now" + a "Queue" button + (queue → `/jobs`). Reuses the modal's per-group input UI in place — no separate `/jobs/new` + page or FileBrowser change needed. *(A standalone deep-linkable `/jobs/new` is deferred; the + phone creates jobs directly via `POST /jobs`.)* - [ ] 3d header job indicators — two always-present badges next to the avatar + reload-tasks: (1) **running** count (0/1) → links to the running job's `/jobs/:id`; (2) **queued** count → links to the queue (`/jobs`). Backed by a lightweight diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx index 4691de1b..628cbca7 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -745,7 +745,12 @@ type ScriptRunnerProps = { const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePath, selectedNames }: ScriptRunnerProps) => { const runner = useTaskRunner(); const client = useClient(); + const navigate = useNavigate(); const files = useFilesAPI('home'); + // Inline tasks (quick/interactive) run ephemerally in this modal; everything else becomes a job. + const [inline, setInline] = useState(false); + // Is a job already running? (drives the Run vs Queue affordance). + const [jobRunning, setJobRunning] = useState(false); const bottomRef = useRef(null); const [inputDefs, setInputDefs] = useState | null>(null); const [formValues, setFormValues] = useState>({}); @@ -771,9 +776,10 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa // Fetch task detail to get input definitions useEffect(() => { client - .get<{ inputs?: Record; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean } }>(`/tasks/${taskDirName}`) + .get<{ inline?: boolean; inputs?: Record; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean } }>(`/tasks/${taskDirName}`) .then((task) => { const defs: Record = task.inputs ?? {}; + setInline(task.inline === true); setFolderKeepAll(task.config?.folderKeepAll === true); setPerGroupTracks(task.config?.perGroupTracks === true); setPerGroupAllFiles(task.config?.perGroupAllFiles === true); @@ -900,31 +906,42 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa (d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks', ); + // Non-inline tasks become jobs — check if one is already running so we can offer Queue. + useEffect(() => { + if (inline) return; + client.get>('/jobs?live=1').then((live) => setJobRunning(live.length > 0)).catch(() => {}); + }, [inline]); + // Which per-group pickers this task declares, and whether the current selection is real work. const has = pickerKinds(inputDefs); const perGroupHasWork = perGroupTracks && entryType === 'directory' && buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles).length > 0; - const handleRun = () => { - // Per-group config: serialize each layout's selection to $INPUT_GROUP_CONFIG (JSON), so a single - // run handles every group. For track-only edits, no-op groups are left out; convert keeps all. + // Collect the final input map (per-group config / include list / keep-all overrides all fold in here). + const buildAllInputs = (): Record => { if (perGroupTracks && entryType === 'directory') { const config = buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles); - const allInputs = { ...formValues, ...(config.length ? { group_config: JSON.stringify(config) } : {}), ...autoInputs }; - runner.run(taskDirName, allInputs, cwd); - return; + return { ...formValues, ...(config.length ? { group_config: JSON.stringify(config) } : {}), ...autoInputs }; } - // "Keep every track" mode: clear track selections (empty = keep all) and convert everything. const overrides: Record = {}; if (keepAll && inputDefs) { for (const [key, def] of Object.entries(inputDefs)) { if (def.type === 'audio_tracks' || def.type === 'subtitle_tracks') overrides[key] = ''; } } - // Include list: keepAll on a whole folder = all files (none); on a multi-selection = all selected. const inc = keepAll ? (selectedNames && selectedNames.length > 1 ? selectedNames.join('\n') : '') : includeFiles; - const allInputs = { ...formValues, ...overrides, ...(inc ? { include: inc } : {}), ...autoInputs }; - runner.run(taskDirName, allInputs, cwd); + return { ...formValues, ...overrides, ...(inc ? { include: inc } : {}), ...autoInputs }; + }; + + // Inline: run ephemerally in the modal. Job: create via REST and jump to the jobs view. + const runInline = () => runner.run(taskDirName, buildAllInputs(), cwd); + const submitJob = async (action: 'start' | 'queue') => { + try { + const { jobId } = await client.post<{ jobId: string }>('/jobs', { taskDirName, inputs: buildAllInputs(), cwd, action }); + navigate(action === 'queue' ? '/jobs' : `/jobs/${jobId}`); + } catch { + /* stays on the modal so the user can retry */ + } }; if (runner.phase === 'ready') { @@ -967,15 +984,35 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa )} )} -
- +
+ {(() => { + const noWork = perGroupTracks && entryType === 'directory' && !perGroupHasWork; + const base = 'flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer'; + if (inline) { + return ( + + ); + } + const jobDisabled = !inputDefs || probing || noWork; + return ( + <> + {jobRunning && ( + + )} + + + ); + })()}
);