diff --git a/web/app/workflows/[id]/page.tsx b/web/app/workflows/[id]/page.tsx index 3aa0db4..394586a 100644 --- a/web/app/workflows/[id]/page.tsx +++ b/web/app/workflows/[id]/page.tsx @@ -43,6 +43,16 @@ function AdhocBadge() { ); } +function timeAgo(date: Date): string { + const s = Math.floor((Date.now() - date.getTime()) / 1000); + if (s < 5) return "just now"; + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + return `${h}h ago`; +} + export default function WorkflowBuilder() { const params = useParams<{ id: string }>(); const id = params.id; @@ -53,6 +63,9 @@ export default function WorkflowBuilder() { const [selected, setSelected] = useState(null); const [search, setSearch] = useState(""); const [saving, setSaving] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + const [, setTick] = useState(0); + const savedSnapshotRef = useRef(null); const [running, setRunning] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -78,7 +91,10 @@ export default function WorkflowBuilder() { }); useEffect(() => { - if (loaded && !wf) setWf(loaded); + if (loaded && !wf) { + setWf(loaded); + savedSnapshotRef.current = JSON.stringify(loaded); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [loaded]); @@ -122,7 +138,11 @@ export default function WorkflowBuilder() { setError("Save failed: server returned an unexpected response."); return; } + // Record the snapshot BEFORE setWf so the autosave effect sees the + // incoming state as already-saved and doesn't re-trigger. + savedSnapshotRef.current = JSON.stringify(updated); setWf(updated); + setLastSaved(new Date()); } catch (e) { setError((e as Error).message); } finally { @@ -130,6 +150,26 @@ export default function WorkflowBuilder() { } }; + // Autosave: debounce 800ms after any change to the workflow (step added, + // removed, reordered, or edited) and persist. Diffing the serialized state + // against the last saved snapshot skips no-op saves and the initial load. + useEffect(() => { + if (!wf || savedSnapshotRef.current === null) return; + if (JSON.stringify(wf) === savedSnapshotRef.current) return; + const t = setTimeout(() => { + save(); + }, 800); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wf]); + + // Re-render every 15s so the "Saved … ago" label stays current. + useEffect(() => { + if (!lastSaved) return; + const iv = setInterval(() => setTick((n) => n + 1), 15000); + return () => clearInterval(iv); + }, [lastSaved]); + const run = async () => { setRunning(true); setError(null); @@ -315,7 +355,9 @@ export default function WorkflowBuilder() {
Workflows / {wf.name} - · draft + + · {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : "draft"} +
@@ -330,9 +372,6 @@ export default function WorkflowBuilder() { -