import { useState, useEffect, useCallback } from "react"; import { type DirectiveSummary, type DirectiveWithSteps, type CreateDirectiveRequest, type UpdateDirectiveRequest, type CreateDirectiveStepRequest, listDirectives, createDirective, getDirective, updateDirective, deleteDirective, createDirectiveStep, deleteDirectiveStep, startDirective, pauseDirective, advanceDirective, completeDirectiveStep, failDirectiveStep, skipDirectiveStep, cleanupDirective, pickUpOrders as pickUpOrdersApi, createDirectivePR, } from "../lib/api"; // ============================================================================= // Stale-while-revalidate cache // // Switching between directives in the document-mode sidebar used to feel // noticeably laggy because every navigation re-fired the GET request and // blocked the UI on it. We now keep a process-wide cache (mirrors the // pattern in `useUserSettings.ts`) and use the cache as the immediate // render value; a fresh fetch fires in the background and notifies all // subscribed hook instances when it lands. // // Mutations (start/pause/etc.) push their result back into the cache so // successive reads are also instant. Hard `refresh()` calls bypass the // cache age check and refetch. // ============================================================================= let listCache: DirectiveSummary[] | null = null; let listInflight: Promise | null = null; const listSubscribers = new Set<(d: DirectiveSummary[]) => void>(); const detailCache = new Map(); const detailInflight = new Map>(); const detailSubscribers = new Map void>>(); function notifyList(value: DirectiveSummary[]) { for (const sub of listSubscribers) sub(value); } function notifyDetail(id: string, value: DirectiveWithSteps) { const subs = detailSubscribers.get(id); if (!subs) return; for (const sub of subs) sub(value); } function fetchList(): Promise { if (listInflight) return listInflight; listInflight = listDirectives() .then((res) => { listCache = res.directives; notifyList(res.directives); return res.directives; }) .finally(() => { listInflight = null; }); return listInflight; } function fetchDetail(id: string): Promise { const existing = detailInflight.get(id); if (existing) return existing; const p = getDirective(id) .then((d) => { detailCache.set(id, d); notifyDetail(id, d); return d; }) .finally(() => { detailInflight.delete(id); }); detailInflight.set(id, p); return p; } export function useDirectives() { const [directives, setDirectives] = useState( () => listCache ?? [], ); const [loading, setLoading] = useState(listCache === null); const [error, setError] = useState(null); useEffect(() => { let mounted = true; const sub = (value: DirectiveSummary[]) => { if (!mounted) return; setDirectives(value); setLoading(false); }; listSubscribers.add(sub); // Always kick a background fetch on mount so we don't ship stale data; // subscribers see the new value when it lands. UI shows the cached // value immediately if there is one. fetchList().catch((e) => { if (!mounted) return; setError(e instanceof Error ? e.message : "Failed to load directives"); setLoading(false); }); return () => { mounted = false; listSubscribers.delete(sub); }; }, []); const refresh = useCallback(async () => { try { const res = await listDirectives(); listCache = res.directives; notifyList(res.directives); setError(null); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load directives"); } }, []); const create = useCallback(async (req: CreateDirectiveRequest) => { const d = await createDirective(req); await refresh(); return d; }, [refresh]); const remove = useCallback(async (id: string) => { await deleteDirective(id); detailCache.delete(id); await refresh(); }, [refresh]); return { directives, loading, error, refresh, create, remove }; } export function useDirective(id: string | undefined) { const [directive, setDirective] = useState( () => (id ? detailCache.get(id) ?? null : null), ); const [loading, setLoading] = useState( id !== undefined && !detailCache.has(id), ); const [error, setError] = useState(null); // Silently refresh without setting loading state (for polls) const silentRefresh = useCallback(async () => { if (!id) return; try { const d = await getDirective(id); detailCache.set(id, d); notifyDetail(id, d); setError(null); } catch { // Don't overwrite existing data on poll failure } }, [id]); // Full refresh with loading state (for explicit refresh) const refresh = useCallback(async () => { if (!id) return; try { setError(null); const d = await getDirective(id); detailCache.set(id, d); notifyDetail(id, d); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load directive"); } }, [id]); // Subscribe to detail updates for this id; render cached value // immediately and kick a background fetch if the cache is missing. useEffect(() => { if (!id) { setDirective(null); setLoading(false); setError(null); return; } let mounted = true; const sub = (value: DirectiveWithSteps) => { if (!mounted) return; setDirective(value); setLoading(false); }; let subs = detailSubscribers.get(id); if (!subs) { subs = new Set(); detailSubscribers.set(id, subs); } subs.add(sub); const cached = detailCache.get(id); if (cached) { setDirective(cached); setLoading(false); } else { setDirective(null); setLoading(true); } // Always kick a fresh fetch so polling-driven UIs see updates. fetchDetail(id).catch((e) => { if (!mounted) return; setError(e instanceof Error ? e.message : "Failed to load directive"); setLoading(false); }); return () => { mounted = false; subs!.delete(sub); if (subs!.size === 0) detailSubscribers.delete(id); }; }, [id]); // Auto-poll while directive is active, has an orchestrator task, or has a completion task useEffect(() => { if (!directive) return; const needsPolling = directive.status === "active" || directive.orchestratorTaskId != null || directive.completionTaskId != null; if (!needsPolling) return; const interval = setInterval(silentRefresh, 5000); return () => clearInterval(interval); }, [directive?.status, directive?.orchestratorTaskId, directive?.completionTaskId, silentRefresh]); const update = useCallback(async (req: UpdateDirectiveRequest) => { if (!id) return; await updateDirective(id, req); await refresh(); }, [id, refresh]); const addStep = useCallback(async (req: CreateDirectiveStepRequest) => { if (!id) return; await createDirectiveStep(id, req); await refresh(); }, [id, refresh]); const removeStep = useCallback(async (stepId: string) => { if (!id) return; await deleteDirectiveStep(id, stepId); await refresh(); }, [id, refresh]); const start = useCallback(async () => { if (!id) return; await startDirective(id); await refresh(); }, [id, refresh]); const pause = useCallback(async () => { if (!id) return; await pauseDirective(id); await refresh(); }, [id, refresh]); const advance = useCallback(async () => { if (!id) return; await advanceDirective(id); await refresh(); }, [id, refresh]); const completeStep = useCallback(async (stepId: string) => { if (!id) return; await completeDirectiveStep(id, stepId); await refresh(); }, [id, refresh]); const failStep = useCallback(async (stepId: string) => { if (!id) return; await failDirectiveStep(id, stepId); await refresh(); }, [id, refresh]); const skipStep = useCallback(async (stepId: string) => { if (!id) return; await skipDirectiveStep(id, stepId); await refresh(); }, [id, refresh]); const cleanup = useCallback(async () => { if (!id) return; await cleanupDirective(id); await refresh(); }, [id, refresh]); const pickUpOrdersFn = useCallback(async () => { if (!id) return null; const result = await pickUpOrdersApi(id); await refresh(); return result; }, [id, refresh]); const createPR = useCallback(async () => { if (!id) return; await createDirectivePR(id); await refresh(); }, [id, refresh]); return { directive, loading, error, refresh, update, addStep, removeStep, start, pause, advance, completeStep, failStep, skipStep, cleanup, pickUpOrders: pickUpOrdersFn, createPR, }; }