feat(web): workflow builder autosave with last-saved status

This commit is contained in:
2026-07-21 11:27:55 +01:00
parent 6c5472760b
commit d0442291f5
+44 -5
View File
@@ -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<number | null>(null);
const [search, setSearch] = useState("");
const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [, setTick] = useState(0);
const savedSnapshotRef = useRef<string | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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() {
<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>
<span className="text-text-secondary">
· {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : "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">
@@ -330,9 +372,6 @@ export default function WorkflowBuilder() {
<Button variant="secondary" size="sm" onClick={() => setEditWorkflowOpen(true)}>
Edit
</Button>
<Button variant="secondary" size="sm" loading={saving} onClick={save}>
Save
</Button>
<Button
size="sm"
loading={running}