From 07241440c904c7c4989940803195fa7c982455c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 16:10:07 +0000 Subject: [PATCH] task runner modal keyed (171 -> 97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batch prompt sent when running a task against files: subtitle/audio track pickers, pipeline status, and the multi-group conflict UI. Three module-level helpers (trackLabel, defaultGroupSel, groupChanges) take `t` as a parameter rather than calling a hook, because they are plain functions, not components — same shape as the status-badge helpers from phase 2. defaultInput, built from the task name and directory, is exempt: it becomes the brief sent to the agent, never shown as editable text (unlike CapabilityPage's composer suggestion, which is exactly that and stays translated). Two lines marked, both with the reason. Two real plurals: "Track {{n}}" and "Run from step {{n}}", checked in both locales. --- .../components/TaskRunnerModal.tsx | 118 +++++++++++------- .../officerdev/src/locales/en-GB.json | 24 ++++ .../officerdev/src/locales/pt-PT.json | 24 ++++ 3 files changed, 118 insertions(+), 48 deletions(-) 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 fd27a627..eca94744 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -22,6 +22,7 @@ import { } from '../../../../hooks/useFilesAPI'; import { randomId } from 'helpers/random-id'; import { copyToClipboard } from 'helpers/clipboard'; +import { useTranslation } from '../../../../locales'; const playDing = () => { const ctx = new AudioContext(); @@ -66,6 +67,7 @@ const AgenticTaskRunner = ({ taskInfo, context, }: AgenticTaskRunnerProps) => { + const { t } = useTranslation(); const [phase, setPhase] = useState('ready'); const chat = useChat(undefined, initialModel, { replaceUrl: false, taskInfo }); const availableModels = useUserVisibleModels(); @@ -249,17 +251,17 @@ const AgenticTaskRunner = ({ className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-destructive/10 text-destructive text-sm font-medium hover:bg-destructive/20 transition-colors cursor-pointer" > - Stop + {t('taskRunner.stop')} ) : accRef.current.some((m) => m.role === 'error') ? ( - Task failed + {t('taskRunner.taskFailed')} ) : ( - Task complete + {t('taskRunner.taskComplete')} )} @@ -291,10 +293,13 @@ type TaskInputFormProps = { hideTrackPickers?: boolean; }; -const trackLabel = (track: { title: string; lang: string; id: number }) => +// Takes `t` because it is a module-level helper, not a component — the alternative is a hook call +// outside a component body. +type TaskRunnerT = ReturnType['t']; +const trackLabel = (track: { title: string; lang: string; id: number }, t: TaskRunnerT) => track.title || (track.lang && track.lang !== 'und' && track.lang !== 'unknown' ? track.lang.toUpperCase() : '') || - `Track ${track.id + 1}`; + t('taskRunner.track', { n: track.id + 1 }); // Channel count → friendly layout name; audio meta → "stereo · aac 193k"-style detail for a track row. const chLabel = (n: number) => @@ -326,6 +331,7 @@ export const TaskInputForm = ({ entryType, hideTrackPickers, }: TaskInputFormProps) => { + const { t } = useTranslation(); const configurableInputs = Object.entries(inputDefs).filter(([key]) => !autoFilledKeys.has(key)); if (configurableInputs.length === 0) return null; @@ -336,7 +342,7 @@ export const TaskInputForm = ({ const tracks = subtitleTracks ?? []; const byId = new Map(parseSubtitleSpec(values[key]).map((e) => [e.id, e])); const entryFor = (track: SubtitleTrack): SubtitleEditEntry => - byId.get(track.id) ?? { id: track.id, keep: true, label: trackLabel(track) }; + byId.get(track.id) ?? { id: track.id, keep: true, label: trackLabel(track, t) }; const update = (id: number, patch: Partial) => onChange( key, @@ -351,10 +357,10 @@ export const TaskInputForm = ({ {probing ? ( {' '} - {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'} + {entryType === 'directory' ? t('taskRunner.analyzing') : 'Probing…'} ) : tracks.length === 0 ? ( - No subtitles + {t('taskRunner.noSubtitles')} ) : ( <>
@@ -417,10 +423,10 @@ export const TaskInputForm = ({ {probing ? ( {' '} - {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'} + {entryType === 'directory' ? t('taskRunner.analyzing') : 'Probing…'} ) : tracks.length === 0 ? ( - None + {t('taskRunner.none')} ) : (
{tracks.map((track) => ( @@ -435,7 +441,7 @@ export const TaskInputForm = ({ className="accent-duck-teal cursor-pointer" /> - {trackLabel(track)} + {trackLabel(track, t)} {isAudio && ( {' '} @@ -580,12 +586,13 @@ type FolderSummaryProps = { }; const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSummaryProps) => { + const { t } = useTranslation(); const { fileCount, majorityCount, skipped } = folder; if (fileCount === 0) { return (
- No videos found in this folder. + {t('taskRunner.noVideos')}
); } @@ -608,7 +615,7 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma {majorityCount} of {fileCount} videos share this layout and will be processed.
- Different layout — run these separately: + {t('taskRunner.differentLayout')}
    {skipped.map((f) => (
  • @@ -658,7 +665,7 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma
{!keepAll && (
- Skipped (convert separately): + {t('taskRunner.skipped')}
    {skipped.map((f) => (
  • @@ -694,14 +701,14 @@ const pickerKinds = (defs: Record | null): PickerKinds => subEdit: Object.values(defs ?? {}).some((d) => d.type === 'subtitle_edit'), }); -const defaultGroupSel = (g: FolderTrackGroup): GroupSel => ({ +const defaultGroupSel = (g: FolderTrackGroup, t: TaskRunnerT): GroupSel => ({ audio: g.audioTracks.map((track) => track.id).join(','), // audio: keep all subs: 'none', // subtitles: keep none by default — you usually want only a few of many - subEdit: g.subtitleTracks.map((track) => ({ id: track.id, keep: false, label: trackLabel(track) })), + subEdit: g.subtitleTracks.map((track) => ({ id: track.id, keep: false, label: trackLabel(track, t) })), }); // Does a group's selection change anything vs keep-all + original labels? -const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds): boolean => { +const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds, t: TaskRunnerT): boolean => { if (has.audio && (s.audio === 'none' ? g.audioTracks.length > 0 : parseCsv(s.audio).size < g.audioTracks.length)) return true; if (has.subs && (s.subs === 'none' ? g.subtitleTracks.length > 0 : parseCsv(s.subs).size < g.subtitleTracks.length)) @@ -709,7 +716,7 @@ const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds): boole if (has.subEdit) { for (const track of g.subtitleTracks) { const e = s.subEdit.find((x) => x.id === track.id); - if (!e || !e.keep || e.label !== trackLabel(track)) return true; + if (!e || !e.keep || e.label !== trackLabel(track, t)) return true; } } return false; @@ -717,12 +724,18 @@ const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds): boole // Build the JSON group config the scripts consume ($INPUT_GROUP_CONFIG). allFiles=true (convert) keeps // every group; otherwise only groups that actually change are included. -const buildGroupConfig = (groups: FolderTrackGroup[], sel: GroupSel[], has: PickerKinds, allFiles: boolean) => { +const buildGroupConfig = ( + groups: FolderTrackGroup[], + sel: GroupSel[], + has: PickerKinds, + allFiles: boolean, + t: TaskRunnerT, +) => { const spec = (csv: string, count: number) => csv === 'none' ? 'none' : parseCsv(csv).size >= count ? 'all' : [...parseCsv(csv)].sort((a, b) => a - b).join(','); return groups - .map((g, gi) => ({ g, s: sel[gi] ?? defaultGroupSel(g) })) - .filter(({ g, s }) => allFiles || groupChanges(g, s, has)) + .map((g, gi) => ({ g, s: sel[gi] ?? defaultGroupSel(g, t) })) + .filter(({ g, s }) => allFiles || groupChanges(g, s, has, t)) .map(({ g, s }) => ({ files: g.files, ...(has.audio ? { audio: spec(s.audio, g.audioTracks.length) } : {}), @@ -731,7 +744,7 @@ const buildGroupConfig = (groups: FolderTrackGroup[], sel: GroupSel[], has: Pick ? { subEdit: g.subtitleTracks.map( (track) => - s.subEdit.find((x) => x.id === track.id) ?? { id: track.id, keep: true, label: trackLabel(track) }, + s.subEdit.find((x) => x.id === track.id) ?? { id: track.id, keep: true, label: trackLabel(track, t) }, ), } : {}), @@ -747,17 +760,18 @@ type PerGroupTrackConfigProps = { }; const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTrackConfigProps) => { + const { t } = useTranslation(); if (probing) { return (
    - Analyzing files… + {t('taskRunner.analyzing')}
    ); } if (groups.length === 0) { return (
    - No videos found in this folder. + {t('taskRunner.noVideos')}
    ); } @@ -772,7 +786,7 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr {groups.length} track layout{groups.length !== 1 ? 's' : ''} — configure each; all groups run in one pass. {groups.map((g, gi) => { - const s = sel[gi] ?? defaultGroupSel(g); + const s = sel[gi] ?? defaultGroupSel(g, t); const aSel = parseCsv(s.audio); const sSel = parseCsv(s.subs); const toggleA = (id: number) => { @@ -786,7 +800,7 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr onChange(gi, { subs: n.size ? [...n].sort((a, b) => a - b).join(',') : 'none' }); }; const entryFor = (track: SubtitleTrack): SubtitleEditEntry => - s.subEdit.find((x) => x.id === track.id) ?? { id: track.id, keep: true, label: trackLabel(track) }; + s.subEdit.find((x) => x.id === track.id) ?? { id: track.id, keep: true, label: trackLabel(track, t) }; const updateEdit = (id: number, patch: Partial) => onChange(gi, { subEdit: g.subtitleTracks.map((track) => @@ -824,7 +838,7 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr className="accent-duck-teal cursor-pointer shrink-0" /> - {trackLabel(track)} + {trackLabel(track, t)} · {audioMeta(track)} @@ -851,7 +865,7 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr className="accent-duck-teal cursor-pointer shrink-0" /> - {trackLabel(track)} + {trackLabel(track, t)} · {track.codec} @@ -935,6 +949,7 @@ const ScriptRunner = ({ selectedNames, onClose, }: ScriptRunnerProps) => { + const { t } = useTranslation(); const runner = useTaskRunner(); const client = useClient(); const navigate = useNavigate(); @@ -1026,7 +1041,7 @@ const ScriptRunner = ({ if (def.type === 'subtitle_tracks') next[key] = 'none'; if (def.type === 'subtitle_edit') next[key] = JSON.stringify( - sub.map((track) => ({ id: track.id, keep: false, label: trackLabel(track) })), + sub.map((track) => ({ id: track.id, keep: false, label: trackLabel(track, t) })), ); } return next; @@ -1061,7 +1076,7 @@ const ScriptRunner = ({ // Per-group mode uses every group with its own pickers, each defaulting to keep-all. setAllGroups(groups); - setGroupSel(groups.map(defaultGroupSel)); + setGroupSel(groups.map((g) => defaultGroupSel(g, t))); const majority = groups[0]; if (!majority) { @@ -1135,12 +1150,12 @@ const ScriptRunner = ({ const perGroupHasWork = perGroupTracks && entryType === 'directory' && - buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles).length > 0; + buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles, t).length > 0; // Collect the final input map (per-group config / include list / keep-all overrides all fold in here). const buildAllInputs = (): Record => { if (perGroupTracks && entryType === 'directory') { - const config = buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles); + const config = buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles, t); return { ...formValues, ...(config.length ? { group_config: JSON.stringify(config) } : {}), ...autoInputs }; } const overrides: Record = {}; @@ -1197,7 +1212,7 @@ const ScriptRunner = ({ onClick={onClose} className="px-5 py-2 rounded-lg text-sm font-medium text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 cursor-pointer" > - Close + {t('taskRunner.close')}
@@ -1209,7 +1224,7 @@ const ScriptRunner = ({
{selectedNames && selectedNames.length > 1 && (
- Running on {selectedNames.length} selected items + {t('taskRunner.runningOn')} {selectedNames.length} selected items
)}
@@ -1280,7 +1295,7 @@ const ScriptRunner = ({ disabled={jobDisabled} className={`${base} bg-duck-dark/10 dark:bg-foreground/10 text-duck-dark dark:text-foreground hover:bg-duck-dark/15 dark:hover:bg-foreground/15`} > - Queue + {t('taskRunner.queue')} )} @@ -1341,7 +1356,7 @@ const ScriptRunner = ({ className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-destructive/10 text-destructive text-sm font-medium hover:bg-destructive/20 transition-colors cursor-pointer" > - Stop + {t('taskRunner.stop')} ) : runner.exitCode !== 0 ? ( @@ -1351,7 +1366,7 @@ const ScriptRunner = ({ ) : ( - Task complete + {t('taskRunner.taskComplete')} )}
@@ -1369,6 +1384,7 @@ type PipelineRunnerProps = { }; const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerProps) => { + const { t } = useTranslation(); const pipeline = usePipelineRunner(); const navigate = useNavigate(); const client = useClient(); @@ -1450,7 +1466,9 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr {hasConcurrentSteps && (
- Concurrency + + {t('taskRunner.concurrency')} + parallel runners per step
)}
- Model + {t('taskRunner.model')}