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,
updateDirectiveGoal,
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<DirectiveSummary[]> | null = null;
const listSubscribers = new Set<(d: DirectiveSummary[]) => void>();
const detailCache = new Map<string, DirectiveWithSteps>();
const detailInflight = new Map<string, Promise<DirectiveWithSteps>>();
const detailSubscribers = new Map<string, Set<(d: DirectiveWithSteps) => 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<DirectiveSummary[]> {
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<DirectiveWithSteps> {
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<DirectiveSummary[]>(
() => listCache ?? [],
);
const [loading, setLoading] = useState<boolean>(listCache === null);
const [error, setError] = useState<string | null>(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<DirectiveWithSteps | null>(
() => (id ? detailCache.get(id) ?? null : null),
);
const [loading, setLoading] = useState<boolean>(
id !== undefined && !detailCache.has(id),
);
const [error, setError] = useState<string | null>(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 updateGoal = useCallback(async (goal: string) => {
if (!id) return;
await updateDirectiveGoal(id, goal);
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,
updateGoal, cleanup,
pickUpOrders: pickUpOrdersFn,
createPR,
};
}