// Client-side store holding the analyzer outputs (taxonomy, glossary terms, // requirements, findings, last-run metadata). Sidebar reads from here for // counts/timestamps; panes read for full content. Refetches on demand. "use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import type { SectionPaneId } from "./openPanesStore"; export type ClientTermStatus = "accepted" | "suggested" | "deprecated"; export interface ClientTerm { id: string; parentId: string | null; label: string; definition: string | null; synonyms: string[]; linkedBlockId: string | null; /// Review state: "accepted" surfaces normally, "suggested" / "deprecated" /// are pending the user's keep/discard decision. status: ClientTermStatus; pinned: boolean; /// True when the user authored / edited the definition by hand. Future /// Analyze runs skip this term's definition while the flag is on. definitionPinned: boolean; } export type ReviewStatus = "accepted" | "suggested" | "deprecated"; export interface ClientRequirement { id: string; tag: string; text: string; tracedToIds: string[]; unsupported: boolean; /// Term this requirement is conceptually about, if any. Powers the back-link /// from a requirement row → TermDetail. linkedTermId: string | null; status: ReviewStatus; pinned: boolean; } export interface ClientFinding { id: string; kind: "assumption" | "risk" | "inconsistency"; text: string; linkedElementIds: string[]; confidence: number; severity: string | null; validationCode: string | null; /// "suggested" | "accepted" | "deprecated" | "dismissed" | "resolved" | /// (legacy) "open". Visible-only set is fetched from the API; dismissed and /// resolved are filtered server-side. status: string; pinned: boolean; } export interface ClientRun { status: "running" | "succeeded" | "failed"; startedAt: string; finishedAt: string | null; errorMessage: string | null; } // All sections map 1:1 to server sections after T3 (taxonomy + glossary // collapsed into "concepts"). Legacy values "taxonomy" / "glossary" still // work because the server normalizes them. export type AnalyzeSection = SectionPaneId | "all" | "taxonomy" | "glossary"; interface AnalysisStoreValue { terms: ClientTerm[]; requirements: ClientRequirement[]; findings: ClientFinding[]; runs: Partial>; inFlight: Set; refresh(): Promise; analyze(section: AnalyzeSection): Promise; /** Lookup helper used by TermDetail. */ getTerm(termId: string): ClientTerm | null; /** Apply a user decision to a pending term. Optimistically updates local * state; on server error refreshes to recover. */ decideTerm(termId: string, decision: "keep" | "discard"): Promise; /** Set a term's definition by user action. Empty string clears it AND * clears the pin (future Analyze fills it again from prose). */ setTermDefinition(termId: string, definition: string | null): Promise; decideRequirement(reqId: string, decision: "keep" | "discard"): Promise; /** Findings support an extra "resolve" decision (semantically distinct from * "dismissed" — same effect on visibility, but signals "I fixed it"). */ decideFinding(findingId: string, decision: "keep" | "discard" | "resolve" | "restore"): Promise; } const Ctx = createContext(null); interface ProviderProps { projectId: string; children: React.ReactNode; } export function AnalysisStoreProvider({ projectId, children }: ProviderProps) { const [terms, setTerms] = useState([]); const [requirements, setRequirements] = useState([]); const [findings, setFindings] = useState([]); const [runs, setRuns] = useState>>({}); const [inFlight, setInFlight] = useState>(new Set()); const refresh = useCallback(async () => { try { const [t, r, f, runsRes] = await Promise.all([ fetch(`/api/projects/${encodeURIComponent(projectId)}/taxonomy`).then(x => (x.ok ? x.json() : { terms: [] })), fetch(`/api/projects/${encodeURIComponent(projectId)}/requirements`).then(x => (x.ok ? x.json() : { requirements: [] })), fetch(`/api/projects/${encodeURIComponent(projectId)}/findings?include=all`).then(x => (x.ok ? x.json() : { findings: [] })), fetch(`/api/projects/${encodeURIComponent(projectId)}/runs`).then(x => (x.ok ? x.json() : { runs: {} })), ]); setTerms((t.terms as ClientTerm[]) ?? []); setRequirements((r.requirements as ClientRequirement[]) ?? []); setFindings((f.findings as ClientFinding[]) ?? []); setRuns((runsRes.runs as Partial>) ?? {}); } catch (err) { console.error("[analysisStore] refresh failed:", err); } }, [projectId]); const analyze = useCallback( async (section: AnalyzeSection) => { setInFlight(prev => { const next = new Set(prev); next.add(section); return next; }); try { const url = `/api/projects/${encodeURIComponent(projectId)}/analyze` + (section === "all" ? "" : `?section=${section}`); const res = await fetch(url, { method: "POST" }); if (!res.ok) { const body = await res.text(); console.error("[analyze] HTTP", res.status, body.slice(0, 200)); } await refresh(); } catch (err) { console.error("[analyze] failed:", err); } finally { setInFlight(prev => { const next = new Set(prev); next.delete(section); return next; }); } }, [projectId, refresh] ); const getTerm = useCallback( (termId: string): ClientTerm | null => terms.find(t => t.id === termId) ?? null, [terms] ); const decideRequirement = useCallback( async (reqId: string, decision: "keep" | "discard") => { setRequirements(prev => { if (decision === "discard") return prev.filter(r => r.id !== reqId); return prev.map(r => r.id === reqId ? { ...r, status: "accepted" as ReviewStatus, pinned: r.status === "deprecated" ? true : r.pinned, } : r ); }); try { const res = await fetch( `/api/projects/${encodeURIComponent(projectId)}/requirements/${encodeURIComponent(reqId)}/decision`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ decision }), } ); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (err) { console.error("[analysisStore] decideRequirement failed:", err); await refresh(); } }, [projectId, refresh] ); const decideFinding = useCallback( async (findingId: string, decision: "keep" | "discard" | "resolve" | "restore") => { // Optimistic update by terminal status: // keep / restore → "accepted" (visible in Kept list) // discard → "dismissed" (visible in Discarded drawer) // resolve → "resolved" (visible in Discarded drawer) const nextStatus = decision === "keep" || decision === "restore" ? "accepted" : decision === "discard" ? "dismissed" : "resolved"; setFindings(prev => prev.map(f => f.id === findingId ? { ...f, status: nextStatus, pinned: decision === "keep" && f.status === "deprecated" ? true : f.pinned, } : f ) ); try { const res = await fetch( `/api/projects/${encodeURIComponent(projectId)}/findings/${encodeURIComponent(findingId)}/decision`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ decision }), } ); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (err) { console.error("[analysisStore] decideFinding failed:", err); await refresh(); } }, [projectId, refresh] ); const setTermDefinition = useCallback( async (termId: string, definition: string | null) => { const trimmed = (definition ?? "").trim(); // Optimistic update: pin when non-empty, clear pin when empty. setTerms(prev => prev.map(t => t.id === termId ? { ...t, definition: trimmed.length > 0 ? trimmed : null, definitionPinned: trimmed.length > 0, } : t ) ); try { const res = await fetch( `/api/projects/${encodeURIComponent(projectId)}/terms/${encodeURIComponent(termId)}/definition`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ definition: trimmed.length > 0 ? trimmed : null }), } ); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (err) { console.error("[analysisStore] setTermDefinition failed:", err); await refresh(); } }, [projectId, refresh] ); const decideTerm = useCallback( async (termId: string, decision: "keep" | "discard") => { // Optimistic update: keep → accepted (+ pinned if previously deprecated); // discard → drop from local state. setTerms(prev => { if (decision === "discard") return prev.filter(t => t.id !== termId); return prev.map(t => t.id === termId ? { ...t, status: "accepted" as ClientTermStatus, pinned: t.status === "deprecated" ? true : t.pinned, } : t ); }); try { const res = await fetch( `/api/projects/${encodeURIComponent(projectId)}/terms/${encodeURIComponent(termId)}/decision`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ decision }), } ); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (err) { console.error("[analysisStore] decideTerm failed:", err); await refresh(); } }, [projectId, refresh] ); useEffect(() => { void refresh(); }, [refresh]); // First-load auto-analyze: if the project has a non-empty document but // nothing has been analyzed yet (no runs of any kind), kick off a full // Analyze in the background so the editor lands populated. Matches the // seed-finalize UX for the demo project and any imported docs. const bootstrappedRef = useRef(false); useEffect(() => { if (bootstrappedRef.current) return; if (Object.keys(runs).length > 0) return; if (terms.length > 0 || requirements.length > 0 || findings.length > 0) return; let cancelled = false; (async () => { try { const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/document`); if (!res.ok) return; const body = (await res.json()) as { doc?: unknown }; const hasDoc = !!body.doc; if (!hasDoc || cancelled || bootstrappedRef.current) return; bootstrappedRef.current = true; void analyze("all"); } catch { /* ignore */ } })(); return () => { cancelled = true; }; }, [projectId, runs, terms.length, requirements.length, findings.length, analyze]); // Poll while any AnalysisRun is still "running" (e.g. the seed-finalize // background pipeline). Stop polling once everything has settled. useEffect(() => { const anyRunning = Object.values(runs).some(r => r?.status === "running"); if (!anyRunning) return; const handle = setInterval(() => void refresh(), 3000); return () => clearInterval(handle); }, [runs, refresh]); const value = useMemo( () => ({ terms, requirements, findings, runs, inFlight, refresh, analyze, getTerm, decideTerm, decideRequirement, decideFinding, setTermDefinition, }), [ terms, requirements, findings, runs, inFlight, refresh, analyze, getTerm, decideTerm, decideRequirement, decideFinding, setTermDefinition, ] ); return {children}; } export function useAnalysis(): AnalysisStoreValue { const ctx = useContext(Ctx); if (!ctx) throw new Error("useAnalysis must be inside "); return ctx; }