From 121ddb99645b2466c2a6e30e66fd95b2be61e589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 24 Aug 2026 04:40:07 +0000 Subject: [PATCH] tasks: run straight from the menu when there is nothing to ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ 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 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/api/tasks/task-files.test.ts | 45 +++++++++++++++++ src/servers/api/tasks/task-files.ts | 33 +++++++++++- src/servers/api/tasks/tasks.ts | 2 + .../FileBrowserApp/useFileBrowserApp.ts | 41 ++++++++++++++- .../src/apps/FileBrowser/useTasks.test.ts | 50 ++++++++++++++++++- .../src/apps/FileBrowser/useTasks.ts | 43 ++++++++++++++++ .../officerdev/src/locales/de-DE.json | 2 + .../officerdev/src/locales/en-GB.json | 2 + .../officerdev/src/locales/es-ES.json | 2 + .../officerdev/src/locales/fr-FR.json | 2 + .../officerdev/src/locales/pt-PT.json | 2 + .../officerdev/src/locales/sv-SE.json | 2 + 12 files changed, 223 insertions(+), 3 deletions(-) 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}}"