proposition pick: persistent lifted selection, live count, honest affordances

Root cause of 'doesn't register': the slice-6 floating-bar edit never
landed (a silent no-match replace) — the Body accepted selectMode but
the bar ignored it, so the picker showed Clear + Delete and nothing
ever confirmed. Found by re-reading; the test suite never pinned the
select-mode bar.

The owner vision instead: the chain table's selection is PERSISTENT
and caller-owned.
- ChainTableOverlay gains controlledSelection/onSelectionChange (every
  check, clear, and delete-prune reports up immediately; uncontrolled
  fallback keeps the delete flow self-contained).
- Select mode's floating bar: Done (closes; the pick already
  registered) — the delete fence never renders while picking.
- GraphView threads the lifted set through, resolving ids → nodes
  against the live snapshot.
- Home: the pick bar above the composer shows 'N propositions
  selected' + Clear in EITHER mode (dashed 'Pick propositions from the
  map' hint only in practice mode with nothing picked); visible hover
  (bg-accent) so the chip reads clickable. Practice sends still seed
  from the pick set.
This commit is contained in:
amosblomqvist
2026-10-04 22:54:17 +03:00
parent 89580d2b93
commit 87f8e1e6f4
4 changed files with 139 additions and 61 deletions
+60 -37
View File
@@ -502,21 +502,36 @@ export function ChainTableBody({
> >
Clear Clear
</button> </button>
{/* The danger fence (owner ruling): a divider quarantines {selectMode ? (
the destructive action at the bar's right end — the <button
safe actions and the kill switch never blend. The type="button"
full-death confirm dialog catches the misclick. */} data-testid="chain-practice-done"
<span data-testid="chain-danger-divider" aria-hidden="true" className="h-4 w-px bg-border/70" /> onClick={onSelect}
<button aria-label={`Done picking — ${selection.size} ${selection.size === 1 ? "proposition" : "propositions"} selected`}
type="button" className="flex items-center gap-1.5 rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/80"
data-testid="chain-delete" >
onClick={onDelete} <DumbbellIcon aria-hidden="true" className="size-3.5" />
aria-label={`Delete ${selection.size} selected ${selection.size === 1 ? "node" : "nodes"}`} Done
className="flex items-center gap-1.5 rounded-full border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20" </button>
> ) : (
<Trash2Icon aria-hidden="true" className="size-3.5" /> <>
Delete {/* The danger fence (owner ruling): a divider quarantines
</button> 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. */}
<span data-testid="chain-danger-divider" aria-hidden="true" className="h-4 w-px bg-border/70" />
<button
type="button"
data-testid="chain-delete"
onClick={onDelete}
aria-label={`Delete ${selection.size} selected ${selection.size === 1 ? "node" : "nodes"}`}
className="flex items-center gap-1.5 rounded-full border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
>
<Trash2Icon aria-hidden="true" className="size-3.5" />
Delete
</button>
</>
)}
</div> </div>
</div> </div>
)} )}
@@ -583,11 +598,15 @@ export function ChainTableOverlay({
sessions, sessions,
fileSources, fileSources,
onClose, onClose,
/** PRACTICE SELECT (2026-10-04): the table opens as a proposition /** PRACTICE SELECT (owner vision, 2026-10-04): the selection is
* picker — the floating bar confirms the selection to the caller * PERSISTENT and caller-owned — when these props are present the
* (never deletes); the title says what the pick is for. */ * 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, selectMode,
onConfirmSelection, controlledSelection,
onSelectionChange,
}: { }: {
open: boolean; open: boolean;
nodes: GraphNode[]; nodes: GraphNode[];
@@ -596,15 +615,24 @@ export function ChainTableOverlay({
fileSources: Record<string, string>; fileSources: Record<string, string>;
onClose: () => void; onClose: () => void;
selectMode?: boolean; selectMode?: boolean;
onConfirmSelection?: (nodes: GraphNode[]) => void; controlledSelection?: ReadonlySet<string>;
onSelectionChange?: (next: ReadonlySet<string>) => void;
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [sort, setSort] = useState<ChainSort>({ col: "name", dir: 1 }); const [sort, setSort] = useState<ChainSort>({ col: "name", dir: 1 });
const [selection, setSelection] = useState<ReadonlySet<string>>(() => new Set()); const [internalSelection, setInternalSelection] = useState<ReadonlySet<string>>(() => new Set());
const [expandedClusters, setExpandedClusters] = useState<ReadonlySet<string>>(() => new Set()); const [expandedClusters, setExpandedClusters] = useState<ReadonlySet<string>>(() => new Set());
const [expandedChains, setExpandedChains] = useState<ReadonlySet<string>>(() => new Set()); const [expandedChains, setExpandedChains] = useState<ReadonlySet<string>>(() => new Set());
const [deleteOpen, setDeleteOpen] = useState(false); 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<string>) => {
if (onSelectionChange) onSelectionChange(next);
else setInternalSelection(next);
};
// Esc dismisses (unless a dialog has focus — each dialog's own Esc // Esc dismisses (unless a dialog has focus — each dialog's own Esc
// owns its layer; all listen on window, so the guards are explicit). // owns its layer; all listen on window, so the guards are explicit).
useEffect(() => { useEffect(() => {
@@ -623,15 +651,14 @@ export function ChainTableOverlay({
const derived = chainsForSelection(selectedNodes, storedChains); const derived = chainsForSelection(selectedNodes, storedChains);
const nodeIds = [...selection].sort(); const nodeIds = [...selection].sort();
const toggleNodes = (ids: string[], next: boolean) => const toggleNodes = (ids: string[], next: boolean) => {
setSelection((prev) => { const s = new Set(selection);
const s = new Set(prev); for (const id of ids) {
for (const id of ids) { if (next) s.add(id);
if (next) s.add(id); else s.delete(id);
else s.delete(id); }
} applySelection(s);
return s; };
});
const toggleIn = (set: ReadonlySet<string>, id: string) => { const toggleIn = (set: ReadonlySet<string>, id: string) => {
const s = new Set(set); const s = new Set(set);
if (s.has(id)) s.delete(id); if (s.has(id)) s.delete(id);
@@ -666,15 +693,11 @@ export function ChainTableOverlay({
onToggleCluster={(key) => setExpandedClusters((prev) => toggleIn(prev, key))} onToggleCluster={(key) => setExpandedClusters((prev) => toggleIn(prev, key))}
onToggleChain={(id) => setExpandedChains((prev) => toggleIn(prev, id))} onToggleChain={(id) => setExpandedChains((prev) => toggleIn(prev, id))}
onToggleNodes={toggleNodes} onToggleNodes={toggleNodes}
onClear={() => setSelection(new Set())} onClear={() => applySelection(new Set())}
onDelete={() => setDeleteOpen(true)} onDelete={() => setDeleteOpen(true)}
onClose={onClose} onClose={onClose}
selectMode={selectMode} selectMode={selectMode}
onSelect={() => { onSelect={onClose}
onConfirmSelection?.(selectedNodes);
setSelection(new Set());
onClose();
}}
/> />
</div> </div>
{deleteOpen && ( {deleteOpen && (
@@ -688,7 +711,7 @@ export function ChainTableOverlay({
// snapshot — fresh nodes/edges flow back in as props. // snapshot — fresh nodes/edges flow back in as props.
setDeleteOpen(false); setDeleteOpen(false);
const dead = new Set(deadIds); 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 } })); window.dispatchEvent(new CustomEvent("lh:graph-revision", { detail: { graphRevision } }));
}} }}
/> />
+20 -8
View File
@@ -946,14 +946,18 @@ export function GraphChromeRow({
export function GraphView({ export function GraphView({
chainSelectOpen = false, chainSelectOpen = false,
onChainSelected, practiceSelection,
onPracticeSelectionChange,
onChainSelectClose, onChainSelectClose,
}: { }: {
/** PRACTICE SELECT (owner, 2026-10-04): the home composer's practice /** PRACTICE SELECT (owner vision, 2026-10-04): the LIFTED, persistent
* chip opens the chain table as a proposition picker — this prop * pick set — every check/clear in the chain table (however opened)
* drives it; the confirm hands the picked nodes to the caller. */ * 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; chainSelectOpen?: boolean;
onChainSelected?: (nodes: { id: string; name: string; statement: string }[]) => void; practiceSelection?: ReadonlySet<string>;
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
onChainSelectClose?: () => void; onChainSelectClose?: () => void;
}) { }) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -1700,9 +1704,17 @@ export function GraphView({
onChainSelectClose?.(); onChainSelectClose?.();
}} }}
selectMode={chainSelectOpen} selectMode={chainSelectOpen}
onConfirmSelection={(picked) => { controlledSelection={practiceSelection}
onChainSelected?.(picked.map((n) => ({ id: n.id, name: n.name, statement: n.statement }))); onSelectionChange={(ids) => {
onChainSelectClose?.(); // 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);
}} }}
/> </div> /> </div>
); );
+20 -15
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react"; import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react";
import { GraphView } from "@/components/graph-view"; import { GraphView } from "@/components/graph-view";
@@ -73,6 +73,8 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
const [mode, setMode] = useState<"teach" | "practice">("teach"); const [mode, setMode] = useState<"teach" | "practice">("teach");
const [practiceProps, setPracticeProps] = useState<{ id: string; name: string; statement: string }[]>([]); const [practiceProps, setPracticeProps] = useState<{ id: string; name: string; statement: string }[]>([]);
const [chainSelectOpen, setChainSelectOpen] = useState(false); 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 /* 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 * run is live with the topic still deferred in the run's buffer — stop
@@ -154,13 +156,8 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
<main className="absolute inset-0"> <main className="absolute inset-0">
<GraphView <GraphView
chainSelectOpen={chainSelectOpen} chainSelectOpen={chainSelectOpen}
onChainSelected={(picked) => { practiceSelection={pickedIds}
setPracticeProps((prev) => { onPracticeSelectionChange={setPracticeProps}
const byId = new Map(prev.map((p) => [p.id, p]));
for (const p of picked) byId.set(p.id, p);
return [...byId.values()];
});
}}
onChainSelectClose={() => setChainSelectOpen(false)} onChainSelectClose={() => setChainSelectOpen(false)}
/> />
</main> </main>
@@ -182,17 +179,25 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
px-4) so nothing shifts when a session starts (owner). */} px-4) so nothing shifts when a session starts (owner). */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5"> <div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5">
<div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4"> <div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4">
{/* The practice chip (owner, 2026-10-04): the graph-view {/* The pick bar (owner vision, 2026-10-04): the chain
entry — a count of the picked propositions, click opens table's selection is PERSISTENT — picked
the chain table as the picker. Quiet, left-aligned above propositions show here as a count + Clear in EITHER
the composer; teaching mode renders nothing. */} mode (it survives mode switches; only a practice
{mode === "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") && (
<div className="mb-2 flex items-center justify-start gap-1.5"> <div className="mb-2 flex items-center justify-start gap-1.5">
<button <button
type="button" type="button"
data-testid="practice-props-chip" data-testid="practice-props-chip"
onClick={() => setChainSelectOpen(true)} onClick={() => setChainSelectOpen(true)}
className="flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50" className={
"flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 font-ui text-xs transition-colors " +
(practiceProps.length > 0
? "border-border bg-card text-foreground hover:bg-accent"
: "border-dashed border-border/80 bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground")
}
> >
<DumbbellIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> <DumbbellIcon aria-hidden="true" className="size-3.5 text-muted-foreground" />
{practiceProps.length > 0 {practiceProps.length > 0
@@ -204,7 +209,7 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
type="button" type="button"
data-testid="practice-props-clear" data-testid="practice-props-clear"
onClick={() => setPracticeProps([])} onClick={() => setPracticeProps([])}
className="rounded-full px-2 py-1 font-ui text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" className="cursor-pointer rounded-full px-2 py-1 font-ui text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
> >
Clear Clear
</button> </button>
+39 -1
View File
@@ -27,7 +27,7 @@ import React from "react";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test"; 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 { ChainSort } from "../components/chain-table.tsx";
import type { GraphChain, GraphNode } from "../src/core/contracts.ts"; 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.")); assert.ok(html.includes("No matching chains."));
}); });
}); });
describe("ChainTableOverlay — the lifted practice pick (owner vision, 2026-10-04)", () => {
function overlay(extra: Record<string, unknown> = {}) {
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"'));
});
});