import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { bootCwd, fetchPlanDetail, fetchPlanFile, fetchPlans, type ArtifactReview, type ArtifactState, type PlanDetail, type PlanSummary, } from "../api"; import { useHive } from "../store"; import RelTime from "../hooks/RelTime"; // The Plans tab is now a slim two-pane status view over OpenSpec changes. The // actual review/annotation happens in the self-hosted Plannotator UI, rendered // inline in an iframe on our own dashboard server at /pl-review/?rid=. // Navigating between changes/artifacts just re-renders the iframe — zero // per-review processes. const STATUS_LABEL: Record = { "no-tasks": "no tasks", "in-progress": "in progress", complete: "complete", }; function StatusBadge({ status }: { status: PlanSummary["status"] }) { return {STATUS_LABEL[status] || status}; } function VerdictPill({ verdict }: { verdict: "red" | "yellow" | "green" }) { return {verdict}; } // Map an artifact to the markdown path the review UI should load. Single-file // artifacts (proposal/design/tasks) are ".md"; specs stays as OpenSpec's // glob because the server expands it into a bounded combined review document. function artifactFile(a: ArtifactState, _files: string[]): string { if (a.outputPath.includes("*")) return a.outputPath; return a.outputPath || `${a.id}.md`; } function ridFor(changeId: string, a: ArtifactState, files: string[]): string { return `${changeId}#${artifactFile(a, files)}`; } function artifactPathFromRid(rid: string): string { return rid.includes("#") ? rid.slice(rid.indexOf("#") + 1) : "proposal.md"; } // A chip for an AUTHORED artifact (exists on disk). Two-stage review state: // awaiting the reviewer AGENT, ready for the HUMAN, approved, or denied. Only // authored artifacts are shown; unwritten ones surface as an "up next" hint, // since OpenSpec "ready" means "cleared to author", not "ready to review". function reviewState(r?: ArtifactReview): { label: string; cls: string } { if (!r) return { label: "", cls: "" }; if (r.humanVerdict === "green") return { label: "approved", cls: "state-approved" }; if (r.humanVerdict === "red") return { label: "changes requested", cls: "state-denied" }; if (r.humanReviewReady) return { label: "review now", cls: "state-review" }; if (r.authored && !r.agentCleared) return { label: "agent review", cls: "state-agent" }; return { label: "", cls: "" }; } function ArtifactChip({ a, review, changeId, files, selectedRid, onSelect, }: { a: ArtifactState; review?: ArtifactReview; changeId: string; files: string[]; selectedRid: string; onSelect: (rid: string) => void }) { const rid = ridFor(changeId, a, files); const st = reviewState(review); return ( ); } function inlineMarkdown(text: string): ReactNode[] { const parts: ReactNode[] = []; const re = /(\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*)/g; let last = 0; let m: RegExpExecArray | null; while ((m = re.exec(text))) { if (m.index > last) parts.push(text.slice(last, m.index)); const token = m[0]; const key = `${m.index}-${token}`; if (token.startsWith("`")) parts.push({token.slice(1, -1)}); else if (token.startsWith("**")) parts.push({token.slice(2, -2)}); else { const close = token.indexOf("]("); const label = token.slice(1, close); const href = token.slice(close + 2, -1); parts.push({label}); } last = m.index + token.length; } if (last < text.length) parts.push(text.slice(last)); return parts; } function MarkdownView({ markdown }: { markdown: string }) { const lines = markdown.replace(/\r\n/g, "\n").split("\n"); const nodes: ReactNode[] = []; let i = 0; const paragraph = (start: number) => { const acc: string[] = []; while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|[-*]\s+|\d+\.\s+|>\s?|```|\|)/.test(lines[i].trim())) acc.push(lines[i++].trim()); nodes.push(

