From 247291eeb94abc8b2eab24564ba640e989586ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 24 Aug 2026 04:47:16 +0000 Subject: [PATCH] tasks: a direct run runs, it does not queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "direct" meant a job row and a link to the jobs page. that is not direct — it is the same work with a screen nobody opened between you and the result. inline decides which kind of direct run it is, which is what that field has always meant. true: run it here and wait, a toast while it works and the listing refreshed when it lands — the file viewer's old transcribe button, reached by the menu instead of a header icon. false: a background job, because a task saying inline false is saying it expects to outlive the click. runTaskNow is the promise-shaped sibling of useTaskRunner: same socket, same protocol, opened for the run and closed after it. the hook could not be reused — it holds a connection for as long as it is mounted, and a hook in the file browser would open one per panel, permanently, for a path that fires on a right-click. a socket that closes before exit rejects. it did not finish, and a success toast would be a lie. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- .../FileBrowserApp/components/runTaskNow.ts | 69 +++++++++++++++++++ .../FileBrowserApp/useFileBrowserApp.ts | 30 +++++++- .../src/apps/FileBrowser/useTasks.test.ts | 7 +- .../src/apps/FileBrowser/useTasks.ts | 8 ++- .../officerdev/src/locales/de-DE.json | 1 + .../officerdev/src/locales/en-GB.json | 1 + .../officerdev/src/locales/es-ES.json | 1 + .../officerdev/src/locales/fr-FR.json | 1 + .../officerdev/src/locales/pt-PT.json | 1 + .../officerdev/src/locales/sv-SE.json | 1 + 10 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/runTaskNow.ts diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/runTaskNow.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/runTaskNow.ts new file mode 100644 index 00000000..11cc8ea3 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/runTaskNow.ts @@ -0,0 +1,69 @@ +type RunResult = { code: number; output: string }; + +type RunTaskNowParams = { + taskDirName: string; + inputs: Record; + cwd?: string; +}; + +/** + * Run a script task and wait for it, with no modal and no job row. + * + * ── Why this exists next to `useTaskRunner` ── + * + * Same socket, same protocol, different shape. `useTaskRunner` is a hook: it holds a connection for as + * long as the modal is mounted and streams output into it, which is what a visible run needs. This is a + * promise: it opens the socket, runs, resolves on exit and closes. Nothing is watching, so nothing is + * kept open. + * + * The hook could not be reused for it. A hook in the file browser would open a websocket for every panel + * that mounts one, permanently, for a path that fires on a right-click — and the file browser is the + * panel people leave open. + * + * ── Why not a job ── + * + * A job row exists to be watched: it survives navigating away, it appears in Jobs, it can be queued. A + * run with nothing to ask has none of that to offer — it is the file viewer's old transcribe button, a + * spinner and then the file is there. Creating a job for it means the answer arrives on a screen nobody + * opened. + */ +export function runTaskNow({ taskDirName, inputs, cwd }: RunTaskNowParams): Promise { + return new Promise((resolve, reject) => { + const token = localStorage.getItem('BEARER_TOKEN'); + if (!token) { + reject(new Error('not signed in')); + return; + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${protocol}//${window.location.host}/api/tasks/run/ws?token=${token}`); + + let output = ''; + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + fn(); + ws.close(); + }; + + ws.addEventListener('open', () => ws.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd }))); + + ws.addEventListener('message', (ev) => { + let msg: { type?: string; data?: string; code?: number; message?: string }; + try { + msg = JSON.parse(ev.data); + } catch { + return; + } + if (msg.type === 'stdout' || msg.type === 'stderr') output += msg.data ?? ''; + if (msg.type === 'exit') finish(() => resolve({ code: msg.code ?? 0, output })); + if (msg.type === 'error') finish(() => reject(new Error(msg.message || 'Task failed'))); + }); + + // A socket that closes before `exit` took the run with it — the server went away mid-run, or the + // token was refused. Either way it did not finish, and reporting success would be a lie. + ws.addEventListener('close', () => finish(() => reject(new Error(output.trim() || 'Connection lost')))); + ws.addEventListener('error', () => finish(() => reject(new Error('Connection failed')))); + }); +} diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 6355d2ef..b2b1de5e 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -12,6 +12,7 @@ import { useAgents, type AgentSummary } from '../useAgents'; import { useUserState } from 'state/useUserState'; import { useAuth } from 'hooks/useAuth'; import { useClient } from 'hooks/useClient'; +import { runTaskNow } from './components/runTaskNow'; import { useFilesRefresh } from '../../../channels'; import { useNewDashboardDraft } from '../../Dashboards/useNewDashboardDraft'; import { copyToClipboard, canReadClipboard } from 'helpers/clipboard'; @@ -202,7 +203,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa } }; - const [refreshSignal] = useFilesRefresh(); + const [refreshSignal, bumpFilesRefresh] = useFilesRefresh(); useEffect(() => { if (basePath !== '/' && !currentPath.startsWith(basePath)) { @@ -585,20 +586,45 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa * 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. + * + * `inline` still decides what KIND of run it is, which is what that field has always meant: + * + * - `true` — run it here and wait. No job row, no navigation: a toast while it works, the listing + * refreshed when it lands. This is the file viewer's old transcribe button, reached by the menu. + * - `false` — a background job, because that is a task saying it expects to outlive the click. The + * toast carries a link to it rather than taking you there. */ 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 cwd = entry.type === 'directory' ? (base ? `${base}/${entry.name}` : entry.name) : base || undefined; const inputs: Record = { file_path: multi ? entryAbs.replace(/\/[^/]+$/, '') : entryAbs }; if (multi) inputs.include = names.join('\n'); + if (task.inline === true) { + const toastId = toast.loading(t('taskRunner.running', { name: task.name })); + try { + const { code, output } = await runTaskNow({ taskDirName: task.dirName, inputs, cwd }); + if (code !== 0) throw new Error(output.trim().split('\n').pop() || ''); + toast.success(t('taskRunner.taskComplete'), { id: toastId }); + // Bumping the channel refreshes this panel too — the effect below listens to the same signal — + // so a second `refresh()` here would only be the same listing fetched twice. + bumpFilesRefresh(); + } catch (err) { + // The script's own last line, when it left one — it knows why it failed and this does not. + const detail = err instanceof Error ? err.message : ''; + toast.error(detail || t('taskRunner.jobStartFailed', { name: task.name }), { id: toastId }); + } + return; + } + 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, + cwd, action: 'start', }); toast.success(t('taskRunner.jobStarted', { name: task.name }), { diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.test.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.test.ts index f5fe53c8..4c841e26 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.test.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.test.ts @@ -94,9 +94,10 @@ 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); +// `inline` decides what KIND of direct run it is — here and now, versus a background job — not whether +// there is one. +test('an inline task with nothing to ask still skips the modal', () => { + expect(runsWithoutPrompting(scriptTask({ inline: true }))).toBe(true); }); test('agentic and pipeline always take the modal', () => { diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts index cbb0a193..a66eb883 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts @@ -47,8 +47,10 @@ export type TaskSummary = { 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. + - `inline: 'ask'`, because the choice IS a question. + + `inline` otherwise decides what kind of direct run it is, not whether there is one — `true` runs here + and waits, `false` creates a background job. See `runTaskDirectly` in `useFileBrowserApp`. */ const BROWSER_AUTOFILL = new Set(['entry_name', 'entry_path']); @@ -57,7 +59,7 @@ const isFilledByBrowser = (key: string, spec: TaskInputSpec): boolean => export const runsWithoutPrompting = (task: TaskSummary): boolean => { if (task.mode !== 'script') return false; - if (task.inline === true || task.inline === 'ask') return false; + if (task.inline === 'ask') return false; return Object.entries(task.inputs ?? {}).every(([key, spec]) => isFilledByBrowser(key, spec ?? {})); }; diff --git a/src/workspaces/officerdev/src/locales/de-DE.json b/src/workspaces/officerdev/src/locales/de-DE.json index ccf9eb7e..8176bad6 100644 --- a/src/workspaces/officerdev/src/locales/de-DE.json +++ b/src/workspaces/officerdev/src/locales/de-DE.json @@ -559,6 +559,7 @@ "viewInJobs": "In Jobs anzeigen", "jobStarted": "{{name}} gestartet", "jobStartFailed": "{{name}} konnte nicht gestartet werden", + "running": "{{name}} läuft…", "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 7e1717b0..e416ab5a 100644 --- a/src/workspaces/officerdev/src/locales/en-GB.json +++ b/src/workspaces/officerdev/src/locales/en-GB.json @@ -559,6 +559,7 @@ "viewInJobs": "View in Jobs", "jobStarted": "{{name}} started", "jobStartFailed": "Could not start {{name}}", + "running": "Running {{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 1e65ab25..e7c69038 100644 --- a/src/workspaces/officerdev/src/locales/es-ES.json +++ b/src/workspaces/officerdev/src/locales/es-ES.json @@ -559,6 +559,7 @@ "viewInJobs": "Ver en Trabajos", "jobStarted": "{{name}} iniciada", "jobStartFailed": "No se ha podido iniciar {{name}}", + "running": "Ejecutando {{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 3555c67b..23ae20e8 100644 --- a/src/workspaces/officerdev/src/locales/fr-FR.json +++ b/src/workspaces/officerdev/src/locales/fr-FR.json @@ -559,6 +559,7 @@ "viewInJobs": "Voir dans les Jobs", "jobStarted": "{{name}} lancée", "jobStartFailed": "Impossible de lancer {{name}}", + "running": "Exécution de {{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 3c7cea64..c057d863 100644 --- a/src/workspaces/officerdev/src/locales/pt-PT.json +++ b/src/workspaces/officerdev/src/locales/pt-PT.json @@ -559,6 +559,7 @@ "viewInJobs": "Ver nos trabalhos", "jobStarted": "{{name}} iniciada", "jobStartFailed": "Não foi possível iniciar {{name}}", + "running": "A executar {{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 a297d7c4..39d7b7ec 100644 --- a/src/workspaces/officerdev/src/locales/sv-SE.json +++ b/src/workspaces/officerdev/src/locales/sv-SE.json @@ -559,6 +559,7 @@ "viewInJobs": "Visa i Jobb", "jobStarted": "{{name}} har startat", "jobStartFailed": "Det gick inte att starta {{name}}", + "running": "Kör {{name}}…", "copyOutput": "Kopiera utdata", "track": "Spår {{n}}", "runFromStep": "Kör från steg {{n}}"