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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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). */}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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…/);
|
||||
});
|
||||
});
|
||||
@@ -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."));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user