tasks: run straight from the menu when there is nothing to ask

a task that takes real options — which audio tracks to keep, what format
to convert to — has to ask, and the runner modal is for that. a task that
is one command over the clicked file has nothing to ask, and putting a
form in front of it with one pre-filled field and a Run button is a click
that decides nothing.

derived, not declared. the rule is whether anything is left over once the
file browser has filled in what it knows, which is two things and only
two: file_path by name, and any input declaring autofill entry_name /
entry_path. a default does NOT count — a default is a suggestion and the
user has to see it to change it. agentic, pipeline, inline true and
inline 'ask' always take the modal; the last because the choice is itself
the question, and the one before it because the modal is where an
ephemeral run's output goes.

and the reason the transcribe task's one field came up EMPTY: inputs is a
list in TASK.md and a map everywhere else. the app's own task-creation
guide teaches the list form, so that is what gets written; every consumer
reads the map. a list arrived at the form as Object.entries(array), key
"0" — so the input rendered nameless, was never matched against what the
browser auto-fills, and would have run with nothing in it. normalised in
parseTaskMd, which fixes the form, the INPUT_<NAME> env vars and the
positional args together. the map form still passes through.

GET /tasks now carries inline and inputs so the menu can decide without a
second request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-24 04:40:07 +00:00
co-authored by Claude Opus 5
parent 6d673a1321
commit 121ddb9964
12 changed files with 223 additions and 3 deletions
@@ -7,10 +7,11 @@ import { EDITOR_FILE_PARAM } from '../../CodeEditor/useEditorState';
import { getFileType } from '../../FileViewer';
import { usePanelMemory, useWorkspace } from '../../../components/Workspace';
import { folderFromPathname, folderHref, relativeToFolder, withoutViewerParams } from './files-route';
import { useTasks, type TaskSummary } from '../useTasks';
import { useTasks, runsWithoutPrompting, type TaskSummary } from '../useTasks';
import { useAgents, type AgentSummary } from '../useAgents';
import { useUserState } from 'state/useUserState';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useFilesRefresh } from '../../../channels';
import { useNewDashboardDraft } from '../../Dashboards/useNewDashboardDraft';
import { copyToClipboard, canReadClipboard } from 'helpers/clipboard';
@@ -157,6 +158,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
const fileScrollRef = useRef<HTMLDivElement | null>(null);
const viewPath = searchParams.get('view');
const files = useFilesAPI(rootOverride ?? homeRoot);
const client = useClient();
const filesRef = useRef(files);
filesRef.current = files;
const currentPathRef = useRef(currentPath);
@@ -576,6 +578,37 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
}
};
/**
* Run a task straight from the context menu, with no modal.
*
* Taken when `runsWithoutPrompting` says there is nothing to ask — see the rule in `useTasks.ts`. The
* inputs and cwd are computed exactly as `TaskRunnerModal` computes them, because this is the same run
* by a shorter route: `file_path` is the clicked entry (the parent folder for a multi-selection, scoped
* by `include`), and a directory trigger descends into the entry.
*/
const runTaskDirectly = async (task: TaskSummary, entry: DirEntry, names: string[]) => {
const multi = names.length > 1;
const entryAbs = absPath(entryPath(entry.name));
const base = currentPath.replace(/^\//, '');
const inputs: Record<string, string> = { file_path: multi ? entryAbs.replace(/\/[^/]+$/, '') : entryAbs };
if (multi) inputs.include = names.join('\n');
try {
const { jobId } = await client.post<{ jobId: string }>('/jobs', {
taskDirName: task.dirName,
inputs,
cwd: entry.type === 'directory' ? (base ? `${base}/${entry.name}` : entry.name) : base || undefined,
action: 'start',
});
toast.success(t('taskRunner.jobStarted', { name: task.name }), {
action: { label: t('taskRunner.viewInJobs'), onClick: () => navigate(`/jobs/${jobId}`) },
});
} catch {
toast.error(t('taskRunner.jobStartFailed', { name: task.name }));
}
};
const handleRunTask = (task: TaskSummary, entry: DirEntry) => {
// When several items are selected and the task is run on one of them, run it on the whole
// selection (scoped via INPUT_INCLUDE — files match by path, folders by prefix).
@@ -583,6 +616,12 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
selected.size > 1 && selected.has(entry.name)
? visibleEntries.filter((e) => selected.has(e.name)).map((e) => e.name)
: [];
if (runsWithoutPrompting(task)) {
void runTaskDirectly(task, entry, names);
return;
}
setRunningTask({ task, entry, selectedNames: names.length > 1 ? names : undefined });
};
@@ -1,5 +1,5 @@
import { test, expect } from 'bun:test';
import { groupTasksByCategory, type TaskSummary } from './useTasks';
import { groupTasksByCategory, runsWithoutPrompting, type TaskSummary } from './useTasks';
const task = (name: string, category: string | null): TaskSummary => ({
id: 0,
@@ -55,3 +55,51 @@ test('tasks within a group are sorted by display name', () => {
const tasks = [task('Zebra', 'Video'), task('Alpha', 'Video')];
expect(groupTasksByCategory(tasks, ['Video'])[0]!.tasks.map((t) => t.name)).toEqual(['Alpha', 'Zebra']);
});
// ── Whether a context-menu run needs the modal ──
//
// The rule is derived rather than declared: a task that takes real options has to ask, a task that is one
// command over the clicked entry has nothing to ask. These pin both sides of that, and the three kinds
// that always ask whatever their inputs say.
const scriptTask = (over: Partial<TaskSummary> = {}): TaskSummary => ({
...task('Some Task', 'Media'),
inline: false,
inputs: null,
...over,
});
test('a task whose only input is the clicked file runs with no modal', () => {
expect(runsWithoutPrompting(scriptTask({ inputs: { file_path: { required: true } } }))).toBe(true);
});
test('a task with no inputs at all runs with no modal', () => {
expect(runsWithoutPrompting(scriptTask())).toBe(true);
});
test('an input the browser fills by autofill counts as filled', () => {
expect(runsWithoutPrompting(scriptTask({ inputs: { target: { autofill: 'entry_path' } } }))).toBe(true);
});
test('a real option takes the modal', () => {
expect(runsWithoutPrompting(scriptTask({ inputs: { format: { type: 'select', options: ['mp4'] } } }))).toBe(false);
});
// A default is a suggestion, not an answer — the user has to see it to change it.
test('an input with a default still takes the modal', () => {
expect(runsWithoutPrompting(scriptTask({ inputs: { bitrate: { default: '192' } } }))).toBe(false);
});
test("'ask' takes the modal, because the choice is itself the question", () => {
expect(runsWithoutPrompting(scriptTask({ inline: 'ask' }))).toBe(false);
});
// The modal is where an ephemeral run's output goes. There is nowhere else to put it.
test('an inline task takes the modal', () => {
expect(runsWithoutPrompting(scriptTask({ inline: true }))).toBe(false);
});
test('agentic and pipeline always take the modal', () => {
expect(runsWithoutPrompting(scriptTask({ mode: 'agentic' }))).toBe(false);
expect(runsWithoutPrompting(scriptTask({ mode: 'pipeline' }))).toBe(false);
});
@@ -4,6 +4,15 @@ import { usePermissions } from 'hooks/usePermissions';
export type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
/** One declared input, as the task frontmatter gives it (normalised server-side to a map by name). */
export type TaskInputSpec = {
type?: string;
default?: string;
autofill?: string;
required?: boolean;
options?: unknown;
};
export type TaskSummary = {
id: number;
dirName: string;
@@ -16,6 +25,40 @@ export type TaskSummary = {
userId: number | null;
/** The plugin that ships it, or null when it is the owner's own. */
plugin?: string | null;
/** false = background job, true = runs ephemerally in the modal, 'ask' = the modal offers the choice. */
inline?: boolean | 'ask';
inputs?: Record<string, TaskInputSpec> | null;
};
/*
── Whether a context-menu run needs the runner modal at all ──
Derived, not declared. A task that takes real options — which audio tracks to keep, what format to
convert to — has to ask, and that is what the modal is for. A task that is one command over the clicked
file or folder has nothing to ask, and putting a form in front of it with a single pre-filled field and
a Run button is a click that decides nothing.
So the question is simply whether anything is left over once the file browser has filled in what it
knows. It fills two things and only two: `file_path`, by name, with the clicked entry's absolute path;
and any input declaring `autofill: entry_name` / `entry_path`. Everything else is a question.
A default does NOT count as filled — a default is a suggestion, and the user has to see it to change it.
Three things always take the modal, whatever their inputs:
- `agentic`, which takes a prompt and a model;
- `pipeline`, which takes a model and a starting step;
- `inline: 'ask'` or `true` — 'ask' because the choice IS a question, `true` because the modal is where
an ephemeral run's output goes and there is nowhere else to put it.
*/
const BROWSER_AUTOFILL = new Set(['entry_name', 'entry_path']);
const isFilledByBrowser = (key: string, spec: TaskInputSpec): boolean =>
key === 'file_path' || (!!spec.autofill && BROWSER_AUTOFILL.has(spec.autofill));
export const runsWithoutPrompting = (task: TaskSummary): boolean => {
if (task.mode !== 'script') return false;
if (task.inline === true || task.inline === 'ask') return false;
return Object.entries(task.inputs ?? {}).every(([key, spec]) => isFilledByBrowser(key, spec ?? {}));
};
export type TaskGroup = { category: string; tasks: TaskSummary[] };
@@ -557,6 +557,8 @@
"pipelineFailed": "Pipeline fehlgeschlagen",
"pipelineComplete": "Pipeline abgeschlossen",
"viewInJobs": "In Jobs anzeigen",
"jobStarted": "{{name}} gestartet",
"jobStartFailed": "{{name}} konnte nicht gestartet werden",
"copyOutput": "Ausgabe kopieren",
"track": "Spur {{n}}",
"runFromStep": "Ab Schritt {{n}} ausführen"
@@ -557,6 +557,8 @@
"pipelineFailed": "Pipeline failed",
"pipelineComplete": "Pipeline complete",
"viewInJobs": "View in Jobs",
"jobStarted": "{{name}} started",
"jobStartFailed": "Could not start {{name}}",
"copyOutput": "Copy output",
"track": "Track {{n}}",
"runFromStep": "Run from step {{n}}"
@@ -557,6 +557,8 @@
"pipelineFailed": "El pipeline falló",
"pipelineComplete": "Pipeline completado",
"viewInJobs": "Ver en Trabajos",
"jobStarted": "{{name}} iniciada",
"jobStartFailed": "No se ha podido iniciar {{name}}",
"copyOutput": "Copiar salida",
"track": "Pista {{n}}",
"runFromStep": "Ejecutar desde el paso {{n}}"
@@ -557,6 +557,8 @@
"pipelineFailed": "Le pipeline a échoué",
"pipelineComplete": "Pipeline terminé",
"viewInJobs": "Voir dans les Jobs",
"jobStarted": "{{name}} lancée",
"jobStartFailed": "Impossible de lancer {{name}}",
"copyOutput": "Copier la sortie",
"track": "Piste {{n}}",
"runFromStep": "Exécuter à partir de l'étape {{n}}"
@@ -557,6 +557,8 @@
"pipelineFailed": "O pipeline falhou",
"pipelineComplete": "Pipeline concluído",
"viewInJobs": "Ver nos trabalhos",
"jobStarted": "{{name}} iniciada",
"jobStartFailed": "Não foi possível iniciar {{name}}",
"copyOutput": "Copiar a saída",
"track": "Faixa {{n}}",
"runFromStep": "Executar a partir do passo {{n}}"
@@ -557,6 +557,8 @@
"pipelineFailed": "Pipeline misslyckades",
"pipelineComplete": "Pipeline klar",
"viewInJobs": "Visa i Jobb",
"jobStarted": "{{name}} har startat",
"jobStartFailed": "Det gick inte att starta {{name}}",
"copyOutput": "Kopiera utdata",
"track": "Spår {{n}}",
"runFromStep": "Kör från steg {{n}}"