feat(web): rebuild workflow builder — mockup styling, drag-and-drop, inputs inspector

This commit is contained in:
2026-07-20 14:48:29 +01:00
parent 619ccd28cb
commit 99bf093f00
+385 -202
View File
@@ -1,61 +1,29 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, Workflow, WorkflowStep, WorkflowStepRef, SecretGroupSummary } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Button } from "@/components/ui";
import { EditStepModal } from "@/components/workflows/EditStepModal";
import { EditWorkflowModal } from "@/components/workflows/EditWorkflowModal";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
function NewStepForm({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [interpreter, setInterpreter] = useState<"bash" | "powershell">("bash");
const [script, setScript] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const create = async () => {
setSaving(true);
setError(null);
try {
await api.createStep({
name: name.trim(),
description: "",
interpreter,
script,
declared_outputs: [],
secret_refs: [],
});
queryClient.invalidateQueries({ queryKey: ["steps"] });
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
};
type DragPayload = { kind: "lib"; stepId: string } | { kind: "move"; from: number };
function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
const isBash = interpreter === "bash";
return (
<div className="mb-3 space-y-2 rounded-lg border border-border bg-surface-2 p-2">
{error && <div className="text-xs text-danger">{error}</div>}
<input className={inputClass} placeholder="Step name" value={name} onChange={(e) => setName(e.target.value)} />
<select className={inputClass} value={interpreter} onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}>
<option value="bash">bash</option>
<option value="powershell">powershell</option>
</select>
<textarea className={`${inputClass} h-20 font-mono text-xs`} placeholder="script" value={script} onChange={(e) => setScript(e.target.value)} />
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" size="sm" loading={saving} disabled={!name.trim()} onClick={create}>
Add
</Button>
</div>
</div>
<span
className={`rounded px-1.5 py-0.5 font-mono text-[10px] uppercase ${
isBash ? "bg-bash/15 text-bash" : "bg-pwsh/15 text-pwsh"
}`}
>
{isBash ? "bash" : "pwsh"}
</span>
);
}
@@ -63,20 +31,25 @@ export default function WorkflowBuilder() {
const params = useParams<{ id: string }>();
const id = params.id;
const router = useRouter();
const queryClient = useQueryClient();
const [wf, setWf] = useState<Workflow | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const [showNewStep, setShowNewStep] = useState(false);
const [search, setSearch] = useState("");
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [groupKeys, setGroupKeys] = useState<Record<string, string[]>>({});
const [editWorkflowOpen, setEditWorkflowOpen] = useState(false);
const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null);
const [editStepOpen, setEditStepOpen] = useState(false);
const [dragOverZone, setDragOverZone] = useState<number | null>(null);
const { data: loaded } = useQuery({
queryKey: ["workflow", id],
queryFn: () => api.getWorkflow(id),
});
const { data: library } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: api.listServers });
const { data: secretGroups } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
@@ -84,10 +57,11 @@ export default function WorkflowBuilder() {
useEffect(() => {
if (loaded && !wf) setWf(loaded);
}, [loaded, wf]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loaded]);
// Lazily fetch the keys for every secret group so the inspector's
// secret-ref multiselect can offer "group/KEY" options.
// secret-ref checklist can offer "group/KEY" options.
useEffect(() => {
if (!secretGroups) return;
secretGroups.forEach((g: SecretGroupSummary) => {
@@ -110,11 +84,22 @@ export default function WorkflowBuilder() {
return <div className="p-8 text-text-secondary">Loading</div>;
}
const libById = (sid: string) => library?.find((l) => l.step_id === sid);
const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order);
const selectedRef = selected !== null ? sortedSteps[selected] : null;
const selectedLib = selectedRef ? libById(selectedRef.step_id) : null;
const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1;
const save = async () => {
setSaving(true);
setError(null);
try {
const updated = await api.updateWorkflow(id, wf);
if (!updated || !Array.isArray(updated.steps)) {
setError("Save failed: server returned an unexpected response.");
return;
}
setWf(updated);
} catch (e) {
setError((e as Error).message);
@@ -135,11 +120,44 @@ export default function WorkflowBuilder() {
}
};
const addStep = (s: WorkflowStep) =>
setWf({
...wf,
steps: [...wf.steps, { step_id: s.step_id, order: wf.steps.length, on_failure: "stop", max_retries: 0 }],
});
const resequence = (steps: WorkflowStepRef[]) => steps.map((r, i) => ({ ...r, order: i }));
const insertLibStep = (stepId: string, pos: number) => {
const next = [...sortedSteps];
next.splice(pos, 0, { step_id: stepId, order: 0, on_failure: "stop", max_retries: 0 });
setWf({ ...wf, steps: resequence(next) });
};
const moveStep = (from: number, pos: number) => {
const next = [...sortedSteps];
const [item] = next.splice(from, 1);
const target = from < pos ? pos - 1 : pos;
next.splice(target, 0, item);
setWf({ ...wf, steps: resequence(next) });
if (selected === from) setSelected(target);
else if (selected !== null) {
if (from < selected && target >= selected) setSelected(selected - 1);
else if (from > selected && target <= selected) setSelected(selected + 1);
}
};
const handleDrop = (e: React.DragEvent, pos: number) => {
e.preventDefault();
setDragOverZone(null);
const raw = e.dataTransfer.getData("text/plain");
if (!raw) return;
let payload: DragPayload;
try {
payload = JSON.parse(raw);
} catch {
return;
}
if (payload.kind === "lib") {
insertLibStep(payload.stepId, pos);
} else if (payload.kind === "move") {
moveStep(payload.from, pos);
}
};
const updateRef = (idx: number, patch: Partial<WorkflowStepRef>) =>
setWf({
@@ -148,146 +166,198 @@ export default function WorkflowBuilder() {
});
const removeStep = (idx: number) => {
const remaining = wf.steps.filter((_, i) => i !== idx).map((r, i) => ({ ...r, order: i }));
const remaining = resequence(wf.steps.filter((_, i) => i !== idx));
setWf({ ...wf, steps: remaining });
setSelected(null);
};
const moveStep = (idx: number, dir: -1 | 1) => {
const target = idx + dir;
const sorted = [...wf.steps].sort((a, b) => a.order - b.order);
if (target < 0 || target >= sorted.length) return;
const next = sorted.map((r, i) => {
if (i === idx) return { ...r, order: sorted[target].order };
if (i === target) return { ...r, order: sorted[idx].order };
return r;
});
setWf({ ...wf, steps: next });
if (selected === idx) setSelected(target);
else if (selected === target) setSelected(idx);
};
const toggleTargetServer = (serverId: string) => {
const set = new Set(wf.target_server_ids);
if (set.has(serverId)) set.delete(serverId);
else set.add(serverId);
setWf({ ...wf, target_server_ids: Array.from(set) });
};
const libById = (sid: string) => library?.find((l) => l.step_id === sid);
const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order);
const selectedRef = selected !== null ? sortedSteps[selected] : null;
const selectedLib = selectedRef ? libById(selectedRef.step_id) : null;
const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1;
const toggleSecretRef = (ref: string) => {
if (selectedIdxInWf === -1) return;
const current = selectedRef?.overrides?.secret_refs ?? [];
if (selectedIdxInWf === -1 || !selectedRef) return;
const current = selectedRef.overrides?.secret_refs ?? [];
const next = current.includes(ref) ? current.filter((r) => r !== ref) : [...current, ref];
updateRef(selectedIdxInWf, { overrides: { ...selectedRef?.overrides, secret_refs: next } });
updateRef(selectedIdxInWf, { overrides: { ...selectedRef.overrides, secret_refs: next } });
};
const filteredLibrary = (library ?? []).filter((s) => s.name.toLowerCase().includes(search.toLowerCase()));
const bashSteps = filteredLibrary.filter((s) => s.interpreter === "bash");
const pwshSteps = filteredLibrary.filter((s) => s.interpreter === "powershell");
const upstreamOutputsFor = (i: number) =>
Array.from(new Set(sortedSteps.slice(0, i).flatMap((r) => libById(r.step_id)?.declared_outputs ?? [])));
const DropZone = ({ pos }: { pos: number }) => (
<div
className={`h-3 w-full transition-all ${dragOverZone === pos ? "h-8 rounded bg-signal/15 border border-dashed border-signal/50" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setDragOverZone(pos);
}}
onDragLeave={() => setDragOverZone((z) => (z === pos ? null : z))}
onDrop={(e) => handleDrop(e, pos)}
/>
);
return (
<>
<div className="topbar border-b border-border bg-surface p-3">
<div className="flex items-center gap-3">
<input className={`${inputClass} flex-1`} value={wf.name} onChange={(e) => setWf({ ...wf, name: e.target.value })} />
<Button variant="secondary" loading={saving} onClick={save}>
<div className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
<span className="h-2.5 w-2.5 rounded-full bg-signal" />
<div className="flex items-center gap-1.5 text-sm">
<span className="text-text-secondary">Workflows /</span>
<span className="font-medium text-text-primary">{wf.name}</span>
<span className="text-text-secondary">· draft</span>
</div>
<div className="ml-auto flex items-center gap-2">
<span className="rounded-full border border-border bg-surface-2 px-3 py-1 text-xs text-text-secondary">
{wf.target_server_ids.length} servers
</span>
<Link
href={`/workflows/${id}/runs`}
className="rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
>
Runs
</Link>
<Button variant="secondary" size="sm" onClick={() => setEditWorkflowOpen(true)}>
Edit
</Button>
<Button variant="secondary" size="sm" loading={saving} onClick={save}>
Save
</Button>
<Button variant="primary" loading={running} onClick={run}>
Run Workflow
<Button
size="sm"
loading={running}
onClick={run}
className="bg-signal text-signal-ink border-transparent hover:bg-signal/90"
>
Run workflow
</Button>
</div>
</div>
<div className="grid h-[calc(100vh-0px)] grid-cols-[264px_1fr_320px]">
{error && (
<div className="border-b border-danger/30 bg-danger/10 px-4 py-2 text-sm text-danger">{error}</div>
)}
<div className="grid h-[calc(100vh-53px)] grid-cols-[264px_1fr_320px]">
{/* LEFT: library */}
<aside className="overflow-auto border-r border-border bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-bold uppercase tracking-wide text-text-secondary">Step Library</h2>
<Button variant="ghost" size="sm" onClick={() => setShowNewStep((v) => !v)}>
{showNewStep ? "Close" : "Add"}
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingStep(null);
setEditStepOpen(true);
}}
>
+
</Button>
</div>
{showNewStep && <NewStepForm onClose={() => setShowNewStep(false)} />}
{library?.map((s) => (
<button key={s.step_id} onClick={() => addStep(s)} className="mb-2 block w-full rounded-lg border border-border bg-surface-2 p-2 text-left hover:border-accent">
<span className="font-mono text-[10px] uppercase text-accent">{s.interpreter}</span>
<div className="text-sm font-medium text-text-primary">{s.name}</div>
</button>
))}
{library && library.length === 0 && <p className="text-xs text-text-secondary">No steps yet. Add one above.</p>}
<input
className={`${inputClass} mb-3`}
placeholder="Search steps…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{bashSteps.length > 0 && (
<>
<h3 className="mb-1 mt-2 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
Shared · Bash
</h3>
{bashSteps.map((s) => (
<LibraryCard key={s.step_id} step={s} onAdd={() => insertLibStep(s.step_id, sortedSteps.length)} onEdit={() => {
setEditingStep(s);
setEditStepOpen(true);
}} />
))}
</>
)}
{pwshSteps.length > 0 && (
<>
<h3 className="mb-1 mt-3 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
Shared · PowerShell
</h3>
{pwshSteps.map((s) => (
<LibraryCard key={s.step_id} step={s} onAdd={() => insertLibStep(s.step_id, sortedSteps.length)} onEdit={() => {
setEditingStep(s);
setEditStepOpen(true);
}} />
))}
</>
)}
{filteredLibrary.length === 0 && <p className="mt-2 text-xs text-text-secondary">No steps found.</p>}
</aside>
{/* CENTER: canvas */}
<main className="overflow-auto p-6">
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<Card className="mb-4">
<h3 className="mb-2 text-xs font-bold uppercase tracking-wide text-text-secondary">Target servers</h3>
<div className="flex flex-wrap gap-2">
{servers?.map((s) => {
const isChecked = wf.target_server_ids.includes(s.server_id);
return (
<label
key={s.server_id}
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${
isChecked ? "border-accent bg-accent/10 text-text-primary" : "border-border text-text-secondary"
}`}
>
<input type="checkbox" className="accent-accent" checked={isChecked} onChange={() => toggleTargetServer(s.server_id)} />
{s.hostname}
</label>
);
})}
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
</div>
</Card>
<div className="mx-auto flex max-w-md flex-col items-center gap-2">
<main
className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-8"
>
<div className="mx-auto flex w-[340px] flex-col items-center">
<DropZone pos={0} />
{sortedSteps.map((ref, i) => {
const lib = libById(ref.step_id);
const outs = sortedSteps.slice(0, i).flatMap((r) => libById(r.step_id)?.declared_outputs ?? []);
const outs = upstreamOutputsFor(i);
const script = ref.overrides?.script ?? lib?.script ?? "";
const wfIdx = wf.steps.indexOf(ref);
const isSelected = selected === i;
return (
<div key={i} className="w-full">
{i > 0 && outs.length > 0 && (
<div className="mx-auto my-1 flex w-fit flex-wrap items-center justify-center gap-1 rounded-full border border-dashed border-accent/50 px-3 py-1">
<span className="text-[10px] uppercase text-text-secondary">passes</span>
{outs.map((o) => (
<span key={o} className="rounded bg-accent px-2 py-0.5 font-mono text-[11px] text-white">
{o}
</span>
))}
<div key={wfIdx} className="w-full">
{i > 0 && (
<div className="flex flex-col items-center py-1">
<div className="h-[13px] w-0.5 bg-border" />
{outs.length > 0 && (
<div className="flex w-fit max-w-[300px] flex-wrap items-center justify-center gap-1 rounded-full border border-dashed border-signal/55 bg-surface px-3 py-1">
<span className="text-[10px] uppercase text-text-secondary">passes</span>
{outs.map((o) => (
<span
key={o}
className="rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink"
>
{o}
</span>
))}
</div>
)}
<div className="h-[13px] w-0.5 bg-border" />
</div>
)}
<div className={`w-full rounded-lg border bg-surface p-3 ${selected === i ? "border-accent" : "border-border"}`}>
<button onClick={() => setSelected(i)} className="block w-full text-left">
<div className="mb-1 flex items-center gap-2">
<span className="font-mono text-[10px] uppercase text-accent">{lib?.interpreter}</span>
<span className="font-medium text-text-primary">{lib?.name ?? ref.step_id}</span>
<span className="ml-auto rounded bg-surface-2 px-2 py-0.5 text-[10px] uppercase text-text-secondary">{ref.on_failure}</span>
</div>
<pre className="max-h-16 overflow-hidden text-ellipsis whitespace-pre-wrap font-mono text-[11px] text-text-secondary">{script.slice(0, 160)}</pre>
</button>
<div className="mt-2 flex items-center gap-2">
<Button variant="ghost" size="sm" disabled={i === 0} onClick={() => moveStep(i, -1)}>
</Button>
<Button variant="ghost" size="sm" disabled={i === sortedSteps.length - 1} onClick={() => moveStep(i, 1)}>
</Button>
<Button variant="danger" size="sm" onClick={() => removeStep(wfIdx)}>
Remove
</Button>
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", JSON.stringify({ kind: "move", from: i }));
}}
onClick={() => setSelected(i)}
className={`w-[340px] cursor-pointer rounded-[10px] border bg-surface p-3 ${
isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"
}`}
>
<div className="mb-2 flex items-center gap-2">
<span className="grid h-5 w-5 place-items-center rounded border border-border font-mono text-[10px] text-text-secondary">
{i + 1}
</span>
<span className="text-sm font-medium text-text-primary">{lib?.name ?? ref.step_id}</span>
{lib && <ShellBadge interpreter={lib.interpreter} />}
</div>
<pre className="max-h-16 overflow-hidden text-ellipsis whitespace-pre-wrap rounded border border-border bg-surface-2 p-2 font-mono text-xs text-text-secondary">
{script.slice(0, 200)}
</pre>
</div>
<DropZone pos={i + 1} />
</div>
);
})}
{wf.steps.length === 0 && <p className="py-10 text-text-secondary">Click a step on the left to add it.</p>}
{sortedSteps.length === 0 && (
<button
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(e, 0)}
className="mt-2 w-full rounded-[10px] border border-dashed border-border bg-surface py-6 text-sm text-text-secondary hover:border-signal/50 hover:text-text-primary"
>
+ Drop a step here
</button>
)}
</div>
</main>
@@ -297,12 +367,20 @@ export default function WorkflowBuilder() {
<p className="text-sm text-text-secondary">Select a step to configure it.</p>
) : (
<div className="space-y-4">
<h2 className="text-sm font-bold text-text-primary">{selectedLib?.name ?? selectedRef.step_id}</h2>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
<div className="mb-1 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
Step {selected + 1} · Inspector
</div>
<div className="flex items-center gap-2">
{selectedLib && <ShellBadge interpreter={selectedLib.interpreter} />}
<h2 className="text-sm font-bold text-text-primary">{selectedLib?.name ?? selectedRef.step_id}</h2>
</div>
</div>
<div className="border-b border-border pb-4">
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
<textarea
className={`${inputClass} h-40 font-mono text-xs`}
className={`${inputClass} h-32 font-mono text-xs`}
value={selectedRef.overrides?.script ?? selectedLib?.script ?? ""}
onChange={(e) =>
updateRef(selectedIdxInWf, {
@@ -310,9 +388,97 @@ export default function WorkflowBuilder() {
})
}
/>
<p className="mt-1 text-xs text-text-secondary">
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose
it to later steps.
</p>
</div>
<div>
{(selectedLib?.declared_inputs ?? []).length > 0 && (
<div className="border-b border-border pb-4">
<label className="mb-2 block text-xs uppercase text-text-secondary">Inputs</label>
<div className="space-y-2">
{selectedLib?.declared_inputs.map((param) => (
<div key={param.name}>
<div className="mb-1 font-mono text-xs text-text-primary">{param.name}</div>
{param.description && (
<div className="mb-1 text-[11px] text-text-secondary">{param.description}</div>
)}
<input
className={inputClass}
placeholder={param.default}
value={selectedRef.inputs?.[param.name] ?? ""}
onChange={(e) =>
updateRef(selectedIdxInWf, {
inputs: { ...selectedRef.inputs, [param.name]: e.target.value },
})
}
/>
</div>
))}
</div>
</div>
)}
<div className="border-b border-border pb-4">
<label className="mb-2 block text-xs uppercase text-text-secondary">Inputs · from upstream</label>
<div className="flex flex-wrap gap-1">
{upstreamOutputsFor(selected).length === 0 && (
<p className="text-xs text-text-secondary">No upstream outputs.</p>
)}
{upstreamOutputsFor(selected).map((o) => (
<span key={o} className="flex items-center gap-1 rounded bg-surface-2 border border-border px-2 py-0.5 font-mono text-[11px] text-text-primary">
<span className="text-[9px] uppercase text-text-secondary">in</span>
{o}
</span>
))}
</div>
</div>
<div className="border-b border-border pb-4">
<label className="mb-2 block text-xs uppercase text-text-secondary">Outputs · to $WORKFLOW_ENV</label>
<div className="flex flex-wrap gap-1">
{(selectedLib?.declared_outputs ?? []).length === 0 && (
<p className="text-xs text-text-secondary">No declared outputs.</p>
)}
{(selectedLib?.declared_outputs ?? []).map((o) => (
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
<span className="text-[9px] uppercase">out</span>
{o}
</span>
))}
</div>
</div>
<div className="border-b border-border pb-4">
<label className="mb-2 block text-xs uppercase text-text-secondary">Secret refs</label>
<div className="max-h-56 space-y-2 overflow-auto rounded-lg border border-border p-2">
{secretGroups?.map((g) => (
<div key={g.group}>
<div className="font-mono text-[11px] font-semibold text-text-secondary">{g.group}</div>
{(groupKeys[g.group] ?? []).map((key) => {
const ref = `${g.group}/${key}`;
const checked = (selectedRef.overrides?.secret_refs ?? []).includes(ref);
return (
<label key={ref} className="ml-2 flex cursor-pointer items-center gap-2 text-xs text-text-primary">
<input
type="checkbox"
className="accent-signal"
checked={checked}
onChange={() => toggleSecretRef(ref)}
/>
<span className="font-mono">{key}</span>
</label>
);
})}
{(groupKeys[g.group] ?? []).length === 0 && <p className="ml-2 text-[11px] text-text-secondary">No keys.</p>}
</div>
))}
{secretGroups && secretGroups.length === 0 && <p className="text-xs text-text-secondary">No secret groups yet.</p>}
</div>
</div>
<div className="border-b border-border pb-4">
<label className="mb-1 block text-xs uppercase text-text-secondary">On failure</label>
<select
className={inputClass}
@@ -327,51 +493,68 @@ export default function WorkflowBuilder() {
<option value="continue">Continue</option>
<option value="retry">Retry</option>
</select>
</div>
{selectedRef.on_failure === "retry" && (
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Max retries</label>
<input
type="number"
min={1}
className={inputClass}
value={selectedRef.max_retries}
onChange={(e) => updateRef(selectedIdxInWf, { max_retries: parseInt(e.target.value || "0", 10) })}
/>
</div>
)}
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Secret refs</label>
<div className="max-h-56 space-y-2 overflow-auto rounded-lg border border-border p-2">
{secretGroups?.map((g) => (
<div key={g.group}>
<div className="font-mono text-[11px] font-semibold text-text-secondary">{g.group}</div>
{(groupKeys[g.group] ?? []).map((key) => {
const ref = `${g.group}/${key}`;
const checked = (selectedRef.overrides?.secret_refs ?? []).includes(ref);
return (
<label key={ref} className="ml-2 flex cursor-pointer items-center gap-2 text-xs text-text-primary">
<input type="checkbox" className="accent-accent" checked={checked} onChange={() => toggleSecretRef(ref)} />
<span className="font-mono">{key}</span>
</label>
);
})}
{(groupKeys[g.group] ?? []).length === 0 && <p className="ml-2 text-[11px] text-text-secondary">No keys.</p>}
</div>
))}
{secretGroups && secretGroups.length === 0 && <p className="text-xs text-text-secondary">No secret groups yet.</p>}
</div>
{selectedRef.on_failure === "retry" && (
<div className="mt-2">
<label className="mb-1 block text-xs uppercase text-text-secondary">Max retries</label>
<input
type="number"
min={1}
className={inputClass}
value={selectedRef.max_retries}
onChange={(e) => updateRef(selectedIdxInWf, { max_retries: parseInt(e.target.value || "0", 10) })}
/>
</div>
)}
</div>
<Button variant="danger" size="sm" onClick={() => removeStep(selectedIdxInWf)}>
Remove step
Remove from workflow
</Button>
</div>
)}
</aside>
</div>
<EditWorkflowModal open={editWorkflowOpen} workflow={wf} onSaved={(w) => setWf(w)} onClose={() => setEditWorkflowOpen(false)} />
<EditStepModal
key={editingStep?.step_id ?? "new"}
open={editStepOpen}
step={editingStep}
onClose={() => {
setEditStepOpen(false);
queryClient.invalidateQueries({ queryKey: ["steps"] });
}}
/>
</>
);
}
function LibraryCard({ step, onAdd, onEdit }: { step: WorkflowStep; onAdd: () => void; onEdit: () => void }) {
return (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", JSON.stringify({ kind: "lib", stepId: step.step_id }));
}}
onClick={onAdd}
className="group relative mb-2 cursor-grab rounded-lg border border-border bg-surface-2 p-2 text-left hover:border-signal/50"
>
<div className="mb-1 flex items-center gap-2">
<span className="text-text-secondary"></span>
<ShellBadge interpreter={step.interpreter} />
<span className="text-sm font-medium text-text-primary">{step.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="ml-auto hidden text-text-secondary hover:text-text-primary group-hover:block"
title="Edit step"
>
</button>
</div>
{step.description && <p className="text-xs text-text-secondary">{step.description}</p>}
</div>
);
}