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

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

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

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

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

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

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

Live-verified on :3001: ring fill arcs match the API byte-for-byte
(483/4096 = 4.45/37.70; 1991/4096 = 18.32/37.70); the sidebar block
reads "2k uncovered / 4 messages unextracted"; a real Extract run
went optimistic → dock run → menu settled "fully extracted" AND the
ring drained to the empty arc (the feed chain); graph off → the ring
vanishes; the flat phase label renders chrome-free beside the Plan
pill.
This commit is contained in:
amosblomqvist
2026-09-24 15:26:22 +03:00
parent 2e9fd01c0c
commit bc1f668e2a
9 changed files with 585 additions and 309 deletions
+143
View File
@@ -0,0 +1,143 @@
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
CoverageMenuBody,
CoverageRingDetail,
CoverageRingGlyph,
ringFill,
type SessionCoverage,
} from "../components/extraction-coverage.tsx";
/**
* The extraction-coverage surfaces (extraction-UX redesign, owner-locked
* dispatch 2026-09-24): the sidebar context-menu block (numbers + the
* manual Extract affordance) and the chat top bar's ring meter. The pure
* folds and the presentational bodies are pinned here; the live fetch
* chains (menu-open read, the 2s running poll, the turn-settle bump, the
* settings signal) are Playwright's beat.
*/
function coverage(over: Partial<SessionCoverage> = {}): SessionCoverage {
return {
totalMessages: 12,
coveredCount: 7,
uncoveredCount: 5,
coveredTokens: 30_000,
uncoveredTokens: 2_048,
running: false,
lastFoldedAt: null,
...over,
};
}
describe("ringFill (the donut's fold, pure)", () => {
it("fills uncovered/chunk, clamped to [0,1]", () => {
assert.equal(ringFill(0, 4096), 0);
assert.equal(ringFill(2048, 4096), 0.5);
assert.equal(ringFill(4096, 4096), 1);
assert.equal(ringFill(999_999, 4096), 1, "past the threshold reads full, never >1");
});
it("a non-positive threshold reads zero (never NaN, never a fake full)", () => {
assert.equal(ringFill(2048, 0), 0);
assert.equal(ringFill(2048, -5), 0);
});
});
describe("CoverageMenuBody — the context-menu block", () => {
it("renders the uncovered numbers + the Extract affordance", () => {
const html = renderToStaticMarkup(
<CoverageMenuBody coverage={coverage()} extracting={false} onExtract={() => {}} />,
);
assert.ok(html.includes('data-testid="ctx-coverage"'), "the block carries its testid");
assert.match(html, /2k uncovered/);
assert.match(html, /5 messages unextracted/);
assert.ok(html.includes('data-testid="ctx-extract"'), "the Extract affordance");
assert.ok(!html.includes('disabled=""'), "an uncovered session can extract (the disabled: utilities alone don't count)");
});
it("fully extracted: the quiet line, Extract disabled (never a fake run)", () => {
const html = renderToStaticMarkup(
<CoverageMenuBody
coverage={coverage({ uncoveredTokens: 0, uncoveredCount: 0 })}
extracting={false}
onExtract={() => {}}
/>,
);
assert.match(html, /fully extracted/);
assert.ok(html.includes("disabled"), "nothing to run — the button rests");
});
it("running: the pulse line + 'Running', not the numbers (motion is the state)", () => {
const html = renderToStaticMarkup(
<CoverageMenuBody coverage={coverage({ running: true })} extracting={false} onExtract={() => {}} />,
);
assert.ok(html.includes('data-testid="ctx-coverage-running"'));
assert.match(html, /extracting…/);
assert.ok(html.includes(">Running</button>"), "the affordance reads Running while in flight");
assert.ok(html.includes("disabled"), "no double-fire");
});
it("optimistic (click-pending, server not yet saying running): same running shape", () => {
const html = renderToStaticMarkup(
<CoverageMenuBody coverage={coverage()} extracting onExtract={() => {}} />,
);
assert.ok(html.includes('data-testid="ctx-coverage-running"'), "the optimistic state shows at once");
assert.match(html, />Running</);
});
});
describe("CoverageRingGlyph — the donut", () => {
it("half fill: the arc covers half the circumference", () => {
const html = renderToStaticMarkup(<CoverageRingGlyph fill={0.5} running={false} />);
const C = 2 * Math.PI * 6;
assert.ok(html.includes(`stroke-dasharray="${0.5 * C} ${C}"`), "fill = uncovered share of the ring");
assert.ok(html.includes("rotate(-90"), "the arc starts at 12 o'clock");
assert.ok(!html.includes("animate-pulse"), "steady state is still");
});
it("empty fill renders the bare track (no arc beyond a hairline)", () => {
const html = renderToStaticMarkup(<CoverageRingGlyph fill={0} running={false} />);
const C = 2 * Math.PI * 6;
assert.ok(html.includes(`stroke-dasharray="0 ${C}"`));
});
it("running: the whole ring pulses (motion = the working-state signal)", () => {
const html = renderToStaticMarkup(<CoverageRingGlyph fill={0.3} running />);
assert.ok(html.includes("animate-pulse"), "the implementer's visual call: pulse, not a spinner");
});
});
describe("CoverageRingDetail — the popover's exact numbers", () => {
it("carries covered/uncovered tokens, messages, the threshold, the last fold", () => {
const html = renderToStaticMarkup(
<CoverageRingDetail
coverage={coverage({ lastFoldedAt: "2026-09-24T10:00:00Z" })}
chunkTokens={4096}
/>,
);
assert.match(html, /Uncovered.*2k tokens/s);
assert.match(html, /Covered.*30k tokens/s);
assert.match(html, /7 \/ 12/);
assert.match(html, /Chunk threshold.*4\.1k tokens/s);
assert.match(html, /Last extraction/);
assert.ok(!html.includes('data-testid="coverage-ring-running"'), "idle: no status line");
});
it("no fold yet renders '—' (never a fake timestamp)", () => {
const html = renderToStaticMarkup(<CoverageRingDetail coverage={coverage()} chunkTokens={4096} />);
assert.match(html, /Last extraction.*—/s);
});
it("running: the extracting… status line", () => {
const html = renderToStaticMarkup(
<CoverageRingDetail coverage={coverage({ running: true })} chunkTokens={4096} />,
);
assert.ok(html.includes('data-testid="coverage-ring-running"'));
assert.match(html, /extracting…/);
});
});