{inlineMarkdown(acc.join(" "))}

); }; while (i < lines.length) { const line = lines[i]; const trimmed = line.trim(); const key = `${i}-${trimmed.slice(0, 12)}`; if (!trimmed) { i++; continue; } if (trimmed.startsWith("```")) { const lang = trimmed.slice(3).trim(); const code: string[] = []; i++; while (i < lines.length && !lines[i].trim().startsWith("```")) code.push(lines[i++]); if (i < lines.length) i++; nodes.push(
{code.join("\n")}
); continue; } const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed); if (heading) { const level = heading[1].length; const children = inlineMarkdown(heading[2]); nodes.push(level === 1 ?

{children}

: level === 2 ?

{children}

:

{children}

); i++; continue; } if (/^[-*]\s+/.test(trimmed)) { const items: ReactNode[] = []; while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) { items.push(
  • {inlineMarkdown(lines[i].trim().replace(/^[-*]\s+/, ""))}
  • ); i++; } nodes.push(
      {items}
    ); continue; } if (/^\d+\.\s+/.test(trimmed)) { const items: ReactNode[] = []; while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) { items.push(
  • {inlineMarkdown(lines[i].trim().replace(/^\d+\.\s+/, ""))}
  • ); i++; } nodes.push(
      {items}
    ); continue; } if (trimmed.startsWith(">")) { const quote: string[] = []; while (i < lines.length && lines[i].trim().startsWith(">")) quote.push(lines[i++].trim().replace(/^>\s?/, "")); nodes.push(
    {inlineMarkdown(quote.join(" "))}
    ); continue; } if (trimmed.startsWith("|") && trimmed.endsWith("|")) { const rows: string[][] = []; while (i < lines.length && lines[i].trim().startsWith("|") && lines[i].trim().endsWith("|")) { const cells = lines[i].trim().slice(1, -1).split("|").map((c) => c.trim()); if (!cells.every((c) => /^:?-{3,}:?$/.test(c))) rows.push(cells); i++; } const [head, ...body] = rows; nodes.push({(head || []).map((c, n) => )}{body.map((r, n) => {r.map((c, x) => )})}
    {inlineMarkdown(c)}
    {inlineMarkdown(c)}
    ); continue; } paragraph(i); } return
    {nodes}
    ; } export default function Plans(props: { search: string }) { // The plan store is a per-project OpenSpec tree. Prefer the cwd of the session // in scope, but the dashboard is GLOBAL and its "current session" may belong to // an unrelated project (or there may be no session at all for a fresh OpenSpec // project). Fall back to the server's boot project cwd so the list is stable // and doesn't flash-then-vanish when a foreign session becomes "current". const scopeCwd = useHive((s) => { if (s.scope.level === "session") return s.currentSession?.cwd; if (s.scope.level === "project") return s.scopedSessions.find((x) => x.cwd)?.cwd; return undefined; // fleet scope: don't pin to an arbitrary project's session }); const [fallbackCwd, setFallbackCwd] = useState(undefined); useEffect(() => { void bootCwd().then((c) => setFallbackCwd(c || undefined)); }, []); const cwd = scopeCwd || fallbackCwd; const [plans, setPlans] = useState([]); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState(null); const [detail, setDetail] = useState(null); const [rid, setRid] = useState(""); const [fullscreen, setFullscreen] = useState(false); const [readOnlyMarkdown, setReadOnlyMarkdown] = useState(null); // Esc exits the fullscreen review. useEffect(() => { if (!fullscreen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setFullscreen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [fullscreen]); const loadPlans = useCallback(async () => { if (!cwd) return; setLoading(true); const list = await fetchPlans(cwd); setPlans(list); setLoading(false); }, [cwd]); const selectPlan = useCallback(async (changeId: string) => { setSelected(changeId); setDetail(null); const d = await fetchPlanDetail(changeId, cwd); setDetail(d); // Default the review to the first authored artifact, else the proposal. const files = d?.files || []; const firstDone = d?.artifacts.find((a) => a.status === "done"); setRid(firstDone ? ridFor(changeId, firstDone, files) : `${changeId}#proposal.md`); }, [cwd]); useEffect(() => { void loadPlans(); }, [loadPlans]); const filtered = useMemo(() => { const q = props.search.toLowerCase(); return plans.filter((p) => !q || p.changeId.toLowerCase().includes(q)); }, [plans, props.search]); const reviewSrc = rid ? `/pl-review/?rid=${encodeURIComponent(rid)}${cwd ? `&cwd=${encodeURIComponent(cwd)}` : ""}` : ""; const selectedArtifact = useMemo(() => (detail?.artifacts || []).find((a) => ridFor(detail!.changeId, a, detail!.files) === rid), [detail, rid]); const selectedReview = useMemo(() => detail?.artifactReview.find((r) => r.id === selectedArtifact?.id), [detail, selectedArtifact]); // A red human verdict is not final: it means feedback was requested and the // same artifact should become reviewable again after the planner revises it. // Only green locks the artifact into read-only mode. const reviewFinal = selectedReview?.humanVerdict === "green"; const artifactPath = artifactPathFromRid(rid); useEffect(() => { let cancelled = false; setReadOnlyMarkdown(null); if (!detail || !rid || !cwd || !reviewFinal) return; void fetchPlanFile(detail.changeId, artifactPath, cwd).then((file) => { if (!cancelled) setReadOnlyMarkdown(file.content ?? "_Unable to load reviewed artifact._"); }); return () => { cancelled = true; }; }, [artifactPath, cwd, detail, reviewFinal, rid]); // The embedded review UI cannot notify this React tree after approve/deny // because it is a vendored iframe. Poll while the selected artifact is awaiting // human approval, then swap to read-only markdown only once it is approved. // A red verdict stays reviewable so the revision loop can reopen Plannotator. useEffect(() => { if (!detail || !selectedReview?.humanReviewReady || selectedReview.humanVerdict === "green" || !selected) return; const timer = window.setInterval(() => { void fetchPlanDetail(selected, cwd).then((d) => { if (d) setDetail(d); }); }, 3000); return () => window.clearInterval(timer); }, [cwd, detail, selected, selectedReview]); // Only AUTHORED artifacts (on disk) are reviewable; the single next unwritten // one is surfaced as an "up next" hint. OpenSpec's "specs" delta is what makes // a change validatable, so before it's authored we show "in progress" instead // of a red validation error (a fresh change failing validation is expected). const authored = useMemo(() => (detail?.artifacts || []).filter((a) => a.status === "done"), [detail]); const upNext = useMemo(() => (detail?.artifacts || []).find((a) => a.status === "ready"), [detail]); const specsAuthored = useMemo(() => authored.some((a) => a.id === "specs"), [authored]); return (
    OpenSpec changes
    {(!loading || plans.length) ? ( filtered.length ? filtered.map((p) => ( )) :
    No OpenSpec changes for this project yet.
    ) :
    Loading…
    }
    {!detail ? (
    Select a change to review its artifacts.
    ) : ( <>
    {detail.changeId} {/* Validation only reads as an ERROR once specs exist (a change is expected to fail validation until its spec deltas are authored — before that it's simply in progress). */} {specsAuthored ? ( {detail.validation.passed ? "✓ valid" : `✗ ${detail.validation.failed} validation issue(s)`} ) : ( in progress )} {detail.readyToExecute && ready to execute}
    {authored.length ? authored.map((a) => ( r.id === a.id)} changeId={detail.changeId} files={detail.files} selectedRid={rid} onSelect={setRid} /> )) : No artifacts authored yet.} {upNext && up next: {upNext.id}}
    {/* Surface real validation issues only once specs are authored. */} {specsAuthored && !detail.validation.passed && detail.validation.issues.length > 0 && (
      {detail.validation.issues.slice(0, 5).map((iss, i) => (
    • {iss.message}
    • ))}
    )}
    {reviewSrc ? ( <>
    {artifactPath} {selectedReview?.humanVerdict && ( {selectedReview.humanVerdict === "green" ? "approved" : "changes requested"} )}
    {!reviewFinal && ↗ New tab}
    {reviewFinal ? (
    {readOnlyMarkdown === null ?
    Loading reviewed artifact…
    : }
    ) : (