diff --git a/explorer/src/index.css b/explorer/src/index.css index 2da58a27..c87a4c2d 100644 --- a/explorer/src/index.css +++ b/explorer/src/index.css @@ -63,3 +63,9 @@ code, pre, .mono { .animate-spin { animation: spin 1s linear infinite; } + +/* Skeleton pulse animation for loading placeholders */ +@keyframes skeleton-pulse { + 0%, 100% { opacity: 0.45; } + 50% { opacity: 0.85; } +} diff --git a/explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx b/explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx index f5e4790d..974819e3 100644 --- a/explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx +++ b/explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx @@ -203,15 +203,21 @@ export function DecisionWorkspace() { const [filterQuery, setFilterQuery] = useState(""); useEffect(() => { + const controller = new AbortController(); setListLoading(true); - fetch("/api/decisions") - .then((res) => res.json()) + fetch("/api/decisions", { signal: controller.signal }) + .then((res) => { + if (!res.ok) throw new Error(`Failed to load decisions: ${res.status}`); + return res.json(); + }) .then((data) => { setDecisions(data); if (data.length > 0) void handleSelectDecision(data[0]); }) - .catch(console.error) + .catch((err) => { if (err.name !== "AbortError") console.error(err); }) .finally(() => setListLoading(false)); + return () => controller.abort(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const filteredDecisions = useMemo(() => { @@ -229,15 +235,18 @@ export function DecisionWorkspace() { setSelectedDecision(d); setLoading(true); setChain([]); + const controller = new AbortController(); try { - const res = await fetch(`/api/decisions/${d.decision_id}/chain`); + const res = await fetch(`/api/decisions/${encodeURIComponent(d.decision_id)}/chain`, { signal: controller.signal }); + if (!res.ok) throw new Error(`Failed to load chain: ${res.status}`); const data = await res.json(); setChain(data.chain || []); } catch (e) { - console.error(e); + if ((e as DOMException).name !== "AbortError") console.error(e); } finally { setLoading(false); } + return () => controller.abort(); }; return ( diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx index b14a26e8..0c04357d 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx @@ -816,6 +816,7 @@ export function GraphWorkspace() { const handleRunPredictions = useCallback(async () => { if (!selectedNodeId) return; + setIsRunningPredictions(true); try { const response = await fetch("/api/enrich/links", { method: "POST", @@ -835,6 +836,8 @@ export function GraphWorkspace() { } catch (predictionError) { console.error("[GraphWorkspace] prediction failed", predictionError); setPredictions([]); + } finally { + setIsRunningPredictions(false); } }, [predictionType, selectedNodeId]); @@ -1609,6 +1612,7 @@ export function GraphWorkspace() { predictionType={predictionType} onPredictionTypeChange={setPredictionType} onRunPredictions={() => void handleRunPredictions()} + isRunningPredictions={isRunningPredictions} pathTargetId={pathTargetId} onPathTargetChange={setPathTargetId} onTracePath={() => void handleTracePath()}