tasks: a direct run runs, it does not queue

"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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-24 04:47:16 +00:00
co-authored by Claude Opus 5
parent 121ddb9964
commit 247291eeb9
10 changed files with 112 additions and 8 deletions
@@ -0,0 +1,69 @@
type RunResult = { code: number; output: string };
type RunTaskNowParams = {
taskDirName: string;
inputs: Record<string, string>;
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<RunResult> {
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'))));
});
}
@@ -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<string, string> = { 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 }), {
@@ -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', () => {
@@ -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 ?? {}));
};
@@ -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"
@@ -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}}"
@@ -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}}"
@@ -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}}"
@@ -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}}"
@@ -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}}"