"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useActivity } from "@/components/activity-dock"; import { usePopoverDismiss } from "@/components/popover-dismiss"; import { useSettingsSaved } from "@/components/settings-signal"; import { formatTokens } from "@/components/usage-format"; import { timeAgo } from "@/components/sidebar"; /** * The extraction-coverage surfaces (extraction-UX redesign, owner-locked * dispatch 2026-09-24): the ring meter in the chat top bar and the coverage * block in the sidebar's session context menu — the two replacements for * the graph view's coverage panel (components/graph-coverage.tsx, retired * with this wave). Coverage is the SAME derivation, scoped to one session: * GET /api/chats/[id] now carries `coverage` (Platform's eed4b4e). * * The ring fills with uncoveredTokens / extractionChunkTokens — the chunk * budget the threshold setting (Graph ▸ extractionChunkTokens) pins: the * meter is "how close this session is to firing its next extraction run". * It DRAINS as a run folds (coverage re-derives per request), pulses while * a run is in flight, and vanishes entirely when the graph is off (the * master kill-switch means no extraction, so no meter). * * The manual affordance stays POST /api/graph/coverage/extract {chatId} * (409 = already running, BENIGN — the trigger raced us; the dock's * start-site nudge seam refreshes either way). */ /** Client-safe mirror of the server's SessionCoverage (src/server/chats.ts * — the GET /api/chats/[id] `coverage` block). */ export interface SessionCoverage { totalMessages: number; coveredCount: number; uncoveredCount: number; coveredTokens: number; uncoveredTokens: number; running: boolean; lastFoldedAt: string | null; } /** One session's coverage read — null on transport/404 (callers render * nothing rather than a fake zero; the honest-render doctrine). */ async function fetchCoverage(chatId: string): Promise { try { const res = await fetch(`/api/chats/${encodeURIComponent(chatId)}`); if (!res.ok) return null; const body = (await res.json()) as { coverage?: SessionCoverage | null }; return body.coverage ?? null; } catch { return null; } } /** The ring's denominator + visibility: the graph section of the settings * record (both fields optional — absent means enabled/4096, the factory * resolution; see GraphSettings). Null on fetch failure → no ring. */ export interface GraphRead { enabled: boolean; chunkTokens: number; } async function fetchGraphRead(): Promise { try { const res = await fetch("/api/settings"); if (!res.ok) return null; const body = (await res.json()) as { settings?: { graph?: { enabled?: boolean; extractionChunkTokens?: number } } }; const graph = body.settings?.graph; if (!graph) return null; return { enabled: graph.enabled !== false, chunkTokens: graph.extractionChunkTokens ?? 4096, }; } catch { return null; } } /** The manual-extract seam, shared by both surfaces. 409 is BENIGN (the * run is already in flight) — returns "running" so the caller flips to the * optimistic/running state instead of an error. Returns false only on a * real failure, where the caller reverts. */ async function requestExtract(chatId: string): Promise { 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) return false; return true; // 202 started, or 409 already-running — both "running" } catch { return false; } } // ─── The sidebar context menu's coverage block ────────────────────────────── /** * The info block at the top of the session context menu (owner-locked * redesign): what the extractor hasn't covered yet, plus the manual Extract * affordance — visually separated from Rename/Delete below (those stay the * two session-management actions). Fetched ON MENU OPEN (the block mounts * with the menu — on-demand, never eagerly), polled at 2s while a run is * in flight so the numbers settle live. No coverage (fetch failed, 404): * the whole block renders nothing — no fake zeros. */ export function CoverageMenuBlock({ chatId }: { chatId: string }) { const refreshActivity = useActivity().refresh; const [coverage, setCoverage] = useState(null); const [extracting, setExtracting] = useState(false); // optimistic until the server says running const lastUncoveredRef = useRef(null); const load = useCallback(async () => { const next = await fetchCoverage(chatId); if (next === null) return; // The optimistic state stands down once the server says running, or // once the fold moved coverage (the run finished). if ( next.running || (lastUncoveredRef.current !== null && next.uncoveredTokens !== lastUncoveredRef.current) ) { setExtracting(false); } lastUncoveredRef.current = next.uncoveredTokens; setCoverage(next); }, [chatId]); // On MENU OPEN (the block mounts with the menu — on-demand, never // eagerly): one read, then the 2s poll below takes over while running. useEffect(() => { lastUncoveredRef.current = null; setExtracting(false); void load(); }, [load]); // While a run is in flight the block polls at 2s (the old panel's // open-only cadence, bounded to the running state) — the numbers // settle live and the block ends in "fully extracted". useEffect(() => { if (coverage?.running !== true && !extracting) return; const timer = setInterval(() => void load(), 2000); return () => clearInterval(timer); }, [load, coverage?.running, extracting]); if (coverage === null) return null; const onExtract = async () => { setExtracting(true); // The dock's start-site nudge (the 202 registers the run before the // response; a 409 means one is already in flight) — refresh so the // run surfaces at once either way. refreshActivity(); const started = await requestExtract(chatId); if (!started) setExtracting(false); void load(); }; return ( void onExtract()} /> ); } /** The block's presentational core (exported for static-render tests): * the numbers, the running pulse, and the Extract affordance with its * optimistic "Running" state. */ export function CoverageMenuBody({ coverage, extracting, onExtract, }: { coverage: SessionCoverage; /** Click-pending (optimistic) until the server says running. */ extracting: boolean; onExtract: () => void; }) { const busy = coverage.running || extracting; const complete = coverage.uncoveredTokens === 0 && coverage.uncoveredCount === 0; return (
{busy ? ( extracting… ) : complete ? ( fully extracted ) : ( {formatTokens(coverage.uncoveredTokens)} uncovered {coverage.uncoveredCount} message{coverage.uncoveredCount === 1 ? "" : "s"} unextracted )}
); } // ─── The chat top bar's ring meter ────────────────────────────────────────── /** The donut's geometry (pure, exported for tests): the fill fraction * uncovered/chunk, clamped to [0,1] — at 1 the next run fires. */ export function ringFill(uncoveredTokens: number, chunkTokens: number): number { if (!(chunkTokens > 0)) return 0; return Math.min(1, Math.max(0, uncoveredTokens / chunkTokens)); } /** * The ring meter (owner-locked: "a donut next to the PhaseChip"): how * close this session is to its next extraction run. Click → the exact * numbers (covered/uncovered tokens, messages, the threshold, the last * fold). Vanishes entirely while the graph is disabled or before the * first successful read. While a run is in flight the ring PULSES (the * implementer's visual call per the dispatch — motion is the honest * state signal in this app's quiet register; no new chrome). * * Refresh chains: mount + `refreshKey` (the chat view bumps it at turn * settle, riding the existing refetch chains) + a 2s poll while running * (resets as the fold drains coverage) + the settings signal (the * threshold/kill-switch re-read live). */ export function CoverageRing({ chatId, refreshKey = 0 }: { chatId: string; refreshKey?: number }) { const [graph, setGraph] = useState(null); const [coverage, setCoverage] = useState(null); const [open, setOpen] = useState(false); const [nonce, setNonce] = useState(0); const rootRef = useRef(null); usePopoverDismiss(rootRef, open, () => setOpen(false)); // The activity/refresh chain (the dispatch's seam): runs can fire from // OTHER surfaces (the sidebar menu's Extract, the turn pipeline, another // tab) — no turn-settle bump announces those to this page. The dock's // feed knows; a run for THIS session appearing or settling in it // re-reads coverage (same runs-empty-transition the graph view rides). const feed = useActivity().feed; const runHere = feed.extractions.some((run) => run.chatId === chatId); useSettingsSaved((section) => { if (section === "graph") setNonce((n) => n + 1); }); useEffect(() => { let alive = true; void fetchGraphRead().then((g) => alive && setGraph(g)); return () => { alive = false; }; }, [nonce]); useEffect(() => { let alive = true; void fetchCoverage(chatId).then((c) => alive && setCoverage(c)); return () => { alive = false; }; }, [chatId, refreshKey, nonce, runHere]); // While a run is in flight: poll at 2s so the ring DRAINS as folds land // and settles at zero when the run completes (the "resets on // completion" requirement — coverage re-derives per request). useEffect(() => { if (coverage?.running !== true) return; const timer = setInterval(() => { void fetchCoverage(chatId).then((c) => { if (c !== null) setCoverage(c); }); }, 2000); return () => clearInterval(timer); }, [chatId, coverage?.running]); // Vanishes entirely when the graph is off, or before the first honest // read (no fake zeros — the doctrine). if (graph === null || !graph.enabled || coverage === null) return null; const fill = ringFill(coverage.uncoveredTokens, graph.chunkTokens); return (
{open && (
)}
); } /** The donut itself (exported for static-render tests): a hairline track * plus the fill arc — uncovered/chunk of the circumference, starting at * 12 o'clock. The whole ring pulses while a run is in flight. */ export function CoverageRingGlyph({ fill, running }: { fill: number; running: boolean }) { const C = 2 * Math.PI * 6; // r=6 in the 16px viewBox return ( ); } /** The popover body (exported for static-render tests): the exact numbers — * covered/uncovered tokens, message coverage, the threshold, the last * fold's age. */ export function CoverageRingDetail({ coverage, chunkTokens }: { coverage: SessionCoverage; chunkTokens: number }) { return ( <>
Extraction coverage
Uncovered {formatTokens(coverage.uncoveredTokens)} tokens
Covered {formatTokens(coverage.coveredTokens)} tokens
Messages {coverage.coveredCount} / {coverage.totalMessages}
Chunk threshold {formatTokens(chunkTokens)} tokens
Last extraction {coverage.lastFoldedAt ? timeAgo(coverage.lastFoldedAt) : "—"}
{coverage.running && (
Status extracting…
)}
); }