chain table redesign (owner refinements, 2026-09-29 evening)
- full-bleed: blur + translucent surface IS the overlay, no inner card; own top bar (info + search + close, lh-no-drag so home's drag mask never eats the X — the clickability bug) - three levels, one click each: cluster (birth session; a chain lives in its CONCLUSION's cluster) → chain → proposition STATEMENTS revealed directly (names never twice; singletons one click); all collapsed by default - proposition names as outlined chips, → connectors between; lonely chip = standalone proposition — the '(no premises)' annotation is dead in the visible UI (survives only as aria-label); Folder/Waypoints icons; rotating chevrons; deep statement indent - real table look: hairline row + column lines; sortable Name + Created headers (click toggles asc/desc; size is text, not a control) - selection never shifts layout: floating bottom bar (Google-Photos pattern) with N propositions → M chains + Clear + Generate - fuzzy search grouped + auto-expanding clusters live-verified end-to-end on the owner's dev server; 15 rewritten render pins; gates: tsc 0, 1179/1179, lint 0, build 0
This commit is contained in:
@@ -1,31 +1,38 @@
|
||||
/**
|
||||
* THE CHAIN TABLE — SSR render pins (the render-suite discipline: pure
|
||||
* bodies, renderToStaticMarkup, testids). The chains overlay (owner-
|
||||
* approved plan, 2026-09-29): every reasoning chain in one selectable
|
||||
* table; the pinned Selected section derives N propositions → M chains
|
||||
* via chainsForSelection over the selection (the agents' ACTUAL input);
|
||||
* fuzzy search filters the list only; the practice dialog opens in
|
||||
* CUSTOM mode from the Generate button.
|
||||
* bodies, renderToStaticMarkup, testids). The owner redesign
|
||||
* (2026-09-29 evening): full-bleed table (blur + translucency IS the
|
||||
* surface), cluster → chain → statement hierarchy (a chain lives in its
|
||||
* CONCLUSION's birth cluster; expanding a chain reveals statements
|
||||
* directly — names live in the line, never twice), proposition CHIPS +
|
||||
* → connectors (a lonely chip = a standalone proposition; the "(no
|
||||
* premises)" annotation is dead), rotating chevrons, folder/waypoints
|
||||
* icons, hairline table lines, sortable Name + Created headers, a
|
||||
* floating bottom selection bar that never shifts layout, and a fuzzy
|
||||
* search that auto-expands matching clusters.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import { ChainTableBody } from "../components/chain-table.tsx";
|
||||
import type { ChainSort } from "../components/chain-table.tsx";
|
||||
import type { GraphEdge, GraphNode } from "../src/core/contracts.ts";
|
||||
|
||||
/** The fixture graph — two follows-chains + one standalone:
|
||||
* a → c1, b → c1 (chain one), d → e (chain two), solo (singleton). */
|
||||
* a → c1 ← b (chain one, conclusion born chat1), d → e (chain two,
|
||||
* conclusion born notes1), solo (singleton born chat1). b is born
|
||||
* chat2 — a PREMISE-only birth: no chain concludes there, so chat2
|
||||
* never appears as a group (the conclusion's-cluster law). */
|
||||
function node(id: string, name: string, statement: string, chatId: string | null): GraphNode {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
statement,
|
||||
aliases: [],
|
||||
aliases: [] as string[],
|
||||
state: "new",
|
||||
createdAt: "2026-09-20T10:00:00.000Z",
|
||||
provenance: chatId === null ? [] : [{ chatId, messageId: `m_${id}` }],
|
||||
@@ -39,6 +46,7 @@ const NODES: GraphNode[] = [
|
||||
node("d", "Support", "Statement D.", null),
|
||||
node("e", "Follow-up", "Statement E with keyword zebra.", "notes1"),
|
||||
node("solo", "Standalone", "Statement S.", "chat1"),
|
||||
node("orphan", "Orphan", "Statement O.", null),
|
||||
];
|
||||
|
||||
const EDGES: GraphEdge[] = [
|
||||
@@ -47,118 +55,163 @@ const EDGES: GraphEdge[] = [
|
||||
{ from: "d", to: "e", type: "follows" },
|
||||
] as GraphEdge[];
|
||||
|
||||
const SESSIONS = { chat1: "Ethics lesson", chat2: "Second chat" };
|
||||
const FILE_SOURCES = { notes1: "notes/raw/ethics.md" };
|
||||
|
||||
const BASE = {
|
||||
nodes: NODES,
|
||||
edges: EDGES,
|
||||
sessions: SESSIONS,
|
||||
fileSources: FILE_SOURCES,
|
||||
sessions: { chat1: "Ethics lesson", chat2: "Second chat" },
|
||||
fileSources: { notes1: "notes/raw/ethics.md" },
|
||||
query: "",
|
||||
onQuery: () => {},
|
||||
sort: { col: "name", dir: 1 } as ChainSort,
|
||||
onSort: () => {},
|
||||
selection: new Set<string>(),
|
||||
onToggleNode: () => {},
|
||||
expandedClusters: new Set<string>(),
|
||||
expandedChains: new Set<string>(),
|
||||
onToggleCluster: () => {},
|
||||
onToggleChain: () => {},
|
||||
onToggleNodes: () => {},
|
||||
onClear: () => {},
|
||||
onGenerate: () => {},
|
||||
onClose: () => {},
|
||||
};
|
||||
|
||||
function render(props: Partial<typeof BASE> = {}) {
|
||||
return renderToStaticMarkup(createElement(ChainTableBody, { ...BASE, ...props }));
|
||||
return renderToStaticMarkup(React.createElement(ChainTableBody, { ...BASE, ...props }));
|
||||
}
|
||||
|
||||
describe("ChainTableBody — the chain rows", () => {
|
||||
it("renders every chain as a formatted name line, with birth + created columns", () => {
|
||||
describe("ChainTableBody — clusters (the outermost layer)", () => {
|
||||
it("collapsed by default: cluster rows render, chain rows do not", () => {
|
||||
const html = render();
|
||||
// chain ids are the conclusion ids (chainsForSelection's law)
|
||||
assert.ok(html.includes('data-testid="chain-row-c1"'), "chain row for c1");
|
||||
assert.ok(html.includes('data-testid="chain-row-e"'), "chain row for e");
|
||||
assert.ok(html.includes('data-testid="chain-row-solo"'), "the singleton renders as its own chain");
|
||||
assert.ok(html.includes("Premise one, Premise two → Conclusion"), "the two-premise line");
|
||||
assert.ok(html.includes("Support → Follow-up"), "the one-premise line");
|
||||
assert.ok(html.includes("Standalone (no premises)"), "the singleton marker");
|
||||
// birth labels: the union of member labels, honest "unlabeled" included
|
||||
assert.ok(html.includes("Ethics lesson · Second chat"), "chain one spans two labeled sessions");
|
||||
assert.ok(html.includes("notes/raw/ethics.md"), "a notes birth shows the file path");
|
||||
assert.ok(html.includes("unlabeled"), "an unnameable birth is honest about it");
|
||||
// created = the conclusion's createdAt (the practice-list date shape)
|
||||
assert.ok(html.includes('data-testid="chain-date-c1"'));
|
||||
assert.ok(html.includes("2026-09-20"), "the date column renders");
|
||||
assert.ok(html.includes('data-testid="cluster-group-chat1"'), "the chat cluster");
|
||||
assert.ok(html.includes('data-testid="cluster-group-notes1"'), "the notes cluster");
|
||||
assert.ok(html.includes("Ethics lesson"), "chat label");
|
||||
assert.ok(html.includes("notes/raw/ethics.md"), "notes label");
|
||||
assert.ok(html.includes("lucide-folder"), "the closed folder icon");
|
||||
assert.ok(!html.includes('data-testid="chain-row-c1"'), "no chain row when collapsed");
|
||||
assert.ok(!html.includes("Premise one"), "no chips when collapsed");
|
||||
});
|
||||
|
||||
it("the first chain row expands by default to member proposition rows; closed members keep their statements unmounted", () => {
|
||||
it("a premise-only birth never becomes a group — chains live in their CONCLUSION's cluster", () => {
|
||||
const html = render();
|
||||
assert.ok(html.includes('data-testid="prop-row-a"'), "premise row a");
|
||||
assert.ok(html.includes('data-testid="prop-row-c1"'), "conclusion row");
|
||||
// member statements expand on click — Radix unmounts closed
|
||||
// content, so SSR honestly does not contain them
|
||||
assert.ok(!html.includes("Statement A."), "statements wait for their click");
|
||||
assert.ok(!html.includes("cluster-group-chat2"), "chat2 births only a premise");
|
||||
assert.ok(!html.includes("Second chat"), "its label never renders");
|
||||
});
|
||||
|
||||
it("every chain has a checkbox; expanded chains expose member checkboxes", () => {
|
||||
it("an unnameable CONCLUSION birth forms one honest muted unlabeled group", () => {
|
||||
const html = render();
|
||||
assert.ok(html.includes('data-testid="chain-checkbox-c1"'));
|
||||
assert.ok(html.includes('data-testid="chain-checkbox-e"'));
|
||||
assert.ok(html.includes('data-testid="chain-checkbox-solo"'));
|
||||
assert.ok(html.includes('data-testid="prop-checkbox-a"'), "inside the default-open chain");
|
||||
assert.ok(html.includes("unlabeled"), "the muted label");
|
||||
// orphan (birth null) concludes its own singleton chain → the group exists;
|
||||
// d (also birth null) is a premise of e and never forms one
|
||||
assert.ok(html.includes('data-testid="chain-row-orphan"') === false, "collapsed, like every group");
|
||||
});
|
||||
|
||||
it("cluster rows carry checkbox + counts + newest date; notes kind marked", () => {
|
||||
const html = render();
|
||||
assert.ok(html.includes('data-testid="cluster-checkbox-chat1"'));
|
||||
// chat1: chains c1 (a, b, c1) + solo (solo) → 4 member ids
|
||||
assert.ok(html.includes(">4</span>"), "chat1's proposition count");
|
||||
assert.ok(html.includes("2026-09-20"), "the newest-member date");
|
||||
assert.ok(html.includes(">notes</span>"), "the notes kind tag");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChainTableBody — the pinned Selected section", () => {
|
||||
it("empty selection: guidance, no summary, no generate enablement pins", () => {
|
||||
const html = render();
|
||||
assert.ok(html.includes("Nothing selected"));
|
||||
assert.ok(!html.includes('data-testid="chain-selected-summary"'));
|
||||
describe("ChainTableBody — chain rows (chips + connectors)", () => {
|
||||
it("names render as outlined chips with → connectors BETWEEN them", () => {
|
||||
const html = render({ expandedClusters: new Set(["chat1"]) });
|
||||
assert.ok(html.includes('data-testid="chain-row-c1"'));
|
||||
assert.ok(html.includes('data-testid="prop-chip-a"'), "premise chip");
|
||||
assert.ok(html.includes('data-testid="prop-chip-c1"'), "conclusion chip");
|
||||
assert.ok(html.includes("Premise one"), "chip text");
|
||||
assert.ok(html.includes('data-testid="chain-arrow-c1-1"'), "the connector");
|
||||
assert.ok(html.includes("→"), "the arrow glyph");
|
||||
assert.ok(html.includes("lucide-waypoints"), "the chain icon");
|
||||
});
|
||||
|
||||
it("a full chain selection: N propositions → M chains + derived line", () => {
|
||||
it("a singleton renders ONE lonely chip, no connector; the annotation survives only for screen readers", () => {
|
||||
const html = render({ expandedClusters: new Set(["chat1"]) });
|
||||
assert.ok(html.includes('data-testid="chain-row-solo"'));
|
||||
assert.ok(html.includes('data-testid="prop-chip-solo"'));
|
||||
assert.ok(!html.includes('data-testid="chain-arrow-solo-1"'), "no connector on a singleton");
|
||||
// the visible line is the chip alone; "(no premises)" exists ONLY as the
|
||||
// row's aria-label (the a11y one-liner) — exactly one occurrence
|
||||
assert.equal(html.split("no premises").length - 1, 1, "aria-label only, never visible text");
|
||||
assert.ok(html.includes('aria-label="Standalone (no premises)"'));
|
||||
});
|
||||
|
||||
it("the row carries the one-line derivation as its aria-label (core formatChainLine)", () => {
|
||||
const html = render({ expandedClusters: new Set(["chat1"]) });
|
||||
assert.ok(html.includes('aria-label="Premise one, Premise two → Conclusion"'));
|
||||
});
|
||||
|
||||
it("expanding a chain reveals member STATEMENTS directly — names never twice, conclusion marked", () => {
|
||||
const html = render({
|
||||
expandedClusters: new Set(["chat1"]),
|
||||
expandedChains: new Set(["c1"]),
|
||||
});
|
||||
assert.ok(html.includes('data-testid="prop-row-a"'), "premise statement row");
|
||||
assert.ok(html.includes("Statement A."), "the statement text, one click deep");
|
||||
assert.ok(html.includes("Statement C."), "the conclusion statement");
|
||||
assert.ok(html.includes('data-testid="prop-conclusion-c1"'), "the conclusion marker");
|
||||
// premise rows carry no conclusion marker
|
||||
const aRow = html.split('data-testid="prop-row-a"')[1]?.split('data-testid="prop-row-')[0] ?? "";
|
||||
assert.ok(!aRow.includes("prop-conclusion"), "premises unmarked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChainTableBody — the sortable table headers", () => {
|
||||
it("Name + Created are sort controls with aria-sort; Props is plain text", () => {
|
||||
const html = render();
|
||||
assert.ok(html.includes('data-testid="sort-name"'));
|
||||
assert.ok(html.includes('data-testid="sort-created"'));
|
||||
assert.ok(html.includes('aria-sort="ascending"'), "default name A→Z");
|
||||
assert.ok(!html.includes('data-testid="sort-props"'), "size is text, not a control");
|
||||
});
|
||||
|
||||
it("descending state shows on the active header only", () => {
|
||||
const html = render({ sort: { col: "created", dir: -1 } });
|
||||
assert.ok(html.includes('data-testid="sort-created"'));
|
||||
assert.ok(html.includes('aria-sort="descending"'));
|
||||
assert.ok(html.includes('aria-sort="none"'), "the inactive column");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChainTableBody — the floating selection bar (never shifts layout)", () => {
|
||||
it("absent when nothing is selected", () => {
|
||||
const html = render({ expandedClusters: new Set(["chat1"]) });
|
||||
assert.ok(!html.includes('data-testid="chain-selection-bar"'));
|
||||
});
|
||||
|
||||
it("appears with the derived N propositions → M chains + Clear + Generate", () => {
|
||||
const html = render({ selection: new Set(["a", "b", "c1"]) });
|
||||
assert.ok(html.includes('data-testid="chain-selection-bar"'));
|
||||
assert.ok(html.includes('data-testid="chain-selected-summary"'));
|
||||
assert.ok(html.includes("3 propositions → 1 chain"), "the derived shape, exactly");
|
||||
assert.ok(html.includes("Premise one, Premise two → Conclusion"), "the derived chain line");
|
||||
assert.ok(html.includes("3 propositions → 1 chain"));
|
||||
assert.ok(html.includes('data-testid="chain-clear"'));
|
||||
assert.ok(html.includes('data-testid="chain-generate"'));
|
||||
});
|
||||
|
||||
it("a premise WITHOUT its conclusion derives as the singleton it becomes", () => {
|
||||
const html = render({ selection: new Set(["a", "d"]) });
|
||||
assert.ok(html.includes("2 propositions → 2 chains"), "no follows-edge survives inside the selection");
|
||||
assert.ok(html.includes("Premise one (no premises)"), "regrouped singleton a");
|
||||
assert.ok(html.includes("Support (no premises)"), "regrouped singleton d");
|
||||
});
|
||||
|
||||
it("a node in several chains is ONE flat selection wherever it appears", () => {
|
||||
// e is conclusion of d→e; picking e alone = 1 proposition, 1 singleton chain
|
||||
const html = render({ selection: new Set(["e"]) });
|
||||
assert.ok(html.includes("1 proposition → 1 chain"));
|
||||
assert.ok(html.includes("Follow-up (no premises)"));
|
||||
});
|
||||
|
||||
it("Clear + Generate problem set render in the pinned section", () => {
|
||||
const html = render({ selection: new Set(["a"]) });
|
||||
assert.ok(html.includes('data-testid="chain-clear"'));
|
||||
assert.ok(html.includes('data-testid="chain-generate"'));
|
||||
assert.ok(html.includes("Generate problem set"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChainTableBody — the fuzzy search (list only, never Selected)", () => {
|
||||
it("filters chains by member name / statement / alias; the pinned selection survives", () => {
|
||||
const aliased = NODES.map((n) =>
|
||||
n.id === "b" ? { ...n, aliases: ["zebra alias"] } : n,
|
||||
);
|
||||
describe("ChainTableBody — the fuzzy search (grouped + auto-expand)", () => {
|
||||
it("matching clusters auto-expand to reveal their chains; others hide; the bar is unaffected", () => {
|
||||
const aliased = NODES.map((n) => (n.id === "b" ? { ...n, aliases: ["zebra alias"] } : n));
|
||||
const html = render({
|
||||
nodes: aliased,
|
||||
query: "zebra",
|
||||
selection: new Set(["a", "b", "c1"]), // selection spans BOTH chains
|
||||
});
|
||||
// zebra hits: chain e (statement) + chain c1 (alias on b)
|
||||
assert.ok(html.includes('data-testid="chain-row-c1"'), "alias match keeps the chain");
|
||||
assert.ok(html.includes('data-testid="chain-row-e"'), "statement match keeps the chain");
|
||||
assert.ok(html.includes('data-testid="chain-row-c1"'), "auto-expanded cluster reveals the chain");
|
||||
assert.ok(html.includes('data-testid="chain-row-e"'), "statement match");
|
||||
assert.ok(!html.includes('data-testid="chain-row-solo"'), "non-matching chain hidden");
|
||||
// the pinned section is never filtered — it still shows the full derived line
|
||||
assert.ok(!html.includes("cluster-group-notes1") === false, "notes1 still renders (it has a match)");
|
||||
assert.ok(!html.includes("Ethics lesson") === false, "chat1 still renders (it has a match)");
|
||||
// the floating bar is never search-filtered
|
||||
assert.ok(html.includes("3 propositions → 1 chain"));
|
||||
assert.ok(html.includes("Premise one, Premise two → Conclusion"));
|
||||
});
|
||||
|
||||
it("no matches: the honest empty line", () => {
|
||||
|
||||
Reference in New Issue
Block a user