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:
+41
-18
@@ -502,6 +502,19 @@ export function ChainTableBody({
|
|||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
|
{selectMode ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="chain-practice-done"
|
||||||
|
onClick={onSelect}
|
||||||
|
aria-label={`Done picking — ${selection.size} ${selection.size === 1 ? "proposition" : "propositions"} selected`}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<DumbbellIcon aria-hidden="true" className="size-3.5" />
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* The danger fence (owner ruling): a divider quarantines
|
{/* The danger fence (owner ruling): a divider quarantines
|
||||||
the destructive action at the bar's right end — the
|
the destructive action at the bar's right end — the
|
||||||
safe actions and the kill switch never blend. The
|
safe actions and the kill switch never blend. The
|
||||||
@@ -517,6 +530,8 @@ export function ChainTableBody({
|
|||||||
<Trash2Icon aria-hidden="true" className="size-3.5" />
|
<Trash2Icon aria-hidden="true" className="size-3.5" />
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</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);
|
||||||
}
|
}
|
||||||
return s;
|
applySelection(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 } }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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"'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user