diff --git a/components/chat.tsx b/components/chat.tsx index 3d2c655..f40bac1 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -35,6 +35,7 @@ import { QuestionDock } from "@/components/question-dock"; import { SidebarToggle } from "@/components/sidebar"; import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core"; import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider"; +import { CoverageRing } from "@/components/extraction-coverage"; import { findPendingToolCall, phaseAt3, phaseTimeline, streamingToolActivity } from "@/src/core"; import { slotsConfigured, type ModelsSettings } from "@/src/core"; import { useSettingsSaved } from "@/components/settings-signal"; @@ -478,6 +479,9 @@ export function Chat({ // The live plan popup (wave 4): opened by any transcript plan block, the // top-bar plan chip, and the dock band's marginalia. const [planOpen, setPlanOpen] = useState(false); + // The coverage ring's refresh chain (extraction-UX redesign): bumped at + // turn settle — the moment this session's uncovered mass changes. + const [coverageTick, setCoverageTick] = useState(0); // The mermaid focus (chrome-melt wave, 2026-09-19): md-blocks' expand // button registers the chart source here; the overlay (and the plan // popup) mount INSIDE the session-area div — both are session-scoped, so @@ -584,6 +588,10 @@ export function Chat({ // immediate: turns-only refetch — the stats bar updates even while // the server's persist/endTurn lags the client by milliseconds void syncTurns(); + // The coverage ring re-reads at turn settle too (extraction-UX + // redesign, 2026-09-24): this turn's messages just joined the + // uncovered mass; the ring's own running-poll handles runs. + setCoverageTick((t) => t + 1); // Turn-end nudge (accuracy wave, 2026-09-19): the extraction // pipeline fires at this turn's onFinish — the dock's poll would // discover a started run up to 10s late. Refetch the activity feed @@ -922,6 +930,11 @@ export function Chat({ Plan )} + {/* The extraction ring (owner-locked redesign, 2026-09-24): fills + with uncovered vs the chunk threshold, pulses while a run is in + flight, vanishes when the graph is off. Click → the exact + numbers. Before the phase label — the chips cluster right. */} + diff --git a/components/extraction-coverage.tsx b/components/extraction-coverage.tsx new file mode 100644 index 0000000..9758c2b --- /dev/null +++ b/components/extraction-coverage.tsx @@ -0,0 +1,393 @@ +"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… +
+ )} +
+ + ); +} \ No newline at end of file diff --git a/components/graph-coverage.tsx b/components/graph-coverage.tsx deleted file mode 100644 index e49e5b9..0000000 --- a/components/graph-coverage.tsx +++ /dev/null @@ -1,203 +0,0 @@ -"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) - )} - - )} -
-
- -
- ); -} \ No newline at end of file diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 4264f18..ba71be4 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -66,7 +66,6 @@ import Link from "next/link"; import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; import { incrementalLayout, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core"; import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; -import { GraphCoverageControl } from "./graph-coverage.tsx"; import { usePopoverDismiss } from "@/components/popover-dismiss"; import { useActivity } from "@/components/activity-dock"; import { MenuShell } from "@/components/menu-shell.tsx"; @@ -1398,12 +1397,6 @@ export function GraphView() { )} - {/* Extraction coverage — between search and import (2026-09-17); - always present, even when the graph is EMPTY — an empty graph - with unextracted sessions is exactly when it matters (extract - → the graph gains nodes). The panel re-reads when background - runs settle (refreshTick). */} -
{!isEmpty && ( <> {/* Import notes — its own container (36px, header band). */} diff --git a/components/phase-divider.tsx b/components/phase-divider.tsx index 1d4c602..d8a63a5 100644 --- a/components/phase-divider.tsx +++ b/components/phase-divider.tsx @@ -31,18 +31,20 @@ const CHIP_LABEL: Record = { teach: "Teaching", }; -/** The header chip: the phase label, monochrome — label-only by design - * (see the component note: the pill's curve suits text, not glyphs). */ +/** The header label: the phase as a quiet flat tag (owner, + * extraction-UX redesign 2026-09-24 — the UN-PILL round): no border, no + * pill chrome, no padding — the Plan chip is the only button-shaped thing + * in the top bar now. Still pure decoration (owner ask, 2026-09-17): no + * tooltip, not selectable, not interactive in any way — no pointer events + * at all, so it is part of the drag surface instead of a carved-out + * target. */ export function PhaseChip({ phase }: { phase: Phase3 }) { - // Pure decoration (owner ask, 2026-09-17): no tooltip, not selectable, - // not interactive in any way — no pointer events at all, so it is part - // of the drag surface instead of a carved-out target. return ( diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 8550e1a..0f266d9 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -8,6 +8,7 @@ import { DeleteSessionDialog } from "@/components/delete-session-dialog"; import type { ChatDeleteMode } from "@/src/core"; import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react"; import { ActivitySection } from "@/components/activity-dock"; +import { CoverageMenuBlock } from "@/components/extraction-coverage"; import { LhMark } from "@/components/lh-mark"; import { BrailleSpinner } from "@/components/braille-spinner"; import { KeyboardShortcuts } from "@/components/keyboard-shortcuts"; @@ -376,11 +377,13 @@ function SessionsSidebar() { const listRef = useRef(null); /* ── Wave 6 chunk A: context menu + inline rename + deletion ── - Right-click a session row → MenuShell with exactly two actions - (owner ruling): Rename (inline, below) and Delete (the two-option - dialog). The anchor is the row that was right-clicked — assigned - into a ref before the state flip so the shell measures it on its - first (hidden) pass. */ + Right-click a session row → MenuShell: the extraction coverage + block (owner-locked redesign, 2026-09-24 — numbers + the manual + Extract affordance, separated by a hairline) above the two + session-management actions (owner ruling): Rename (inline, below) + and Delete (the two-option dialog). The anchor is the row that was + right-clicked — assigned into a ref before the state flip so the + shell measures it on its first (hidden) pass. */ const [ctxChatId, setCtxChatId] = useState(null); const ctxAnchorRef = useRef(null); const [renamingId, setRenamingId] = useState(null); @@ -648,16 +651,25 @@ function SessionsSidebar() { - {/* The context menu (wave 6): exactly two actions (owner - ruling — no more). MenuShell owns placement/focus/arrow + {/* The context menu (wave 6 + the extraction-UX redesign): the + coverage block above, then exactly two management actions + (owner ruling — no more). MenuShell owns placement/focus/arrow cycling; the dismissal effect above owns the contract. */} {ctxChatId !== null && ( + {/* The extraction block (owner-locked redesign, 2026-09-24): + what the extractor hasn't covered yet + the manual Extract + affordance — visually separated from the management + actions below by a hairline. Fetched on menu open (the + block mounts WITH the menu); 409 = benign (already + running). Renders nothing on a failed read. */} + +