diff --git a/README.md b/README.md
index 0bc1267..89c36ab 100644
--- a/README.md
+++ b/README.md
@@ -156,7 +156,7 @@ data/ chats/ · graph/ (six ledgers + positions/source
**The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the session, rendered as readable markdown by the shared `core/session-markdown.ts` engine — propositions as true statements, `follows`/`supports` edges with verbatim evidence; fully trusted, graph-blind) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools, stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create — and may draw bridging edges between existing nodes and the new batch) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes edges, checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 4096, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection.
-**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N propositions → M chains` the agents will receive — handing a free-form node selection to the same dialog. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
+**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N propositions → M chains` the agents will receive — handing a free-form node selection to the same dialog. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server.
diff --git a/components/chain-table.tsx b/components/chain-table.tsx
index b4f77a4..94790e6 100644
--- a/components/chain-table.tsx
+++ b/components/chain-table.tsx
@@ -1,31 +1,39 @@
"use client";
/**
- * THE CHAIN TABLE (chains overlay; owner redesign, 2026-09-29 evening):
- * every reasoning chain of the graph in one FULL-BLEED table — the blur
- * + translucent background IS the surface, no inner card (owner ruling:
- * "full width of the area"). The overlay's own top bar carries the info
- * + the close button (lh-no-drag — it lives inside home's 52px drag
- * mask, and being later in the DOM the carve-out sticks; that mask was
- * what ate the old card's X clicks).
+ * THE CHAIN TABLE (chains overlay; owner refinements round 2, 2026-09-29):
+ * every reasoning chain of the graph in one FULL-BLEED surface — the blur
+ * + translucent background IS the surface, no inner card. The top bar is
+ * THE shared TopBar (standardization ruling: one element, every surface —
+ * h-15, lh-drag with carve-outs, select-none, sidebar reopen, traffic-
+ * light gutter; born from this overlay's hand-rolled bar breaking every
+ * rule at once). The overlay (z-40) covers home's floating header while
+ * open, so the TopBar's own SidebarToggle is the reopen path.
*
* HIERARCHY (three levels, one click each): CLUSTER (the birth session —
* a chat or a notes file; a chain lives where its CONCLUSION was born,
* owner ruling) → REASONING CHAIN → PROPOSITION STATEMENTS. Expanding a
- * chain reveals its members' statement TEXT directly — the names already
- * live in the chain line, so they never render twice; a singleton chain
- * reveals its one statement in a single click. Everything starts
- * COLLAPSED (owner ruling — no arbitrary default expansion).
+ * chain reveals its members' statement text directly — names live in the
+ * chain line, never twice; a singleton reveals its one statement in one
+ * click. Everything starts collapsed. PROMINENT indentation carries the
+ * structure (owner ruling: chains deep under folders, statements deeper)
+ * — the ONLY lines are the horizontal separators between cluster groups
+ * (vertical column hairlines removed with them, owner ruling: "anything
+ * else is better without them").
*
- * THE TABLE LOOK: drawn hairline row + column lines; sortable column
- * headers (Name + Created clickable, owner ruling — the size column is
- * text, not a control). In the chain line every proposition name is a
- * faint-outlined CHIP and the → a clearly separated connector between
- * chips (owner ruling) — a lonely chip with no connector reads as a
- * standalone proposition; the "(no premises)" text annotation is dead.
- * Icons: clusters FolderIcon (opens when expanded), chains WaypointsIcon
- * (the sidebar's graph glyph), propositions chip-only. Chevrons rotate
- * with expansion state.
+ * THE CHAIN LINE: every proposition name is a faint-outlined CHIP with
+ * the → a clearly separated connector between chips — a lonely chip
+ * reads as a standalone proposition (the "(no premises)" annotation is
+ * dead in the visible UI; it survives only as the row's aria-label).
+ * Icons: clusters FolderIcon (open when expanded), chains WaypointsIcon,
+ * propositions chip-only. Chevrons rotate with expansion. Statement rows
+ * show propositions PLAIN (owner ruling: the derivation is already
+ * encoded in the chain line — no conclusion marker down there).
+ *
+ * SORTING: all three columns sortable (owner ruling — "consistency is
+ * key"): Name (A→Z), Props (largest-first), Created (newest-first);
+ * click toggles asc/desc; a SMALL chevron marks the active header. Sort
+ * orders cluster rows AND chains within.
*
* SELECTION never shifts layout (owner ruling): a floating bottom bar —
* the Google-Photos pattern — appears only when something is selected,
@@ -33,9 +41,9 @@
* node-id selection: a premise without its conclusion counts as the
* singleton it becomes) + Clear + Generate problem set (the one action
* for now; the bar is a neutral action slot for future ones). The fuzzy
- * search (names, aliases, statements) filters chains; clusters with
- * matches auto-expand, others hide — the collapsed grouped view returns
- * when the query clears.
+ * search (names, aliases, statements) filters chains grouped +
+ * auto-expanded under their clusters (non-matching clusters hide; the
+ * collapsed view returns on clear).
*
* Read-only toward the graph. Named neutrally (ChainTable, not
* Practice*) — only the Generate handoff knows about practice.
@@ -43,7 +51,9 @@
import { useEffect, useMemo, useState } from "react";
import {
+ ChevronDownIcon,
ChevronRightIcon,
+ ChevronUpIcon,
FolderIcon,
FolderOpenIcon,
ListTreeIcon,
@@ -53,16 +63,14 @@ import {
} from "lucide-react";
import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core";
import type { GraphEdge, GraphNode } from "@/src/core";
+import { TopBar } from "@/components/top-bar";
import { PracticeDialog } from "@/components/practice-dialog";
-/** The shared row grid: checkbox | chevron | icon | content | props | created. */
-const ROW_COLS = "grid-cols-[1.75rem_1.25rem_1.5rem_minmax(0,1fr)_5.5rem_6rem]";
-
-/** The sortable columns (owner ruling: Name + Created only — size is text). */
-export type ChainSortCol = "name" | "created";
+/** The sortable columns (owner ruling: all three — consistency). */
+export type ChainSortCol = "name" | "props" | "created";
export interface ChainSort {
col: ChainSortCol;
- /** 1 = ascending (A→Z, oldest-first), -1 = descending. */
+ /** 1 = ascending (A→Z, oldest-first, smallest-first), -1 = descending. */
dir: 1 | -1;
}
@@ -158,7 +166,8 @@ export function ChainTableBody({
let group = map.get(key);
if (!group) {
const label = birth === null ? null : (sessions[birth] ?? fileSources[birth] ?? null);
- const kind = birth === null ? null : sessions[birth] !== undefined ? "chat" : fileSources[birth] !== undefined ? "notes" : null;
+ const kind =
+ birth === null ? null : sessions[birth] !== undefined ? "chat" : fileSources[birth] !== undefined ? "notes" : null;
group = { key, birth, label, kind, chains: [], nodeIds: [], newestCreatedAt: "" };
map.set(key, group);
}
@@ -170,31 +179,32 @@ export function ChainTableBody({
}
}
const collator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
- const nameKey = (g: ClusterGroup) => g.label ?? "\uffff"; // unlabeled last
const list = [...map.values()];
- if (sort.col === "created") {
- list.sort((a, b) =>
- sort.dir === -1
- ? b.newestCreatedAt.localeCompare(a.newestCreatedAt)
- : a.newestCreatedAt.localeCompare(b.newestCreatedAt),
- );
- } else {
- list.sort((a, b) => collator.compare(nameKey(a), nameKey(b)) * sort.dir);
- }
+ // Sort law (owner ruling: all three columns sortable — consistency):
+ // the active column orders BOTH cluster rows and chains within; name
+ // is the universal tiebreak (asc). Unlabeled clusters sort last under
+ // name-sort ( sentinel).
+ const nameKey = (g: ClusterGroup) => g.label ?? "\uffff";
+ const cmpName = (a: ClusterGroup, b: ClusterGroup) => collator.compare(nameKey(a), nameKey(b)) * (sort.col === "name" ? sort.dir : 1);
+ const cmpProps = (a: ClusterGroup, b: ClusterGroup) =>
+ sort.dir === -1 ? b.nodeIds.length - a.nodeIds.length : a.nodeIds.length - b.nodeIds.length;
+ const cmpCreated = (a: ClusterGroup, b: ClusterGroup) =>
+ sort.dir === -1 ? b.newestCreatedAt.localeCompare(a.newestCreatedAt) : a.newestCreatedAt.localeCompare(b.newestCreatedAt);
+ list.sort((a, b) =>
+ sort.col === "created" ? cmpCreated(a, b) || cmpName(a, b) : sort.col === "props" ? cmpProps(a, b) || cmpName(a, b) : cmpName(a, b),
+ );
for (const group of list) {
- if (sort.col === "created") {
- group.chains.sort((a, b) => {
- const da = byId.get(a.conclusion)?.createdAt ?? "";
- const db = byId.get(b.conclusion)?.createdAt ?? "";
- return sort.dir === -1 ? db.localeCompare(da) : da.localeCompare(db);
- });
- } else {
- group.chains.sort((a, b) => {
- const na = byId.get(a.conclusion)?.name ?? a.conclusion;
- const nb = byId.get(b.conclusion)?.name ?? b.conclusion;
- return collator.compare(na, nb) * sort.dir;
- });
- }
+ const chainName = (chain: (typeof chains)[number]) => byId.get(chain.conclusion)?.name ?? chain.conclusion;
+ const chainSize = (chain: (typeof chains)[number]) => chain.premises.length + 1;
+ const chainDate = (chain: (typeof chains)[number]) => byId.get(chain.conclusion)?.createdAt ?? "";
+ const cName = (a: (typeof chains)[number], b: (typeof chains)[number]) => collator.compare(chainName(a), chainName(b)) * (sort.col === "name" ? sort.dir : 1);
+ const cProps = (a: (typeof chains)[number], b: (typeof chains)[number]) =>
+ sort.dir === -1 ? chainSize(b) - chainSize(a) : chainSize(a) - chainSize(b);
+ const cDate = (a: (typeof chains)[number], b: (typeof chains)[number]) =>
+ sort.dir === -1 ? chainDate(b).localeCompare(chainDate(a)) : chainDate(a).localeCompare(chainDate(b));
+ group.chains.sort((a, b) =>
+ sort.col === "created" ? cDate(a, b) || cName(a, b) : sort.col === "props" ? cProps(a, b) || cName(a, b) : cName(a, b),
+ );
}
return list;
}, [visibleChains, byId, sessions, fileSources, sort]);
@@ -207,23 +217,21 @@ export function ChainTableBody({
const nameOf = (id: string) => byId.get(id)?.name ?? id;
const dateOf = (id: string) => byId.get(id)?.createdAt.slice(0, 10) ?? "";
- const sortArrow = (col: ChainSortCol) =>
- sort.col !== col ? "" : sort.dir === 1 ? "▲" : "▼";
+ const dirIcon = (col: ChainSortCol) =>
+ sort.col !== col ? null : sort.dir === 1 ?