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`
|
(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
|
— `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.
|
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.
|
- [x] `inline` flag plumbed (parser + list/detail endpoints); 3 quick tasks flagged.
|
||||||
- [ ] 3c FileBrowser task action: `task.inline ? openModal : navigate('/jobs/new?...')`. Modal stays
|
- [x] 3b/3c task→job via the modal (pragmatic reuse). The task modal is now the job creator: on Run,
|
||||||
for inline tasks (not retired); shared input form used by both.
|
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:
|
- [ ] 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`;
|
(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
|
(2) **queued** count → links to the queue (`/jobs`). Backed by a lightweight
|
||||||
|
|||||||
+56
-19
@@ -745,7 +745,12 @@ type ScriptRunnerProps = {
|
|||||||
const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePath, selectedNames }: ScriptRunnerProps) => {
|
const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePath, selectedNames }: ScriptRunnerProps) => {
|
||||||
const runner = useTaskRunner();
|
const runner = useTaskRunner();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
const files = useFilesAPI('home');
|
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 bottomRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
|
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
|
||||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
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
|
// Fetch task detail to get input definitions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
client
|
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) => {
|
.then((task) => {
|
||||||
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
||||||
|
setInline(task.inline === true);
|
||||||
setFolderKeepAll(task.config?.folderKeepAll === true);
|
setFolderKeepAll(task.config?.folderKeepAll === true);
|
||||||
setPerGroupTracks(task.config?.perGroupTracks === true);
|
setPerGroupTracks(task.config?.perGroupTracks === true);
|
||||||
setPerGroupAllFiles(task.config?.perGroupAllFiles === 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',
|
(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.
|
// Which per-group pickers this task declares, and whether the current selection is real work.
|
||||||
const has = pickerKinds(inputDefs);
|
const has = pickerKinds(inputDefs);
|
||||||
const perGroupHasWork =
|
const perGroupHasWork =
|
||||||
perGroupTracks && entryType === 'directory' && buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles).length > 0;
|
perGroupTracks && entryType === 'directory' && buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles).length > 0;
|
||||||
|
|
||||||
const handleRun = () => {
|
// Collect the final input map (per-group config / include list / keep-all overrides all fold in here).
|
||||||
// Per-group config: serialize each layout's selection to $INPUT_GROUP_CONFIG (JSON), so a single
|
const buildAllInputs = (): Record<string, string> => {
|
||||||
// run handles every group. For track-only edits, no-op groups are left out; convert keeps all.
|
|
||||||
if (perGroupTracks && entryType === 'directory') {
|
if (perGroupTracks && entryType === 'directory') {
|
||||||
const config = buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles);
|
const config = buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles);
|
||||||
const allInputs = { ...formValues, ...(config.length ? { group_config: JSON.stringify(config) } : {}), ...autoInputs };
|
return { ...formValues, ...(config.length ? { group_config: JSON.stringify(config) } : {}), ...autoInputs };
|
||||||
runner.run(taskDirName, allInputs, cwd);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// "Keep every track" mode: clear track selections (empty = keep all) and convert everything.
|
|
||||||
const overrides: Record<string, string> = {};
|
const overrides: Record<string, string> = {};
|
||||||
if (keepAll && inputDefs) {
|
if (keepAll && inputDefs) {
|
||||||
for (const [key, def] of Object.entries(inputDefs)) {
|
for (const [key, def] of Object.entries(inputDefs)) {
|
||||||
if (def.type === 'audio_tracks' || def.type === 'subtitle_tracks') overrides[key] = '';
|
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 inc = keepAll ? (selectedNames && selectedNames.length > 1 ? selectedNames.join('\n') : '') : includeFiles;
|
||||||
const allInputs = { ...formValues, ...overrides, ...(inc ? { include: inc } : {}), ...autoInputs };
|
return { ...formValues, ...overrides, ...(inc ? { include: inc } : {}), ...autoInputs };
|
||||||
runner.run(taskDirName, allInputs, cwd);
|
};
|
||||||
|
|
||||||
|
// 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') {
|
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">
|
<div className="flex-1 flex items-center justify-center gap-3">
|
||||||
<button
|
{(() => {
|
||||||
onClick={handleRun}
|
const noWork = perGroupTracks && entryType === 'directory' && !perGroupHasWork;
|
||||||
disabled={!runner.isConnected || !inputDefs || probing || (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';
|
||||||
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"
|
if (inline) {
|
||||||
>
|
return (
|
||||||
<Play className="h-4 w-4" />
|
<button onClick={runInline} disabled={!runner.isConnected || !inputDefs || probing || noWork} className={`${base} bg-duck-teal text-white hover:bg-duck-teal/90`}>
|
||||||
Run
|
<Play className="h-4 w-4" /> Run
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user