diff --git a/components/chain-table.tsx b/components/chain-table.tsx
index b1e7538..ff268f2 100644
--- a/components/chain-table.tsx
+++ b/components/chain-table.tsx
@@ -502,21 +502,36 @@ export function ChainTableBody({
>
Clear
- {/* The danger fence (owner ruling): a divider quarantines
- the destructive action at the bar's right end — the
- safe actions and the kill switch never blend. The
- full-death confirm dialog catches the misclick. */}
-
-
+ {selectMode ? (
+
+ ) : (
+ <>
+ {/* The danger fence (owner ruling): a divider quarantines
+ the destructive action at the bar's right end — the
+ safe actions and the kill switch never blend. The
+ full-death confirm dialog catches the misclick. */}
+
+
+ >
+ )}
)}
@@ -583,11 +598,15 @@ export function ChainTableOverlay({
sessions,
fileSources,
onClose,
- /** PRACTICE SELECT (2026-10-04): the table opens as a proposition
- * picker — the floating bar confirms the selection to the caller
- * (never deletes); the title says what the pick is for. */
+ /** PRACTICE SELECT (owner vision, 2026-10-04): the selection is
+ * PERSISTENT and caller-owned — when these props are present the
+ * table is CONTROLLED (every check/clear/delete-prune reports up
+ * immediately; the composer-side chip reflects it live). selectMode
+ * only changes the floating bar: Done instead of the delete fence
+ * (deleting while picking is a footgun). */
selectMode,
- onConfirmSelection,
+ controlledSelection,
+ onSelectionChange,
}: {
open: boolean;
nodes: GraphNode[];
@@ -596,15 +615,24 @@ export function ChainTableOverlay({
fileSources: Record;
onClose: () => void;
selectMode?: boolean;
- onConfirmSelection?: (nodes: GraphNode[]) => void;
+ controlledSelection?: ReadonlySet;
+ onSelectionChange?: (next: ReadonlySet) => void;
}) {
const [query, setQuery] = useState("");
const [sort, setSort] = useState({ col: "name", dir: 1 });
- const [selection, setSelection] = useState>(() => new Set());
+ const [internalSelection, setInternalSelection] = useState>(() => new Set());
const [expandedClusters, setExpandedClusters] = useState>(() => new Set());
const [expandedChains, setExpandedChains] = useState>(() => new Set());
const [deleteOpen, setDeleteOpen] = useState(false);
+ // Controlled when the caller lifts the selection (the practice pick
+ // set); internal (uncontrolled) otherwise — the delete flow's own.
+ const selection = controlledSelection ?? internalSelection;
+ const applySelection = (next: ReadonlySet) => {
+ if (onSelectionChange) onSelectionChange(next);
+ else setInternalSelection(next);
+ };
+
// Esc dismisses (unless a dialog has focus — each dialog's own Esc
// owns its layer; all listen on window, so the guards are explicit).
useEffect(() => {
@@ -623,15 +651,14 @@ export function ChainTableOverlay({
const derived = chainsForSelection(selectedNodes, storedChains);
const nodeIds = [...selection].sort();
- const toggleNodes = (ids: string[], next: boolean) =>
- setSelection((prev) => {
- const s = new Set(prev);
- for (const id of ids) {
- if (next) s.add(id);
- else s.delete(id);
- }
- return s;
- });
+ const toggleNodes = (ids: string[], next: boolean) => {
+ const s = new Set(selection);
+ for (const id of ids) {
+ if (next) s.add(id);
+ else s.delete(id);
+ }
+ applySelection(s);
+ };
const toggleIn = (set: ReadonlySet, id: string) => {
const s = new Set(set);
if (s.has(id)) s.delete(id);
@@ -666,15 +693,11 @@ export function ChainTableOverlay({
onToggleCluster={(key) => setExpandedClusters((prev) => toggleIn(prev, key))}
onToggleChain={(id) => setExpandedChains((prev) => toggleIn(prev, id))}
onToggleNodes={toggleNodes}
- onClear={() => setSelection(new Set())}
+ onClear={() => applySelection(new Set())}
onDelete={() => setDeleteOpen(true)}
onClose={onClose}
selectMode={selectMode}
- onSelect={() => {
- onConfirmSelection?.(selectedNodes);
- setSelection(new Set());
- onClose();
- }}
+ onSelect={onClose}
/>
{deleteOpen && (
@@ -688,7 +711,7 @@ export function ChainTableOverlay({
// snapshot — fresh nodes/edges flow back in as props.
setDeleteOpen(false);
const dead = new Set(deadIds);
- setSelection((prev) => new Set([...prev].filter((id) => !dead.has(id))));
+ applySelection(new Set([...selection].filter((id) => !dead.has(id))));
window.dispatchEvent(new CustomEvent("lh:graph-revision", { detail: { graphRevision } }));
}}
/>
diff --git a/components/graph-view.tsx b/components/graph-view.tsx
index 035266b..88d0f26 100644
--- a/components/graph-view.tsx
+++ b/components/graph-view.tsx
@@ -946,14 +946,18 @@ export function GraphChromeRow({
export function GraphView({
chainSelectOpen = false,
- onChainSelected,
+ practiceSelection,
+ onPracticeSelectionChange,
onChainSelectClose,
}: {
- /** PRACTICE SELECT (owner, 2026-10-04): the home composer's practice
- * chip opens the chain table as a proposition picker — this prop
- * drives it; the confirm hands the picked nodes to the caller. */
+ /** PRACTICE SELECT (owner vision, 2026-10-04): the LIFTED, persistent
+ * pick set — every check/clear in the chain table (however opened)
+ * reports the full node list up immediately; the composer-side chip
+ * reflects it live. chainSelectOpen still drives the picker mode
+ * (Done instead of Delete). */
chainSelectOpen?: boolean;
- onChainSelected?: (nodes: { id: string; name: string; statement: string }[]) => void;
+ practiceSelection?: ReadonlySet;
+ onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
onChainSelectClose?: () => void;
}) {
const containerRef = useRef(null);
@@ -1700,9 +1704,17 @@ export function GraphView({
onChainSelectClose?.();
}}
selectMode={chainSelectOpen}
- onConfirmSelection={(picked) => {
- onChainSelected?.(picked.map((n) => ({ id: n.id, name: n.name, statement: n.statement })));
- onChainSelectClose?.();
+ controlledSelection={practiceSelection}
+ onSelectionChange={(ids) => {
+ // Resolve ids → nodes against the live snapshot; the
+ // caller holds the pick set (count + seed).
+ const props = snapshot?.propositions ?? [];
+ const picked: { id: string; name: string; statement: string }[] = [];
+ for (const id of ids) {
+ const n = props.find((node) => node.id === id);
+ if (n) picked.push({ id: n.id, name: n.name, statement: n.statement });
+ }
+ onPracticeSelectionChange?.(picked);
}}
/>
);
diff --git a/components/home.tsx b/components/home.tsx
index f1c3b49..64e82f2 100644
--- a/components/home.tsx
+++ b/components/home.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react";
import { GraphView } from "@/components/graph-view";
@@ -73,6 +73,8 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
const [mode, setMode] = useState<"teach" | "practice">("teach");
const [practiceProps, setPracticeProps] = useState<{ id: string; name: string; statement: string }[]>([]);
const [chainSelectOpen, setChainSelectOpen] = useState(false);
+ // The chain table is CONTROLLED off this id set (the lifted pick).
+ const pickedIds = useMemo(() => new Set(practiceProps.map((p) => p.id)), [practiceProps]);
/* FINISH DICTATION FIRST (owner, 2026-10-01): Enter may arrive while a
* run is live with the topic still deferred in the run's buffer — stop
@@ -154,13 +156,8 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
{
- setPracticeProps((prev) => {
- const byId = new Map(prev.map((p) => [p.id, p]));
- for (const p of picked) byId.set(p.id, p);
- return [...byId.values()];
- });
- }}
+ practiceSelection={pickedIds}
+ onPracticeSelectionChange={setPracticeProps}
onChainSelectClose={() => setChainSelectOpen(false)}
/>
@@ -182,17 +179,25 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
px-4) so nothing shifts when a session starts (owner). */}
- {/* The practice chip (owner, 2026-10-04): the graph-view
- entry — a count of the picked propositions, click opens
- the chain table as the picker. Quiet, left-aligned above
- the composer; teaching mode renders nothing. */}
- {mode === "practice" && (
+ {/* The pick bar (owner vision, 2026-10-04): the chain
+ table's selection is PERSISTENT — picked
+ propositions show here as a count + Clear in EITHER
+ mode (it survives mode switches; only a practice
+ send consumes it). Practice mode with nothing picked
+ shows the pick HINT so the affordance is discoverable;
+ teaching mode with nothing picked renders nothing. */}
+ {(practiceProps.length > 0 || mode === "practice") && (
diff --git a/tests/render-chain-table.test.tsx b/tests/render-chain-table.test.tsx
index 75c34fe..c1ec76e 100644
--- a/tests/render-chain-table.test.tsx
+++ b/tests/render-chain-table.test.tsx
@@ -27,7 +27,7 @@ import React from "react";
import assert from "node:assert/strict";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
-import { ChainTableBody } from "../components/chain-table.tsx";
+import { ChainTableBody, ChainTableOverlay } from "../components/chain-table.tsx";
import type { ChainSort } from "../components/chain-table.tsx";
import type { GraphChain, GraphNode } from "../src/core/contracts.ts";
@@ -319,3 +319,41 @@ describe("ChainTableBody — the fuzzy search (grouped + auto-expand)", () => {
assert.ok(html.includes("No matching chains."));
});
});
+
+describe("ChainTableOverlay — the lifted practice pick (owner vision, 2026-10-04)", () => {
+ function overlay(extra: Record = {}) {
+ return renderToStaticMarkup(
+ React.createElement(ChainTableOverlay, {
+ open: true,
+ nodes: NODES,
+ chains: CHAINS,
+ sessions: { chat1: "Ethics lesson", chat2: "Follow-up", notes1: "Notes" },
+ fileSources: {},
+ onClose: () => {},
+ ...extra,
+ } as never),
+ );
+ }
+
+ it("CONTROLLED selection renders the caller's set — the bar counts it live", () => {
+ const html = overlay({ controlledSelection: new Set(["a", "b", "c1"]) });
+ assert.ok(html.includes("3 nodes → 1 chain"), "the bar reflects the lifted set, not internal state");
+ });
+
+ it("select mode: Done replaces the delete fence — no way to delete while picking", () => {
+ const html = overlay({
+ selectMode: true,
+ controlledSelection: new Set(["a", "b"]),
+ });
+ assert.ok(html.includes('data-testid="chain-practice-done"'));
+ assert.ok(html.includes("Done"));
+ assert.ok(!html.includes('data-testid="chain-delete"'), "no delete while picking");
+ assert.ok(!html.includes('data-testid="chain-danger-divider"'));
+ });
+
+ it("browse mode unchanged: the fenced Delete remains", () => {
+ const html = overlay({ controlledSelection: new Set(["a"]) });
+ assert.ok(html.includes('data-testid="chain-delete"'));
+ assert.ok(!html.includes('data-testid="chain-practice-done"'));
+ });
+});