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 { SidebarToggle } from "@/components/sidebar";
|
||||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core";
|
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core";
|
||||||
import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
|
import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
|
||||||
|
import { CoverageRing } from "@/components/extraction-coverage";
|
||||||
import { findPendingToolCall, phaseAt3, phaseTimeline, streamingToolActivity } from "@/src/core";
|
import { findPendingToolCall, phaseAt3, phaseTimeline, streamingToolActivity } from "@/src/core";
|
||||||
import { slotsConfigured, type ModelsSettings } from "@/src/core";
|
import { slotsConfigured, type ModelsSettings } from "@/src/core";
|
||||||
import { useSettingsSaved } from "@/components/settings-signal";
|
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
|
// The live plan popup (wave 4): opened by any transcript plan block, the
|
||||||
// top-bar plan chip, and the dock band's marginalia.
|
// top-bar plan chip, and the dock band's marginalia.
|
||||||
const [planOpen, setPlanOpen] = useState(false);
|
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
|
// The mermaid focus (chrome-melt wave, 2026-09-19): md-blocks' expand
|
||||||
// button registers the chart source here; the overlay (and the plan
|
// button registers the chart source here; the overlay (and the plan
|
||||||
// popup) mount INSIDE the session-area div — both are session-scoped, so
|
// 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
|
// immediate: turns-only refetch — the stats bar updates even while
|
||||||
// the server's persist/endTurn lags the client by milliseconds
|
// the server's persist/endTurn lags the client by milliseconds
|
||||||
void syncTurns();
|
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
|
// Turn-end nudge (accuracy wave, 2026-09-19): the extraction
|
||||||
// pipeline fires at this turn's onFinish — the dock's poll would
|
// pipeline fires at this turn's onFinish — the dock's poll would
|
||||||
// discover a started run up to 10s late. Refetch the activity feed
|
// discover a started run up to 10s late. Refetch the activity feed
|
||||||
@@ -922,6 +930,11 @@ export function Chat({
|
|||||||
Plan
|
Plan
|
||||||
</button>
|
</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"} />
|
<PhaseChip phase={phases.at(-1) ?? "probe"} />
|
||||||
</header>
|
</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 { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { incrementalLayout, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
|
import { incrementalLayout, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
|
||||||
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
||||||
import { GraphCoverageControl } from "./graph-coverage.tsx";
|
|
||||||
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
||||||
import { useActivity } from "@/components/activity-dock";
|
import { useActivity } from "@/components/activity-dock";
|
||||||
import { MenuShell } from "@/components/menu-shell.tsx";
|
import { MenuShell } from "@/components/menu-shell.tsx";
|
||||||
@@ -1398,12 +1397,6 @@ export function GraphView() {
|
|||||||
</div>
|
</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 && (
|
{!isEmpty && (
|
||||||
<>
|
<>
|
||||||
{/* Import notes — its own container (36px, header band). */}
|
{/* Import notes — its own container (36px, header band). */}
|
||||||
|
|||||||
@@ -31,18 +31,20 @@ const CHIP_LABEL: Record<Phase3, string> = {
|
|||||||
teach: "Teaching",
|
teach: "Teaching",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The header chip: the phase label, monochrome — label-only by design
|
/** The header label: the phase as a quiet flat tag (owner,
|
||||||
* (see the component note: the pill's curve suits text, not glyphs). */
|
* 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 }) {
|
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
data-testid="phase-chip"
|
data-testid="phase-chip"
|
||||||
data-phase={phase}
|
data-phase={phase}
|
||||||
aria-hidden="true"
|
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]}
|
{CHIP_LABEL[phase]}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+20
-8
@@ -8,6 +8,7 @@ import { DeleteSessionDialog } from "@/components/delete-session-dialog";
|
|||||||
import type { ChatDeleteMode } from "@/src/core";
|
import type { ChatDeleteMode } from "@/src/core";
|
||||||
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
|
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
|
||||||
import { ActivitySection } from "@/components/activity-dock";
|
import { ActivitySection } from "@/components/activity-dock";
|
||||||
|
import { CoverageMenuBlock } from "@/components/extraction-coverage";
|
||||||
import { LhMark } from "@/components/lh-mark";
|
import { LhMark } from "@/components/lh-mark";
|
||||||
import { BrailleSpinner } from "@/components/braille-spinner";
|
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||||
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
|
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
|
||||||
@@ -376,11 +377,13 @@ function SessionsSidebar() {
|
|||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
/* ── Wave 6 chunk A: context menu + inline rename + deletion ──
|
/* ── Wave 6 chunk A: context menu + inline rename + deletion ──
|
||||||
Right-click a session row → MenuShell with exactly two actions
|
Right-click a session row → MenuShell: the extraction coverage
|
||||||
(owner ruling): Rename (inline, below) and Delete (the two-option
|
block (owner-locked redesign, 2026-09-24 — numbers + the manual
|
||||||
dialog). The anchor is the row that was right-clicked — assigned
|
Extract affordance, separated by a hairline) above the two
|
||||||
into a ref before the state flip so the shell measures it on its
|
session-management actions (owner ruling): Rename (inline, below)
|
||||||
first (hidden) pass. */
|
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 [ctxChatId, setCtxChatId] = useState<string | null>(null);
|
||||||
const ctxAnchorRef = useRef<HTMLElement | null>(null);
|
const ctxAnchorRef = useRef<HTMLElement | null>(null);
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
@@ -648,16 +651,25 @@ function SessionsSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The context menu (wave 6): exactly two actions (owner
|
{/* The context menu (wave 6 + the extraction-UX redesign): the
|
||||||
ruling — no more). MenuShell owns placement/focus/arrow
|
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. */}
|
cycling; the dismissal effect above owns the contract. */}
|
||||||
{ctxChatId !== null && (
|
{ctxChatId !== null && (
|
||||||
<MenuShell
|
<MenuShell
|
||||||
anchorRef={ctxAnchorRef}
|
anchorRef={ctxAnchorRef}
|
||||||
testid="session-context-menu"
|
testid="session-context-menu"
|
||||||
preferredSide="down"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="ctx-rename"
|
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)", () => {
|
it("NEVER shows a working spinner — the pill's curve suits text, not glyph cells (owner, round 2)", () => {
|
||||||
const html = renderToStaticMarkup(<PhaseChip phase="teach" />);
|
const html = renderToStaticMarkup(<PhaseChip phase="teach" />);
|
||||||
assert.ok(!html.includes("braille-spinner"), "the chip is label-only by design");
|
assert.ok(!html.includes("braille-spinner"), "the chip is label-only by design");
|
||||||
|
|||||||
Reference in New Issue
Block a user