task runner modal keyed (171 -> 97)

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.
This commit is contained in:
2026-08-23 16:10:07 +00:00
parent f2f3ca12ba
commit 07241440c9
3 changed files with 118 additions and 48 deletions
@@ -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<Phase>('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"
>
<Square className="h-3.5 w-3.5" />
Stop
{t('taskRunner.stop')}
</button>
) : accRef.current.some((m) => m.role === 'error') ? (
<span className="flex items-center gap-2 text-sm text-destructive font-medium">
<CircleX className="h-4 w-4" />
Task failed
{t('taskRunner.taskFailed')}
</span>
) : (
<span className="flex items-center gap-2 text-sm text-success font-medium">
<CircleCheck className="h-4 w-4" />
Task complete
{t('taskRunner.taskComplete')}
</span>
)}
</div>
@@ -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<typeof useTranslation>['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<SubtitleEditEntry>) =>
onChange(
key,
@@ -351,10 +357,10 @@ export const TaskInputForm = ({
{probing ? (
<span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
<Loader2 className="h-3 w-3 animate-spin" />{' '}
{entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
{entryType === 'directory' ? t('taskRunner.analyzing') : 'Probing…'}
</span>
) : tracks.length === 0 ? (
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">No subtitles</span>
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">{t('taskRunner.noSubtitles')}</span>
) : (
<>
<div className="flex flex-col gap-1 max-h-72 overflow-y-auto pr-1">
@@ -417,10 +423,10 @@ export const TaskInputForm = ({
{probing ? (
<span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
<Loader2 className="h-3 w-3 animate-spin" />{' '}
{entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
{entryType === 'directory' ? t('taskRunner.analyzing') : 'Probing…'}
</span>
) : tracks.length === 0 ? (
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">None</span>
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">{t('taskRunner.none')}</span>
) : (
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
{tracks.map((track) => (
@@ -435,7 +441,7 @@ export const TaskInputForm = ({
className="accent-duck-teal cursor-pointer"
/>
<span className="whitespace-nowrap">
{trackLabel(track)}
{trackLabel(track, t)}
{isAudio && (
<span className="text-duck-dark/40 dark:text-foreground/40">
{' '}
@@ -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 (
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-busy-ink dark:text-busy-ink">
<AlertCircle className="h-4 w-4 shrink-0" /> No videos found in this folder.
<AlertCircle className="h-4 w-4 shrink-0" /> {t('taskRunner.noVideos')}
</div>
);
}
@@ -608,7 +615,7 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma
{majorityCount} of {fileCount} videos share this layout and will be processed.
</span>
<div className="text-xs text-duck-dark/50 dark:text-foreground/50">
<span>Different layout — run these separately:</span>
<span>{t('taskRunner.differentLayout')}</span>
<ul className="mt-1 max-h-24 overflow-y-auto flex flex-col gap-0.5 pl-1">
{skipped.map((f) => (
<li key={f} className="truncate">
@@ -658,7 +665,7 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma
</div>
{!keepAll && (
<div className="text-xs text-duck-dark/50 dark:text-foreground/50">
<span>Skipped (convert separately):</span>
<span>{t('taskRunner.skipped')}</span>
<ul className="mt-1 max-h-20 overflow-y-auto flex flex-col gap-0.5 pl-1">
{skipped.map((f) => (
<li key={f} className="truncate">
@@ -694,14 +701,14 @@ const pickerKinds = (defs: Record<string, TaskInputDef> | 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 (
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
<Loader2 className="h-3 w-3 animate-spin" /> Analyzing files…
<Loader2 className="h-3 w-3 animate-spin" /> {t('taskRunner.analyzing')}
</div>
);
}
if (groups.length === 0) {
return (
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-busy-ink dark:text-busy-ink">
<AlertCircle className="h-4 w-4 shrink-0" /> No videos found in this folder.
<AlertCircle className="h-4 w-4 shrink-0" /> {t('taskRunner.noVideos')}
</div>
);
}
@@ -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.
</span>
{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<SubtitleEditEntry>) =>
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"
/>
<span className="whitespace-nowrap">
{trackLabel(track)}
{trackLabel(track, t)}
<span className="text-duck-dark/40 dark:text-foreground/40"> · {audioMeta(track)}</span>
</span>
</label>
@@ -851,7 +865,7 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
className="accent-duck-teal cursor-pointer shrink-0"
/>
<span className="whitespace-nowrap">
{trackLabel(track)}
{trackLabel(track, t)}
<span className="text-duck-dark/40 dark:text-foreground/40"> · {track.codec}</span>
</span>
</label>
@@ -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<string, string> => {
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<string, string> = {};
@@ -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')}
</button>
</div>
</div>
@@ -1209,7 +1224,7 @@ const ScriptRunner = ({
<div className="flex-1 flex flex-col min-h-0">
{selectedNames && selectedNames.length > 1 && (
<div className="shrink-0 px-5 py-2 border-b border-duck-dark/10 text-sm text-duck-dark/70 dark:text-foreground/70">
Running on <span className="font-medium">{selectedNames.length}</span> selected items
{t('taskRunner.runningOn')} <span className="font-medium">{selectedNames.length}</span> selected items
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto">
@@ -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')}
</button>
)}
<button
@@ -1306,7 +1321,7 @@ const ScriptRunner = ({
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-white/10 text-white/40 hover:text-white hover:bg-white/20 transition-colors opacity-0 group-hover:opacity-100 cursor-pointer z-10"
title="Copy output"
title={t('taskRunner.copyOutput')}
>
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
</button>
@@ -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"
>
<Square className="h-3.5 w-3.5" />
Stop
{t('taskRunner.stop')}
</button>
) : runner.exitCode !== 0 ? (
<span className="flex items-center gap-2 text-sm text-destructive font-medium">
@@ -1351,7 +1366,7 @@ const ScriptRunner = ({
) : (
<span className="flex items-center gap-2 text-sm text-success font-medium">
<CircleCheck className="h-4 w-4" />
Task complete
{t('taskRunner.taskComplete')}
</span>
)}
</div>
@@ -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 && (
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center justify-between gap-4">
<div className="min-w-0">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Concurrency</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('taskRunner.concurrency')}
</span>
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 ml-2">parallel runners per step</span>
</div>
<input
@@ -1464,7 +1482,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
</div>
)}
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center justify-between gap-4">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Model</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{t('taskRunner.model')}</span>
<select
value={selectedModel}
onChange={(ev) => setSelectedModel(ev.target.value)}
@@ -1479,7 +1497,9 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
</div>
{pipelineSteps.length > 0 && (
<div className="px-5 py-3 border-b border-duck-dark/10">
<span className="text-sm font-medium text-duck-dark dark:text-foreground mb-2 block">Steps</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground mb-2 block">
{t('taskRunner.steps')}
</span>
<div className="flex flex-col gap-0.5">
{pipelineSteps.map((step, i) => (
<button
@@ -1510,7 +1530,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<Play className="h-4 w-4" />
{startAt > 0 ? `Run from step ${startAt + 1}` : 'Run Pipeline'}
{startAt > 0 ? t('taskRunner.runFromStep', { n: startAt + 1 }) : 'Run Pipeline'}
</button>
</div>
</form>
@@ -1642,17 +1662,17 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
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"
>
<Square className="h-3.5 w-3.5" />
Stop Pipeline
{t('taskRunner.stopPipeline')}
</button>
) : pipeline.hasError ? (
<span className="flex items-center gap-2 text-sm text-destructive font-medium">
<CircleX className="h-4 w-4" />
Pipeline failed
{t('taskRunner.pipelineFailed')}
</span>
) : (
<span className="flex items-center gap-2 text-sm text-success font-medium">
<CircleCheck className="h-4 w-4" />
Pipeline complete
{t('taskRunner.pipelineComplete')}
</span>
)}
{pipeline.totalCost && (
@@ -1676,7 +1696,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
className="flex items-center gap-1.5 text-xs text-duck-teal hover:text-duck-teal/80 transition-colors mt-1 cursor-pointer"
>
<ExternalLink className="h-3 w-3" />
View in Jobs
{t('taskRunner.viewInJobs')}
</button>
)}
</div>
@@ -1726,8 +1746,10 @@ export const TaskRunnerModal = ({
const defaultInput =
promptOverride ??
(entryRef && entryType
? `Execute the task "${task.name}" (${task.dirName}) on the ${entryType}: ${entryRef}`
: `Execute the task "${task.name}" (${task.dirName})`);
? // i18n-exempt: agent prompt — becomes the brief sent to the model, never shown as editable text
`Execute the task "${task.name}" (${task.dirName}) on the ${entryType}: ${entryRef}`
: // i18n-exempt: agent prompt — becomes the brief sent to the model, never shown as editable text
`Execute the task "${task.name}" (${task.dirName})`);
const taskInfo: TaskInfo = {
taskName: task.name,
taskDirName: task.dirName,
@@ -485,5 +485,29 @@
"pastedInto": "Pasted into “{{name}}”",
"unpinned": "Unpinned “{{name}}”",
"pinned": "Pinned “{{name}}”"
},
"taskRunner": {
"stop": "Stop",
"taskFailed": "Task failed",
"taskComplete": "Task complete",
"noSubtitles": "No subtitles",
"none": "None",
"noVideos": "No videos found in this folder.",
"differentLayout": "Different layout — run these separately:",
"skipped": "Skipped (convert separately):",
"analyzing": "Analyzing files…",
"close": "Close",
"runningOn": "Running on",
"queue": "Queue",
"concurrency": "Concurrency",
"model": "Model",
"steps": "Steps",
"stopPipeline": "Stop Pipeline",
"pipelineFailed": "Pipeline failed",
"pipelineComplete": "Pipeline complete",
"viewInJobs": "View in Jobs",
"copyOutput": "Copy output",
"track": "Track {{n}}",
"runFromStep": "Run from step {{n}}"
}
}
@@ -485,5 +485,29 @@
"pastedInto": "Colado em “{{name}}”",
"unpinned": "“{{name}}” removido dos fixados",
"pinned": "“{{name}}” fixado"
},
"taskRunner": {
"stop": "Parar",
"taskFailed": "A tarefa falhou",
"taskComplete": "Tarefa concluída",
"noSubtitles": "Sem legendas",
"none": "Nenhuma",
"noVideos": "Não foram encontrados vídeos nesta pasta.",
"differentLayout": "Disposição diferente — execute estes em separado:",
"skipped": "Ignorados (converter em separado):",
"analyzing": "A analisar ficheiros…",
"close": "Fechar",
"runningOn": "A executar em",
"queue": "Fila",
"concurrency": "Concorrência",
"model": "Modelo",
"steps": "Passos",
"stopPipeline": "Parar o pipeline",
"pipelineFailed": "O pipeline falhou",
"pipelineComplete": "Pipeline concluído",
"viewInJobs": "Ver nos trabalhos",
"copyOutput": "Copiar a saída",
"track": "Faixa {{n}}",
"runFromStep": "Executar a partir do passo {{n}}"
}
}