diff --git a/src/servers/api/tasks/task-files.test.ts b/src/servers/api/tasks/task-files.test.ts index badc95cb..6bb4e115 100644 --- a/src/servers/api/tasks/task-files.test.ts +++ b/src/servers/api/tasks/task-files.test.ts @@ -98,3 +98,48 @@ describe('tasks across sources', () => { } }); }); + +/** + * `inputs` is a LIST in TASK.md and a map everywhere else. + * + * The app's own task-creation guide teaches the list form, so agents write it; every consumer reads the + * map. A list reached the runner as `Object.entries(array)` — key "0" — so the input rendered nameless, + * never matched what the file browser auto-fills, and ran empty. That is exactly what the transcribe + * plugin's task did the first time it was clicked. + */ +describe('input normalisation', () => { + const withInputs = (yaml: string) => `---\nname: T\nmode: script\n${yaml}---\n\nbody\n`; + + test('the list form becomes a map keyed by name', async () => { + const owner = seed({ + 't/TASK.md': withInputs('inputs:\n - name: file_path\n description: the file\n required: true\n'), + }); + try { + const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]); + expect(task?.inputs).toEqual({ file_path: { description: 'the file', required: true } }); + } finally { + rmSync(owner, { recursive: true, force: true }); + } + }); + + test('the map form passes through untouched', async () => { + const owner = seed({ 't/TASK.md': withInputs('inputs:\n file_path:\n description: the file\n') }); + try { + const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]); + expect(task?.inputs).toEqual({ file_path: { description: 'the file' } }); + } finally { + rmSync(owner, { recursive: true, force: true }); + } + }); + + // No name means no INPUT_ env var and no form key — there is nothing to call it. + test('an entry with no name is dropped', async () => { + const owner = seed({ 't/TASK.md': withInputs('inputs:\n - description: nameless\n') }); + try { + const task = await getTaskByDirName('t', [{ dir: owner, plugin: null }]); + expect(task?.inputs).toBeNull(); + } finally { + rmSync(owner, { recursive: true, force: true }); + } + }); +}); diff --git a/src/servers/api/tasks/task-files.ts b/src/servers/api/tasks/task-files.ts index 2f6dbf24..89e463ce 100644 --- a/src/servers/api/tasks/task-files.ts +++ b/src/servers/api/tasks/task-files.ts @@ -66,6 +66,9 @@ export type TaskSummary = { version: number; trigger: unknown; plugin: string | null; + /** Normalised to a map keyed by input name. Carried in the list so a caller can tell, without a second + * request, whether running this task needs anything from the user. */ + inputs: unknown; }; // Frontmatter keys, in write order. These mirror the old `tasks` table columns 1:1. @@ -104,6 +107,32 @@ const taskFile = (dirName: string) => join(taskDir(dirName), 'TASK.md'); const asArray = (v: unknown): string[] | null => (Array.isArray(v) ? v.map(String) : null); +/** + * `inputs` in TASK.md is a LIST of `{ name, … }`. Everything downstream wants a map keyed by name. + * + * Both shapes are in the wild because the app's own task-creation guide teaches the list form while + * every consumer — the runner modal, the agent runner, `execute-script`'s `INPUT_` env vars — + * reads the map. A list reached the form as `Object.entries(array)`, so the key was "0": the input + * rendered with no name, was never matched against what the file browser auto-fills, and ran with an + * empty value. Normalising here fixes every consumer at once, and the map form still passes through. + */ +function normalizeInputs(raw: unknown): Record> | null { + if (!raw) return null; + + if (Array.isArray(raw)) { + const map: Record> = {}; + for (const entry of raw) { + if (!entry || typeof entry !== 'object') continue; + const { name, ...rest } = entry as Record; + if (typeof name !== 'string' || !name) continue; // no name, no way to pass it to the script + map[name] = rest; + } + return Object.keys(map).length ? map : null; + } + + return typeof raw === 'object' ? (raw as Record>) : null; +} + export const slugify = (name: string) => name .trim() @@ -141,7 +170,7 @@ function parseTaskMd(raw: string): { fm: TaskFrontmatter; body: string } { tags: asArray(parsed.tags), tools: asArray(parsed.tools), skills: asArray(parsed.skills), - inputs: parsed.inputs ?? null, + inputs: normalizeInputs(parsed.inputs), outputs: parsed.outputs ?? null, dependencies: parsed.dependencies ?? null, config: parsed.config ?? null, @@ -224,6 +253,7 @@ export async function listTasks(sources?: CapabilitySource[]): Promise { triggers: (row.trigger as TriggerConfig[]) ?? [], mode: row.mode ?? 'agentic', plugin: row.plugin, + inline: row.inline, + inputs: row.inputs, })); return ctx.json(tasks); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 5d06f769..6355d2ef 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -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(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 = { 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 }); }; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.test.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.test.ts index 6a5bad7c..f5fe53c8 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.test.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.test.ts @@ -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 => ({ + ...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); +}); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts index fa4aca48..cbb0a193 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts @@ -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 | 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[] }; diff --git a/src/workspaces/officerdev/src/locales/de-DE.json b/src/workspaces/officerdev/src/locales/de-DE.json index 696ce99c..ccf9eb7e 100644 --- a/src/workspaces/officerdev/src/locales/de-DE.json +++ b/src/workspaces/officerdev/src/locales/de-DE.json @@ -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" diff --git a/src/workspaces/officerdev/src/locales/en-GB.json b/src/workspaces/officerdev/src/locales/en-GB.json index 085016b4..7e1717b0 100644 --- a/src/workspaces/officerdev/src/locales/en-GB.json +++ b/src/workspaces/officerdev/src/locales/en-GB.json @@ -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}}" diff --git a/src/workspaces/officerdev/src/locales/es-ES.json b/src/workspaces/officerdev/src/locales/es-ES.json index af8401f1..1e65ab25 100644 --- a/src/workspaces/officerdev/src/locales/es-ES.json +++ b/src/workspaces/officerdev/src/locales/es-ES.json @@ -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}}" diff --git a/src/workspaces/officerdev/src/locales/fr-FR.json b/src/workspaces/officerdev/src/locales/fr-FR.json index 36e1d684..3555c67b 100644 --- a/src/workspaces/officerdev/src/locales/fr-FR.json +++ b/src/workspaces/officerdev/src/locales/fr-FR.json @@ -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}}" diff --git a/src/workspaces/officerdev/src/locales/pt-PT.json b/src/workspaces/officerdev/src/locales/pt-PT.json index f4fa131b..3c7cea64 100644 --- a/src/workspaces/officerdev/src/locales/pt-PT.json +++ b/src/workspaces/officerdev/src/locales/pt-PT.json @@ -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}}" diff --git a/src/workspaces/officerdev/src/locales/sv-SE.json b/src/workspaces/officerdev/src/locales/sv-SE.json index b121de16..a297d7c4 100644 --- a/src/workspaces/officerdev/src/locales/sv-SE.json +++ b/src/workspaces/officerdev/src/locales/sv-SE.json @@ -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}}"