jobs: modal creates jobs for non-inline tasks (Run/Queue -> POST /jobs -> /jobs/:id)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+57
-20
@@ -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<HTMLDivElement | null>(null);
|
||||
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||
@@ -771,9 +776,10 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
// Fetch task detail to get input definitions
|
||||
useEffect(() => {
|
||||
client
|
||||
.get<{ inputs?: Record<string, TaskInputDef>; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean } }>(`/tasks/${taskDirName}`)
|
||||
.get<{ inline?: boolean; inputs?: Record<string, TaskInputDef>; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean } }>(`/tasks/${taskDirName}`)
|
||||
.then((task) => {
|
||||
const defs: Record<string, TaskInputDef> = 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<Array<unknown>>('/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<string, string> => {
|
||||
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<string, string> = {};
|
||||
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
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={!runner.isConnected || !inputDefs || probing || (perGroupTracks && entryType === 'directory' && !perGroupHasWork)}
|
||||
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Run
|
||||
</button>
|
||||
<div className="flex-1 flex items-center justify-center gap-3">
|
||||
{(() => {
|
||||
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 (
|
||||
<button onClick={runInline} disabled={!runner.isConnected || !inputDefs || probing || noWork} className={`${base} bg-duck-teal text-white hover:bg-duck-teal/90`}>
|
||||
<Play className="h-4 w-4" /> Run
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const jobDisabled = !inputDefs || probing || noWork;
|
||||
return (
|
||||
<>
|
||||
{jobRunning && (
|
||||
<button onClick={() => submitJob('queue')} disabled={jobDisabled} className={`${base} bg-duck-dark/10 dark:bg-foreground/10 text-duck-dark dark:text-foreground hover:bg-duck-dark/15 dark:hover:bg-foreground/15`}>
|
||||
Queue
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => submitJob('start')}
|
||||
disabled={jobDisabled}
|
||||
className={`${base} text-white ${jobRunning ? 'bg-red-500 hover:bg-red-500/90' : 'bg-duck-teal hover:bg-duck-teal/90'}`}
|
||||
>
|
||||
<Play className="h-4 w-4" /> {jobRunning ? 'Run now' : 'Run'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user