diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 2017262..802c99e 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -218,6 +218,44 @@ export function wrapRegionLabel( lines.push(`${tail}…`); return lines; } + +/** Label-only cluster patch (rename-shuffle fix, 2026-09-23): apply a set + * of live label updates to the data-side cluster records AND the built + * region meta (re-wrapping the changed entries with the provided + * measure — the same wrap a geometry build would produce). The + * hull-silence guard travels here: a cluster whose CURRENT label is + * null is a gone record — no rename can reach it. Pure, in-place on the + * provided arrays; returns whether anything changed (the caller only + * redraws when it did). Exported for tests. */ +export function applyClusterLabels( + clusters: { id: string; label: string | null }[], + regionMeta: { id: string; label: string | null; lines: string[] | null }[], + labels: ReadonlyMap, + maxWidth: number, + maxLines: number, + measure: (s: string) => number, +): boolean { + let changed = false; + for (const cluster of clusters) { + if (!labels.has(cluster.id)) continue; + if (cluster.label == null) continue; // hull silence: gone records stay silent + const label = labels.get(cluster.id) ?? null; + if (label === cluster.label) continue; + cluster.label = label; + changed = true; + } + if (!changed) return false; + for (const rm of regionMeta) { + if (!labels.has(rm.id)) continue; + if (rm.label == null) continue; + const label = labels.get(rm.id) ?? null; + rm.label = label; + rm.lines = label + ? wrapRegionLabel(label, maxWidth, maxLines, measure) + : null; + } + return true; +} const LABEL_MAX_WIDTH = 170; // wrap width at the reference label size, world units const LABEL_GAP = 2.9; // world units between node edge and first label line (the 2px-at-fit reference ÷ 0.69) const LABEL_LINE_HEIGHT = 1.3; @@ -590,7 +628,7 @@ export class GraphCanvasRenderer { /** World rect the bitmap covers (drawImage target coords). */ private regionRect: { minX: number; minY: number; w: number; h: number } | null = null; /** Region labels: cluster centroid + hue, computed at splat build. */ - private regionMeta: { label: string | null; lines: string[] | null; cx: number; cy: number; hue: number }[] = []; + private regionMeta: { id: string; label: string | null; lines: string[] | null; cx: number; cy: number; hue: number }[] = []; private regionTuning: RegionTuning = DEFAULT_REGION_TUNING; private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING; /** Gravity center for the live sim (graph bbox centroid at setData). */ @@ -771,6 +809,42 @@ export class GraphCanvasRenderer { this.markDirty(); } + /** Label-only channel (rename-shuffle fix, 2026-09-23): live + * cluster-label updates with NO setData. The rename path used to + * build a fresh snapshot object, which flowed setData → geometry + * rebuild — and the rebuild treats the last-fetched ledger as + * authoritative and cancels any pending drag commit, so a rename + * visibly rewound the whole graph whenever display had drifted + * (recent drag, mid-anneal, physics re-anneal). This channel touches + * ONLY label text: the region meta the draw pass reads plus the + * data-side cluster record (so the next geometry rebuild — + * visibility/settings/size — keeps the fresh label). Nodes, the + * sim, positions, and the pending commit are never touched. */ + setClusterLabels(labels: ReadonlyMap): void { + const data = this.data; + if (!data) return; + const settings = this.settings; + // No settings yet → nothing is drawn; patch the data side only and + // let the next build derive the wrap (same guard the build uses). + if (!settings) { + applyClusterLabels(data.clusters, [], labels, REGION_LABEL_MAX_WIDTH, REGION_LABEL_MAX_LINES, () => 0); + return; + } + const ctx = this.ctx; + const prevFont = ctx.font; + ctx.font = `${LABEL_WEIGHT} ${settings.clusterLabelSize}px ${data.fontFamily}`; + const changed = applyClusterLabels( + data.clusters, + this.regionMeta, + labels, + REGION_LABEL_MAX_WIDTH, + REGION_LABEL_MAX_LINES, + (s) => ctx.measureText(s).width, + ); + ctx.font = prevFont; + if (changed) this.markDirty(); + } + setVisible(visible: ReadonlySet | null): void { this.visible = visible; this.geoVersion++; // member visibility changes hulls + paths @@ -1229,6 +1303,14 @@ export class GraphCanvasRenderer { }, }); this.simById = new Map(data.nodes.map((n) => [n.id, n as LayoutNode])); + // Hygiene (2026-09-23 fix wave): a fresh sim starts at alpha 1 and + // never decays while stopped — so the first post-rebuild drag would + // re-anneal the whole graph at full strength (alphaTarget(0.3) + // glides DOWN from 1 instead of gently up from rest). Initialize at + // the settle floor so first-drag behavior matches steady-state + // drags. setPhysicsTuning still rehearses its explicit alpha(1) + // when a full re-anneal IS the point. + this.sim.alpha(SIM_SETTLE_ALPHA); } /** Rasterize cluster regions into the low-res world-space bitmap. @@ -1296,6 +1378,7 @@ export class GraphCanvasRenderer { } if (members > 0) { this.regionMeta.push({ + id: cluster.id, label: cluster.label || null, cx: sumX / members, cy: sumY / members, diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 573ae62..4264f18 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -129,6 +129,64 @@ export function isNodeVisible(node: Pick, query return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q); } +/** In-place sessions patch for a live rename (rename-shuffle fix, + * 2026-09-23). The OLD path built a fresh snapshot object, which + * flowed through the [snapshot]-keyed setData effect → geometry + * rebuild → last-fetched ledger became authoritative + pending drag + * commit cancelled → the graph visibly rewound whenever display had + * drifted. This seam NEVER changes object identity: same snapshot + * reference, same clusters array — so the data feed cannot re-fire. + * The display refresh rides a separate tick state, and the cluster + * label itself goes through the renderer's label-only channel + * (setClusterLabels). Exported for tests; pure display state only — + * the server derives the same join on its next read. */ +export function renameSessionsPatch(snapshot: Pick, id: string, title: string): void { + snapshot.sessions[id] = title; +} + +/** Seed positions for nodes the ledger doesn't know yet (hygiene, + * 2026-09-23 fix wave): they used to fall through to a ?? 0 and dump + * at the origin. Place them on a deterministic ring around the known + * positions' centroid — angle hashed from the id (FNV-1a), golden-angle + * spiral offset so even hash collisions spread. Stable across + * remounts, far enough off the mass to see, and they relax with the + * pack on the next drag. In-place on the provided map; pure otherwise. */ +export function seedMissingNodePositions( + nodes: readonly { id: string }[], + positions: Map, +): Map { + if (positions.size === 0 || nodes.every((n) => positions.has(n.id))) return positions; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const p of positions.values()) { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const radius = Math.max(maxX - minX, maxY - minY, 40) * 0.7 + 20; + const missing = nodes.filter((n) => !positions.has(n.id)); + missing.forEach((n, i) => { + // FNV-1a hash → [0,1) → angle; the golden-angle spiral offset + // both spreads collisions and staggers long paths deterministically. + let h = 0x811c9dc5; + for (let c = 0; c < n.id.length; c++) { + h ^= n.id.charCodeAt(c); + h = (h * 0x01000193) >>> 0; + } + const angle = ((h / 0xffffffff) * 2 * Math.PI + i * 2.39996323) % (2 * Math.PI); + positions.set(n.id, { + x: cx + Math.cos(angle) * radius, + y: cy + Math.sin(angle) * radius, + }); + }); + return positions; +} + // ---- Color helpers (canvas wants hex, not oklch) --------------------------- /** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */ @@ -921,27 +979,27 @@ export function GraphView() { return () => window.removeEventListener("lh:graph-revision", onRevision); }, [refreshCosts]); - /* Live rename labels (wave 7 interlude): a session rename anywhere - (sidebar inline row, chat header) announces itself as lh:chat-renamed - {id, title} — and the birth-session partition means that title IS - the cluster label. Patch the snapshot in place: the sessions map - (the detail card's provenance line) and the one matching cluster. - No refetch — the server derives the same join on its next read, this - just keeps the two views from disagreeing until then. Unlabeled - clusters keep their hull-only silence (a null label is a GONE - record — a rename can't reach one, and the guard holds the line). */ + /* Live rename labels (rename-shuffle fix, 2026-09-23): a session + rename anywhere (sidebar inline row, chat header) announces itself + as lh:chat-renamed {id, title} — and the birth-session partition + means that title IS the cluster label. The label update goes + through the renderer's label-only channel (setClusterLabels): NO + new snapshot object, NO setData, NO geometry rebuild — so the + position ledger can't assert itself and a pending drag commit + survives. The sessions map (the detail card's provenance line) + patches in place; a tick bump re-renders the view so an open + detail card reads the fresh title. Unlabeled clusters keep their + hull-only silence — the null-label guard lives in the channel + (applyClusterLabels): a rename can't reach a gone record. */ + const [, setLabelTick] = useState(0); useEffect(() => { const onRenamed = (e: Event) => { const { id, title } = (e as CustomEvent<{ id?: string; title?: string }>).detail ?? {}; if (typeof id !== "string" || typeof title !== "string" || id === "" || title === "") return; - setSnapshot((snap) => { - if (snap == null) return snap; - const sessions = { ...snap.sessions, [id]: title }; - const clusters = snap.clusters.map((c) => - c.id === id && c.label != null ? { ...c, label: title } : c, - ); - return { ...snap, sessions, clusters }; - }); + const snap = snapshotRef.current; + if (snap != null) renameSessionsPatch(snap, id, title); + rendererRef.current?.setClusterLabels(new Map([[id, title]])); + setLabelTick((t) => t + 1); }; window.addEventListener("lh:chat-renamed", onRenamed); return () => window.removeEventListener("lh:chat-renamed", onRenamed); @@ -995,7 +1053,10 @@ export function GraphView() { if (p) positions.set(node.id, { x: p.x, y: p.y }); } if (positions.size > 0) { - posRef.current = positions; + // Hygiene (2026-09-23 fix wave): nodes the ledger doesn't know + // yet (raced mid-settle) get seeded ring positions instead of + // the old ?? 0 origin dump. + posRef.current = seedMissingNodePositions(snapshot.nodes, positions); return () => { posRef.current = null; }; diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts index ca7b223..53d93bc 100644 --- a/tests/graph-canvas.test.ts +++ b/tests/graph-canvas.test.ts @@ -12,6 +12,7 @@ import assert from "node:assert/strict"; import { describe, it, it as test } from "node:test"; import { + applyClusterLabels, arrowGeometry, boxBlurRound, fitTransform, @@ -314,3 +315,81 @@ describe("wrapRegionLabel (wave 7: session names / file paths)", () => { assert.ok(measure(lines[1]!) <= W, "ellipsized tail fits"); }); }); + + +describe("applyClusterLabels (the label-only rename channel)", () => { + const measure = (s: string) => s.length * 5; + const W = 220; + const MAXL = 3; + + const fixture = () => ({ + clusters: [ + { id: "chat_a", label: "Old title" }, + { id: "chat_b", label: null }, + { id: "seedplan", label: "Orbital mechanics" }, + ], + regionMeta: [ + { id: "chat_a", label: "Old title", lines: ["Old title"], cx: 0, cy: 0, hue: 10 }, + { id: "chat_b", label: null, lines: null, cx: 1, cy: 1, hue: 20 }, + { id: "seedplan", label: "Orbital mechanics", lines: ["Orbital mechanics"], cx: 2, cy: 2, hue: 30 }, + ], + }); + + it("patches the matching cluster and re-wraps its region meta", () => { + const f = fixture(); + // 5px/char and W=220: a 45-char title just overflows one line + // ("...for the" is 40 chars, appending " graph" reads 46). + const longTitle = "A much longer new session title for the graph"; + const changed = applyClusterLabels( + f.clusters, f.regionMeta, new Map([["chat_a", longTitle]]), + W, MAXL, measure, + ); + assert.equal(changed, true); + assert.equal(f.clusters[0]!.label, longTitle); + assert.deepEqual(f.regionMeta[0]!.lines, ["A much longer new session title for the", "graph"]); + // untouched entries stay byte-identical + assert.equal(f.clusters[1]!.label, null); + assert.equal(f.clusters[2]!.label, "Orbital mechanics"); + assert.deepEqual(f.regionMeta[2]!.lines, ["Orbital mechanics"]); + }); + + it("hull silence: a null-label cluster never gains a label", () => { + const f = fixture(); + const changed = applyClusterLabels( + f.clusters, f.regionMeta, new Map([["chat_b", "Resurrected?"]]), + W, MAXL, measure, + ); + assert.equal(changed, false); + assert.equal(f.clusters[1]!.label, null); + assert.equal(f.regionMeta[1]!.lines, null); + }); + + it("a label already matching is a no-op (no redraw)", () => { + const f = fixture(); + const changed = applyClusterLabels( + f.clusters, f.regionMeta, new Map([["seedplan", "Orbital mechanics"]]), + W, MAXL, measure, + ); + assert.equal(changed, false); + }); + + it("an explicit null clears a labeled cluster's lines", () => { + const f = fixture(); + const changed = applyClusterLabels( + f.clusters, f.regionMeta, new Map([["chat_a", null]]), + W, MAXL, measure, + ); + assert.equal(changed, true); + assert.equal(f.clusters[0]!.label, null); + assert.equal(f.regionMeta[0]!.lines, null); + }); + + it("unknown ids in the map change nothing", () => { + const f = fixture(); + const changed = applyClusterLabels( + f.clusters, f.regionMeta, new Map([["chat_ghost", "Nope"]]), + W, MAXL, measure, + ); + assert.equal(changed, false); + }); +}); diff --git a/tests/graph-rename-labels.test.ts b/tests/graph-rename-labels.test.ts new file mode 100644 index 0000000..2c4c592 --- /dev/null +++ b/tests/graph-rename-labels.test.ts @@ -0,0 +1,120 @@ +/** + * Rename-shuffle fix tests (2026-09-23 fix wave): the live-rename label + * path must be LABEL-ONLY — it can never build a new snapshot object, + * because the [snapshot]-keyed data feed (renderer.setData) treats any + * identity change as "the server spoke": the geometry rebuild makes the + * last-fetched ledger authoritative and cancels any pending drag + * commit, rewinding the graph whenever display had drifted. + * + * The pinned contracts here: + * - renameSessionsPatch changes display state IN PLACE (no identity + * change → the data feed cannot re-fire); + * - seedMissingNodePositions gives ledger-unknown nodes a seeded ring + * instead of the old ?? 0 origin dump; + * - applyClusterLabels (the renderer's label-only channel) has its own + * suite in graph-canvas.test.ts. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renameSessionsPatch, seedMissingNodePositions } from "../components/graph-view.tsx"; + +const snapshot = (): { + sessions: Record; + fileSources: Record; + clusters: { id: string; label: string | null; members: string[] }[]; + nodes: { id: string }[]; + edges: never[]; + nodeSessions: Record; + nodeObservations: Record>; +} => ({ + sessions: { chat_a: "Old title", seedplan: "Orbital mechanics" }, + fileSources: {}, + clusters: [ + { id: "chat_a", label: "Old title", members: ["n1"] }, + { id: "chat_b", label: null, members: ["n2"] }, + ], + nodes: [{ id: "n1" }, { id: "n2" }], + edges: [], + nodeSessions: {}, + nodeObservations: {}, +}); + +describe("renameSessionsPatch (in-place, identity-preserving)", () => { + it("writes the title into the sessions map and keeps EVERY object identity", () => { + const snap = snapshot(); + const sessionsRef = snap.sessions; + const clustersRef = snap.clusters; + renameSessionsPatch(snap, "chat_a", "New title"); + // display state changed... + assert.equal(snap.sessions.chat_a, "New title"); + // ...but nothing was rebuilt: same map, same array, same snapshot. + // This is the contract that keeps the [snapshot]-keyed setData + // feed from firing (and the pending drag commit from dying). + assert.equal(snap.sessions, sessionsRef, "sessions map identity preserved"); + assert.equal(snap.clusters, clustersRef, "clusters array identity preserved"); + assert.equal(snap.clusters[0]!.label, "Old title", "react-side cluster label untouched (renderer channel owns it)"); + }); + + it("adds an unknown session id harmlessly (renames outside the graph)", () => { + const snap = snapshot(); + renameSessionsPatch(snap, "chat_other", "Some other chat"); + assert.equal(snap.sessions.chat_other, "Some other chat"); + assert.equal(snap.sessions.chat_a, "Old title"); + }); +}); + +describe("seedMissingNodePositions (ledger stragglers)", () => { + const positions = (): Map => + new Map([ + ["n1", { x: -100, y: 0 }], + ["n2", { x: 100, y: 40 }], + ]); + + it("known positions are byte-identical; missing nodes are seeded off the origin", () => { + const nodes = [{ id: "n1" }, { id: "n2" }, { id: "n_new" }, { id: "n_newer" }]; + const out = seedMissingNodePositions(nodes, positions()); + // the two known entries untouched (same objects, same values) + assert.equal(out.get("n1"), out.get("n1")); + assert.equal(out.get("n1")!.x, -100); + assert.equal(out.get("n2")!.y, 40); + // every node has a position now — no ?? 0 origin dump + assert.equal(out.size, 4); + for (const id of ["n_new", "n_newer"]) { + const p = out.get(id)!; + assert.ok(p.x !== 0 || p.y !== 0, `${id} not at the origin`); + } + }); + + it("deterministic across runs (same ids → same ring spots)", () => { + const nodes = [{ id: "n1" }, { id: "n2" }, { id: "n_new" }]; + const a = seedMissingNodePositions(nodes, positions()); + const b = seedMissingNodePositions(nodes, positions()); + assert.deepEqual( + [...a.entries()].map(([id, p]) => [id, p.x, p.y]), + [...b.entries()].map(([id, p]) => [id, p.x, p.y]), + ); + }); + + it("seeds land on the ring around the centroid (off the known mass)", () => { + const nodes = [{ id: "n1" }, { id: "n2" }, { id: "n_new" }]; + const out = seedMissingNodePositions(nodes, positions()); + const p = out.get("n_new")!; + // centroid (0, 20); radius = max(200, 40, 40) * 0.7 + 20 = 160 + const dist = Math.hypot(p.x - 0, p.y - 20); + assert.ok(Math.abs(dist - 160) < 1e-9, `seed on the 160-unit ring, got ${dist}`); + }); + + it("nothing missing → the map comes back untouched (early exit)", () => { + const pos = positions(); + const out = seedMissingNodePositions([{ id: "n1" }, { id: "n2" }], pos); + assert.equal(out, pos); + assert.equal(out.size, 2); + }); + + it("empty known map → early exit (the bootstrap fallback owns that case)", () => { + const pos = new Map(); + const out = seedMissingNodePositions([{ id: "n1" }], pos); + assert.equal(out.size, 0); + }); +});