Extraction surfaces: sidebar coverage block, chat-bar ring, phase un-pill, panel death

The owner-locked extraction-UX redesign (dispatch 2026-09-24), UI
chunk over Platform's data layer (eed4b4e — GET /api/chats/[id]
carries `coverage`, the threshold is Graph ▸ extractionChunkTokens):

1. SIDEBAR BLOCK (components/extraction-coverage.tsx +
   sidebar's session menu): the coverage info block at the TOP of
   the context menu — uncovered tokens, uncovered messages, running
   state — then the Extract affordance (optimistic "extracting… /
   Running" until the server says running; 409 = benign, the trigger
   raced us), fetched ON MENU OPEN (the block mounts with the menu —
   on-demand, never eagerly), polled at 2s while a run is in flight.
   Visually separated from Rename/Delete by a hairline; those stay
   the two session-management actions. Failed read → nothing (never
   fake zeros).

2. RING METER (CoverageRing, chat top bar, before the PhaseChip):
   a 16px donut filling uncoveredTokens / extractionChunkTokens —
   "how close this session is to its next extraction run". Click →
   the exact numbers (covered/uncovered tokens, messages, the
   threshold, lastFoldedAt via timeAgo). Refresh chains: mount, the
   turn-settle bump (chat.tsx coverageTick), the settings signal
   (threshold/kill-switch re-read live), a 2s drain-poll while a run
   is in flight, and the ACTIVITY FEED — a run for this session
   appearing or settling in the dock's feed re-reads coverage (runs
   fire from other surfaces — the sidebar menu, the turn pipeline —
   and no turn-settle bump announces them; the runs-empty-transition
   the graph view rides). Vanishes entirely when the graph is off or
   before the first honest read. Running = the whole ring PULSES
   (implementer's visual call: motion is the working-state signal in
   this app's quiet register).

3. PHASECHIP UN-PILL: the phase label is a flat tag — no border, no
   rounding, no padding. The Plan chip is the only button-shaped
   thing in the top bar. Drag-surface (pointer-events-none) and aria
   semantics unchanged; the transcript PhaseDivider keeps its look.

4. PANEL DEATH: components/graph-coverage.tsx, its graph-view
   mount, and tests/render-graph-coverage.test.tsx die whole.
   Confirmed: after this, NOTHING client-side consumes GET
   /api/graph/coverage — the only remaining client use of the
   coverage endpoints is the Extract POST (kept, by design); the
   routes stay (Platform's call).

Pure seams exported for tests: ringFill (clamp math),
CoverageMenuBody, CoverageRingGlyph, CoverageRingDetail.
901/901.

Live-verified on :3001: ring fill arcs match the API byte-for-byte
(483/4096 = 4.45/37.70; 1991/4096 = 18.32/37.70); the sidebar block
reads "2k uncovered / 4 messages unextracted"; a real Extract run
went optimistic → dock run → menu settled "fully extracted" AND the
ring drained to the empty arc (the feed chain); graph off → the ring
vanishes; the flat phase label renders chrome-free beside the Plan
pill.
This commit is contained in:
amosblomqvist
2026-09-24 15:26:22 +03:00
parent 2e9fd01c0c
commit bc1f668e2a
9 changed files with 585 additions and 309 deletions
+13
View File
@@ -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
</button>
)}
{/* 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. */}
<CoverageRing chatId={chatId} refreshKey={coverageTick} />
<PhaseChip phase={phases.at(-1) ?? "probe"} />
</header>
+393
View File
@@ -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<SessionCoverage | null> {
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<GraphRead | null> {
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<boolean> {
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<SessionCoverage | null>(null);
const [extracting, setExtracting] = useState(false); // optimistic until the server says running
const lastUncoveredRef = useRef<number | null>(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 (
<CoverageMenuBody
coverage={coverage}
extracting={extracting}
onExtract={() => 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 (
<div data-testid="ctx-coverage" className="flex flex-col gap-1 px-2.5 pt-1 pb-2">
{busy ? (
<span className="flex items-center gap-1.5 text-muted-foreground" data-testid="ctx-coverage-running">
<span className="size-1.5 animate-pulse rounded-full bg-foreground/70" aria-hidden />
extracting…
</span>
) : complete ? (
<span className="text-muted-foreground">fully extracted</span>
) : (
<span className="flex flex-col">
<span className="tabular-nums text-foreground">
{formatTokens(coverage.uncoveredTokens)} uncovered
</span>
<span className="tabular-nums text-muted-foreground">
{coverage.uncoveredCount} message{coverage.uncoveredCount === 1 ? "" : "s"} unextracted
</span>
</span>
)}
<button
type="button"
data-testid="ctx-extract"
onClick={onExtract}
disabled={busy || complete}
className="flex items-center justify-center rounded-lg border px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
{busy ? "Running" : "Extract"}
</button>
</div>
);
}
// ─── 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<GraphRead | null>(null);
const [coverage, setCoverage] = useState<SessionCoverage | null>(null);
const [open, setOpen] = useState(false);
const [nonce, setNonce] = useState(0);
const rootRef = useRef<HTMLDivElement>(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 (
<div ref={rootRef} className="lh-no-drag relative shrink-0">
<button
type="button"
data-testid="coverage-ring"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
title="Extraction coverage — uncovered tokens vs the chunk threshold"
className="relative flex size-5 cursor-pointer items-center justify-center transition-opacity hover:opacity-80"
>
{/* The donut: track + fill. The whole ring pulses while a run
is in flight (motion = the working-state signal). */}
<CoverageRingGlyph fill={fill} running={coverage.running} />
</button>
{open && (
<div
data-testid="coverage-ring-popover"
className="absolute bottom-full right-0 z-40 mb-1.5 w-64 rounded-xl border bg-card p-3 text-xs shadow-xl"
>
<CoverageRingDetail coverage={coverage} chunkTokens={graph.chunkTokens} />
</div>
)}
</div>
);
}
/** 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 (
<svg
viewBox="0 0 16 16"
className={running ? "size-full animate-pulse" : "size-full"}
aria-hidden="true"
>
<circle cx="8" cy="8" r="6" fill="none" strokeWidth="2.5" className="stroke-border" />
<circle
cx="8"
cy="8"
r="6"
fill="none"
strokeWidth="2.5"
className="stroke-foreground/70"
strokeDasharray={`${fill * C} ${C}`}
strokeLinecap="round"
transform="rotate(-90 8 8)"
/>
</svg>
);
}
/** 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 (
<>
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Extraction coverage
</div>
<div className="flex flex-col gap-1 text-muted-foreground">
<div className="flex justify-between gap-2">
<span>Uncovered</span>
<span className="tabular-nums text-card-foreground">
{formatTokens(coverage.uncoveredTokens)} tokens
</span>
</div>
<div className="flex justify-between gap-2">
<span>Covered</span>
<span className="tabular-nums text-card-foreground">
{formatTokens(coverage.coveredTokens)} tokens
</span>
</div>
<div className="flex justify-between gap-2">
<span>Messages</span>
<span className="tabular-nums text-card-foreground">
{coverage.coveredCount} / {coverage.totalMessages}
</span>
</div>
<div className="flex justify-between gap-2">
<span>Chunk threshold</span>
<span className="tabular-nums text-card-foreground">
{formatTokens(chunkTokens)} tokens
</span>
</div>
<div className="flex justify-between gap-2">
<span>Last extraction</span>
<span className="tabular-nums text-card-foreground">
{coverage.lastFoldedAt ? timeAgo(coverage.lastFoldedAt) : "—"}
</span>
</div>
{coverage.running && (
<div className="flex justify-between gap-2" data-testid="coverage-ring-running">
<span>Status</span>
<span className="text-card-foreground">extracting…</span>
</div>
)}
</div>
</>
);
}
-203
View File
@@ -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<HTMLDivElement>(null);
usePopoverDismiss(rootRef, open, () => setOpen(false));
const [chats, setChats] = useState<CoverageChat[]>([]);
const [loaded, setLoaded] = useState(false);
const [extracting, setExtracting] = useState<string[]>([]); // 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 (
<div ref={rootRef} className="relative">
<button
type="button"
title="Extraction coverage — what the knowledge graph has covered"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
<ListChecksIcon className="size-4" />
</button>
{open && (
<div className="absolute right-0 top-full mt-2">
<CoveragePanel
chats={chats}
loaded={loaded}
isBusy={(c) => pending(c)}
onExtract={(c) => void onExtract(c.chatId)}
/>
</div>
)}
</div>
);
}
/**
* 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 (
<div
data-testid="coverage-panel"
className="flex max-h-[60vh] w-[24rem] flex-col rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur"
>
<div className="mb-1.5 flex items-baseline justify-between px-1">
<span className="text-xs font-medium uppercase tracking-widest text-muted-foreground/70">
Extraction coverage
</span>
<span className="text-xs text-muted-foreground">uncovered first</span>
</div>
{!anyUncovered && (
<p className="px-1 py-2 text-xs text-muted-foreground">
{loaded ? "Every session is fully extracted — nothing pending." : "…"}
</p>
)}
<div className="flex flex-col gap-1 overflow-y-auto">
{chats.map((c) => (
<CoverageRow key={c.chatId} chat={c} busy={isBusy(c)} onExtract={() => onExtract(c)} />
))}
</div>
</div>
);
}
function CoverageRow({ chat, busy, onExtract }: { chat: CoverageChat; busy: boolean; onExtract: () => void }) {
return (
<div className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-accent/50" data-testid="coverage-row">
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-foreground">{chat.title}</div>
<div className="flex items-baseline gap-1.5 text-xs text-muted-foreground">
{busy ? (
<span className="flex items-center gap-1">
<Loader2Icon className="size-3 animate-spin" /> extracting…
</span>
) : (
<>
<span className="tabular-nums">{formatTokens(chat.coveredTokens)} covered</span>
{chat.uncoveredTokens > 0 && (
<span className="tabular-nums text-foreground">
{formatTokens(chat.uncoveredTokens)} uncovered
</span>
)}
{chat.uncoveredTokens === 0 && <span>· complete</span>}
{chat.uncoveredMessages > 0 && (
<span className="tabular-nums">({chat.uncoveredMessages} msgs)</span>
)}
</>
)}
</div>
</div>
<button
type="button"
disabled={busy || chat.uncoveredTokens === 0}
onClick={onExtract}
className="shrink-0 rounded-lg border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
{busy ? "Running" : "Extract"}
</button>
</div>
);
}
-7
View File
@@ -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() {
</div>
</>
)}
{/* 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). */}
<div className="shrink-0"><GraphCoverageControl refreshKey={refreshTick} /></div>
{!isEmpty && (
<>
{/* Import notes — its own container (36px, header band). */}
+8 -6
View File
@@ -31,18 +31,20 @@ const CHIP_LABEL: Record<Phase3, string> = {
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 (
<span
data-testid="phase-chip"
data-phase={phase}
aria-hidden="true"
className="pointer-events-none flex shrink-0 select-none items-center rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground"
className="pointer-events-none flex shrink-0 select-none items-center text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
{CHIP_LABEL[phase]}
</span>
+20 -8
View File
@@ -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<HTMLDivElement>(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<string | null>(null);
const ctxAnchorRef = useRef<HTMLElement | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
@@ -648,16 +651,25 @@ function SessionsSidebar() {
</div>
</div>
{/* 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 && (
<MenuShell
anchorRef={ctxAnchorRef}
testid="session-context-menu"
preferredSide="down"
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
menuClass="flex w-52 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
>
{/* 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. */}
<CoverageMenuBlock chatId={ctxChatId} />
<div className="my-0.5 border-t border-border/60" aria-hidden="true" />
<button
type="button"
data-testid="ctx-rename"
+143
View File
@@ -0,0 +1,143 @@
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
CoverageMenuBody,
CoverageRingDetail,
CoverageRingGlyph,
ringFill,
type SessionCoverage,
} from "../components/extraction-coverage.tsx";
/**
* The extraction-coverage surfaces (extraction-UX redesign, owner-locked
* dispatch 2026-09-24): the sidebar context-menu block (numbers + the
* manual Extract affordance) and the chat top bar's ring meter. The pure
* folds and the presentational bodies are pinned here; the live fetch
* chains (menu-open read, the 2s running poll, the turn-settle bump, the
* settings signal) are Playwright's beat.
*/
function coverage(over: Partial<SessionCoverage> = {}): SessionCoverage {
return {
totalMessages: 12,
coveredCount: 7,
uncoveredCount: 5,
coveredTokens: 30_000,
uncoveredTokens: 2_048,
running: false,
lastFoldedAt: null,
...over,
};
}
describe("ringFill (the donut's fold, pure)", () => {
it("fills uncovered/chunk, clamped to [0,1]", () => {
assert.equal(ringFill(0, 4096), 0);
assert.equal(ringFill(2048, 4096), 0.5);
assert.equal(ringFill(4096, 4096), 1);
assert.equal(ringFill(999_999, 4096), 1, "past the threshold reads full, never >1");
});
it("a non-positive threshold reads zero (never NaN, never a fake full)", () => {
assert.equal(ringFill(2048, 0), 0);
assert.equal(ringFill(2048, -5), 0);
});
});
describe("CoverageMenuBody — the context-menu block", () => {
it("renders the uncovered numbers + the Extract affordance", () => {
const html = renderToStaticMarkup(
<CoverageMenuBody coverage={coverage()} extracting={false} onExtract={() => {}} />,
);
assert.ok(html.includes('data-testid="ctx-coverage"'), "the block carries its testid");
assert.match(html, /2k uncovered/);
assert.match(html, /5 messages unextracted/);
assert.ok(html.includes('data-testid="ctx-extract"'), "the Extract affordance");
assert.ok(!html.includes('disabled=""'), "an uncovered session can extract (the disabled: utilities alone don't count)");
});
it("fully extracted: the quiet line, Extract disabled (never a fake run)", () => {
const html = renderToStaticMarkup(
<CoverageMenuBody
coverage={coverage({ uncoveredTokens: 0, uncoveredCount: 0 })}
extracting={false}
onExtract={() => {}}
/>,
);
assert.match(html, /fully extracted/);
assert.ok(html.includes("disabled"), "nothing to run — the button rests");
});
it("running: the pulse line + 'Running', not the numbers (motion is the state)", () => {
const html = renderToStaticMarkup(
<CoverageMenuBody coverage={coverage({ running: true })} extracting={false} onExtract={() => {}} />,
);
assert.ok(html.includes('data-testid="ctx-coverage-running"'));
assert.match(html, /extracting…/);
assert.ok(html.includes(">Running</button>"), "the affordance reads Running while in flight");
assert.ok(html.includes("disabled"), "no double-fire");
});
it("optimistic (click-pending, server not yet saying running): same running shape", () => {
const html = renderToStaticMarkup(
<CoverageMenuBody coverage={coverage()} extracting onExtract={() => {}} />,
);
assert.ok(html.includes('data-testid="ctx-coverage-running"'), "the optimistic state shows at once");
assert.match(html, />Running</);
});
});
describe("CoverageRingGlyph — the donut", () => {
it("half fill: the arc covers half the circumference", () => {
const html = renderToStaticMarkup(<CoverageRingGlyph fill={0.5} running={false} />);
const C = 2 * Math.PI * 6;
assert.ok(html.includes(`stroke-dasharray="${0.5 * C} ${C}"`), "fill = uncovered share of the ring");
assert.ok(html.includes("rotate(-90"), "the arc starts at 12 o'clock");
assert.ok(!html.includes("animate-pulse"), "steady state is still");
});
it("empty fill renders the bare track (no arc beyond a hairline)", () => {
const html = renderToStaticMarkup(<CoverageRingGlyph fill={0} running={false} />);
const C = 2 * Math.PI * 6;
assert.ok(html.includes(`stroke-dasharray="0 ${C}"`));
});
it("running: the whole ring pulses (motion = the working-state signal)", () => {
const html = renderToStaticMarkup(<CoverageRingGlyph fill={0.3} running />);
assert.ok(html.includes("animate-pulse"), "the implementer's visual call: pulse, not a spinner");
});
});
describe("CoverageRingDetail — the popover's exact numbers", () => {
it("carries covered/uncovered tokens, messages, the threshold, the last fold", () => {
const html = renderToStaticMarkup(
<CoverageRingDetail
coverage={coverage({ lastFoldedAt: "2026-09-24T10:00:00Z" })}
chunkTokens={4096}
/>,
);
assert.match(html, /Uncovered.*2k tokens/s);
assert.match(html, /Covered.*30k tokens/s);
assert.match(html, /7 \/ 12/);
assert.match(html, /Chunk threshold.*4\.1k tokens/s);
assert.match(html, /Last extraction/);
assert.ok(!html.includes('data-testid="coverage-ring-running"'), "idle: no status line");
});
it("no fold yet renders '—' (never a fake timestamp)", () => {
const html = renderToStaticMarkup(<CoverageRingDetail coverage={coverage()} chunkTokens={4096} />);
assert.match(html, /Last extraction.*—/s);
});
it("running: the extracting… status line", () => {
const html = renderToStaticMarkup(
<CoverageRingDetail coverage={coverage({ running: true })} chunkTokens={4096} />,
);
assert.ok(html.includes('data-testid="coverage-ring-running"'));
assert.match(html, /extracting…/);
});
});
-85
View File
@@ -1,85 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { CoveragePanel, GraphCoverageControl } from "../components/graph-coverage.tsx";
// The extraction-coverage panel (vision-extraction-coverage.md, settled
// 2026-09-15): the graph view's window into WHAT the pipeline has covered,
// with a per-session "Extract" button for the uncovered remainder. Static
// markup can't click — the closed control and the panel body are pinned
// separately. (Interaction — the fetch + POST flow — is covered by the live
// smoke.)
describe("GraphCoverageControl", () => {
it("renders the coverage button in the chrome, closed by default", () => {
const html = renderToStaticMarkup(<GraphCoverageControl />);
assert.ok(html.includes("<button"));
assert.ok(html.includes("Extraction coverage"));
assert.ok(!html.includes('data-testid="coverage-panel"'), "the panel starts closed");
});
});
describe("CoveragePanel — exact per-session coverage data", () => {
const base = {
chatId: "chat_a",
title: "Modal logic",
updatedAt: "2026-09-15T00:00:00.000Z",
totalMessages: 12,
coveredMessages: 10,
uncoveredMessages: 2,
coveredTokens: 1536,
uncoveredTokens: 384,
running: false,
};
it("shows each session's covered/uncovered tokens and the Extract button", () => {
const html = renderToStaticMarkup(
<CoveragePanel chats={[base]} loaded isBusy={() => false} onExtract={() => {}} />,
);
assert.ok(html.includes("Modal logic"));
assert.ok(html.includes("1.5k covered"));
assert.ok(html.includes("384 uncovered"));
assert.ok(html.includes("(2 msgs)"));
assert.ok(html.includes(">Extract<"), "the uncovered session offers a manual extract");
});
it("a fully covered session shows · complete with a disabled Extract button", () => {
const html = renderToStaticMarkup(
<CoveragePanel
chats={[{ ...base, uncoveredMessages: 0, uncoveredTokens: 0, coveredMessages: 12, coveredTokens: 1920 }]}
loaded
isBusy={() => false}
onExtract={() => {}}
/>,
);
assert.ok(html.includes("· complete"));
assert.ok(html.includes("disabled"), "nothing to extract — the button is honest");
assert.ok(!html.includes(">Extract<") || html.includes("disabled"));
});
it("a running session shows extracting… and no clickable duplicate start", () => {
const html = renderToStaticMarkup(
<CoveragePanel
chats={[{ ...base, running: true }]}
loaded
isBusy={(c) => c.running}
onExtract={() => {}}
/>,
);
assert.ok(html.includes("extracting…"));
assert.ok(html.includes(">Running<"));
assert.ok(html.includes("disabled"), "single-flight: the in-flight run can't be double-started");
});
it("all-covered panel: the nothing-pending line, not a row list", () => {
const html = renderToStaticMarkup(
<CoveragePanel
chats={[{ ...base, uncoveredMessages: 0, uncoveredTokens: 0 }]}
loaded
isBusy={() => false}
onExtract={() => {}}
/>,
);
assert.ok(html.includes("Every session is fully extracted — nothing pending."));
});
});
+8
View File
@@ -30,6 +30,14 @@ describe("PhaseChip (header)", () => {
}
});
it("UN-PILLED (owner, extraction-UX redesign 2026-09-24): a flat tag — no border, no rounding, no padding", () => {
const html = renderToStaticMarkup(<PhaseChip phase="plan" />);
assert.ok(!html.includes("rounded-full"), "no pill curve");
assert.ok(!html.includes("border-border"), "no border chrome");
assert.ok(!html.includes("px-2"), "no pill padding");
assert.match(html, /class="[^"]*text-xs font-medium uppercase/, "the quiet flat label keeps its type treatment");
});
it("NEVER shows a working spinner — the pill's curve suits text, not glyph cells (owner, round 2)", () => {
const html = renderToStaticMarkup(<PhaseChip phase="teach" />);
assert.ok(!html.includes("braille-spinner"), "the chip is label-only by design");