diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx
index 7b5376c9..3c9711fd 100644
--- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx
+++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx
@@ -10,6 +10,10 @@ export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps)
if (!runningTask) return null;
+ const entryAbs = getEntryAbsPath(runningTask.entry.name);
+ // Parent folder of the entry — the batch target when a multi-selection is run.
+ const folderFullPath = entryAbs.replace(/\/[^/]+$/, '');
+
return (
);
};
diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx
index b356cbd7..6be117b4 100644
--- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx
+++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx
@@ -502,9 +502,10 @@ type ScriptRunnerProps = {
cwd?: string;
entryType?: 'file' | 'directory';
filePath?: string;
+ selectedNames?: string[];
};
-const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePath }: ScriptRunnerProps) => {
+const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePath, selectedNames }: ScriptRunnerProps) => {
const runner = useTaskRunner();
const client = useClient();
const files = useFilesAPI('home');
@@ -532,6 +533,10 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
}
setFormValues(initial);
+ // Multi-selection: scope the run to exactly the selected files. Even tasks without pickers
+ // (which return early below) need this so the batch only touches the selection.
+ if (selectedNames && selectedNames.length > 0) setIncludeFiles(selectedNames.join('\n'));
+
// Track pickers: audio defaults to keep-all, subtitles to keep-none. A folder probes every
// video and drives the pickers off the largest matching group (the rest convert separately).
const hasTrackPickers = Object.values(defs).some(
@@ -559,16 +564,38 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
files
.probeFolder(filePath)
.then((probe: FolderProbe) => {
- const majority = probe.groups[0];
+ // Restrict grouping to the selection when this is a multi-selection run — a file matches
+ // if it's selected directly, or sits under a selected folder.
+ const sel = selectedNames && selectedNames.length > 0 ? new Set(selectedNames) : null;
+ const inSel = (f: string) => {
+ if (sel!.has(f)) return true;
+ let p = f;
+ for (let i = p.lastIndexOf('/'); i >= 0; i = p.lastIndexOf('/')) {
+ p = p.slice(0, i);
+ if (sel!.has(p)) return true;
+ }
+ return false;
+ };
+ const groups = sel
+ ? probe.groups
+ .map((g) => ({ ...g, files: g.files.filter(inSel) }))
+ .filter((g) => g.files.length > 0)
+ .map((g) => ({ ...g, count: g.files.length }))
+ .sort((a, b) => b.count - a.count)
+ : probe.groups;
+ const fileCount = sel ? groups.reduce((n, g) => n + g.files.length, 0) : probe.fileCount;
+
+ const majority = groups[0];
if (!majority) {
- setFolder({ fileCount: probe.fileCount, majorityCount: 0, skipped: [] });
+ setFolder({ fileCount, majorityCount: 0, skipped: [] });
return;
}
seedTracks(majority.audioTracks, majority.subtitleTracks);
- const skipped = probe.groups.slice(1).flatMap((g) => g.files);
- setFolder({ fileCount: probe.fileCount, majorityCount: majority.count, skipped });
- // Only pin the include list when some files are being left out; a uniform folder converts all.
- setIncludeFiles(skipped.length > 0 ? majority.files.join('\n') : '');
+ const skipped = groups.slice(1).flatMap((g) => g.files);
+ setFolder({ fileCount, majorityCount: majority.count, skipped });
+ // Pin the include list when leaving files out — always, for a multi-selection (so it
+ // never spills onto unselected files), otherwise only on a mixed-layout folder.
+ setIncludeFiles(sel || skipped.length > 0 ? majority.files.join('\n') : '');
})
.catch(() => setFolder({ fileCount: 0, majorityCount: 0, skipped: [] }))
.finally(() => setProbing(false));
@@ -619,6 +646,11 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
if (runner.phase === 'ready') {
return (
+ {selectedNames && selectedNames.length > 1 && (
+
+ Running on {selectedNames.length} selected items
+
+ )}
{entryType === 'directory' && folder && !probing &&
}
{inputDefs && (
{
+export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFullPath, entryType, cwd = { path: '' }, promptOverride, description, sandboxed, selectedNames, folderFullPath }: TaskRunnerModalProps) => {
const navigate = useNavigate();
const { settings } = useSettings();
const taskSettings = settings.tasks;
@@ -1034,6 +1068,10 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
const isScript = task.mode === 'script';
const isPipeline = task.mode === 'pipeline';
+ // Multi-selection: run a script task on the whole current folder, scoped to the selected files.
+ const multi = isScript && (selectedNames?.length ?? 0) > 1;
+ const effectiveEntryType = multi ? 'directory' : entryType;
+
// Agentic mode prompt (fallback if task has no body)
const defaultInput = promptOverride
?? (entryRef && entryType
@@ -1051,7 +1089,8 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
// Script mode: auto-filled inputs from context (e.g. file_path from file browser)
const autoInputs: Record = {};
- if (entryFullPath) autoInputs.file_path = entryFullPath;
+ if (multi && folderFullPath) autoInputs.file_path = folderFullPath;
+ else if (entryFullPath) autoInputs.file_path = entryFullPath;
return (