"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { usePopoverDismiss } from "@/components/popover-dismiss"; import { ListChecksIcon, Loader2Icon } from "lucide-react"; import { useActivity } from "@/components/activity-dock"; import { formatTokens } from "@/components/usage-format"; /** * The extraction-coverage control (vision-extraction-coverage.md, settled * 2026-09-15): the graph view's window into WHAT THE PIPELINE HAS COVERED. * One button in the top-right chrome opens a panel with the exact coverage * of every session — covered/uncovered tokens and message counts — and a * per-session "Extract" button: the manual answer to an aborted run, a * pipeline error, or a session that went dormant under the chunk budget. * * Data: GET /api/graph/coverage (uncovered is DERIVED server-side — * fingerprint the transcript, diff the ledger — never dual-tracked). * Manual runs: POST /api/graph/coverage/extract; the run then surfaces in * the status chips + this panel's running row, and the graph refetches when * it finishes (the parent's runs-empty transition). The panel polls only * while open — no new standing poll. */ interface CoverageChat { chatId: string; title: string; updatedAt: string; totalMessages: number; coveredMessages: number; uncoveredMessages: number; coveredTokens: number; uncoveredTokens: number; running: boolean; } export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number }) { const [open, setOpen] = useState(false); // The dock's start-site nudge seam (accuracy wave, 2026-09-19) — see onExtract. const refreshActivity = useActivity().refresh; // Click anywhere outside (or Escape) closes the panel (2026-09-17) — // the root wraps button + panel, so the toggle itself keeps working. const rootRef = useRef(null); usePopoverDismiss(rootRef, open, () => setOpen(false)); const [chats, setChats] = useState([]); const [loaded, setLoaded] = useState(false); const [extracting, setExtracting] = useState([]); // click-pending ids (optimistic until the poll says running) const load = useCallback(async () => { try { const res = await fetch("/api/graph/coverage"); if (!res.ok) return; const data = (await res.json()) as { chats: CoverageChat[] }; setChats(data.chats); setLoaded(true); // Drop click-pending ids the server now reports as running (or // that have finished — coverage moved, so the row settled). setExtracting((ids) => ids.filter((id) => data.chats.find((c) => c.chatId === id)?.running === true)); } catch { /* transient — the next tick retries */ } }, []); useEffect(() => { if (!open) return; void load(); const timer = setInterval(load, 2000); return () => clearInterval(timer); }, [open, load, refreshKey]); const onExtract = async (chatId: string) => { setExtracting((ids) => [...ids, chatId]); try { const res = await fetch("/api/graph/coverage/extract", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chatId }), }); if (!res.ok && res.status !== 409) { console.error("manual extraction failed:", res.status); setExtracting((ids) => ids.filter((id) => id !== chatId)); return; } // 409 = already running (e.g. the trigger raced us): the row's // running flag takes over on the next poll. void load(); // The dock's start-site nudge (accuracy wave, 2026-09-19): the 202 // means setPhase already registered the run (synchronous, before the // response) — a refresh here is race-free; the 409 also refreshes so // an unseen in-flight run adopts at once. refreshActivity(); } catch { setExtracting((ids) => ids.filter((id) => id !== chatId)); } }; const pending = (chat: CoverageChat) => chat.running || extracting.includes(chat.chatId); return (
{open && (
pending(c)} onExtract={(c) => void onExtract(c.chatId)} />
)}
); } /** * The panel body — exported for static-render tests. Rows: exact coverage * numbers per session, "Extract" for the uncovered ones, "Running" while a * run is in flight. */ export function CoveragePanel({ chats, loaded, isBusy, onExtract, }: { chats: CoverageChat[]; loaded: boolean; isBusy: (chat: CoverageChat) => boolean; onExtract: (chat: CoverageChat) => void; }) { const anyUncovered = chats.some((c) => c.uncoveredTokens > 0 || c.uncoveredMessages > 0); return (
Extraction coverage uncovered first
{!anyUncovered && (

{loaded ? "Every session is fully extracted — nothing pending." : "…"}

)}
{chats.map((c) => ( onExtract(c)} /> ))}
); } function CoverageRow({ chat, busy, onExtract }: { chat: CoverageChat; busy: boolean; onExtract: () => void }) { return (
{chat.title}
{busy ? ( extracting… ) : ( <> {formatTokens(chat.coveredTokens)} covered {chat.uncoveredTokens > 0 && ( {formatTokens(chat.uncoveredTokens)} uncovered )} {chat.uncoveredTokens === 0 && · complete} {chat.uncoveredMessages > 0 && ( ({chat.uncoveredMessages} msgs) )} )}
); }