Wave 7 close-out: the graphTools surface, wrapped cluster labels, the orphan sweep

Part 1 — the surface:
- Settings ▸ Agent ▸ Tools gains the prober GRAPH-TOOLS row as its FIRST
  row (owner ruling: in the Tools section, not the tool-toggles list —
  its subject is the map, its mechanism is agent capability). Writes
  agent.graphTools {enabled} (absent = on, the toggle discipline) via
  the section PUT; composeGraphTools pure seam. Its own key in the agent
  contract — never in the tools map.
- The row carries the search-row hint discipline: "Connects prior
  sessions' knowledge into new ones — the prober reads the knowledge
  map" (owner wording). The honest interplay composes both: when
  graph.enabled is off the row dims + disables with a "Graph is
  disabled" hint (the effective gate itself is server-side, per-turn
  fresh). AgentSection reads the graph section (record.graph, passed by
  the dialog) — read-only.
- Cluster labels wrap: wave 7 made labels session names and FILE PATHS,
  which the old single-line fillText could not bound. wrapRegionLabel
  (pure, exported): word-wrap first, char-split long paths (the
  node-label discipline), capped at 3 lines, overflow folded into an
  ellipsized last line (owner: wrap, cap 2–3, ellipsize). Pre-wrapped
  once per geometry build at the SCREEN-fixed size (viewer-relative
  labels — the wrap never goes stale mid-camera-move), drawn centered
  line-by-line at the centroid. Deleted-session clusters keep their
  hull-only honest silence (label null → no lines).
- The orphan sweep: StatsBar's leftSlot prop is dead (its one consumer,
  the knowledge-context slider, died in c690181) — removed, and pre-chat
  views now render NOTHING on the left (owner ruling this round: a
  "— · —" summary over an empty popover is pure noise) — the dictation
  middle just widens. Stale knowledge-context comments swept in
  chat.tsx (adoption-window + activity-nudge docs), activity-dock.tsx
  (useActivity ground rules), dictation.tsx (×2 adaptive-meter docs),
  home.tsx (pre-chat dock comment).

Part 2 — Teacher's c690181 review: clean. Imports, KIND_ICON/KIND_LABEL
entries, render branches and event seams all excised whole; the probe-
handoff branch re-indented intact and merge-adopted; tests updated in
lockstep (merge-adopted, record-adapter, render-new-session retired).
Platform's 4b60e0f roster-string fixes (labeler dropped from both
SLOT_META blocks) reviewed — consistent across agent-section +
setup-dialog.

Gates: tsc clean, 854/854 (8 new: wrapRegionLabel cases, graphTools
SSR rows ×3; stats-bar + ladder fixtures updated to the new rulings),
lint 0, build ok. Live on :3001: graphTools row first-in-group with
hint, off→{enabled:false}→on round-trip, graph-off dimming + hint +
disabled toggle, pre-chat rows summary-free, chat view keeps its
summary, long-title cluster label wrapped without console errors
(visual read unavailable this session — pure wrap is unit-pinned).
This commit is contained in:
amosblomqvist
2026-09-23 15:57:59 +03:00
parent d1b46132b9
commit 7a17230f26
11 changed files with 266 additions and 40 deletions
+53 -1
View File
@@ -10,13 +10,14 @@
*/
import assert from "node:assert/strict";
import { describe, it as test } from "node:test";
import { describe, it, it as test } from "node:test";
import {
arrowGeometry,
boxBlurRound,
fitTransform,
labelAlpha,
pickNearest,
wrapRegionLabel,
zoomRatioOf,
LABELS_FADE_RATIO,
} from "../components/graph-canvas.ts";
@@ -262,3 +263,54 @@ describe("sanitizePhysicsTuning (physics controls)", () => {
});
});
describe("wrapRegionLabel (wave 7: session names / file paths)", () => {
// A fake measure: chars are 5px wide, spaces 4px — enough to drive
// wrap decisions deterministically.
const measure = (s: string) => s.length * 5;
const W = 20; // 4 chars per line
it("fits → single line, untouched", () => {
assert.deepEqual(wrapRegionLabel("Plato", 100, 3, measure), ["Plato"]);
});
it("wraps words, no cap hit", () => {
// W=20 at 5px/char = 3 chars a line: every word lands alone, four
// lines, cap 4 never reached, no ellipsis.
assert.deepEqual(wrapRegionLabel("aaa bbb ccc ddd", W, 4, measure), [
"aaa",
"bbb",
"ccc",
"ddd",
]);
});
it("caps at maxLines with an ellipsis on the last line", () => {
const lines = wrapRegionLabel("aaaa bbbb cccc dddd eeee", W, 2, measure);
assert.equal(lines.length, 2);
assert.ok(lines[1]!.endsWith("…"), "last line ellipsized");
for (const line of lines) assert.ok(measure(line) <= W || line.length <= 2, "each line fits");
});
it("char-splits an unbroken path longer than the width (the notes case)", () => {
const path = "/users/amos/notes/physics/lecture-notes.md";
const lines = wrapRegionLabel(path, W, 3, measure);
assert.equal(lines.length, 3);
// every kept line fits the width; the last carries the ellipsis
assert.ok(lines[2]!.endsWith("…"), "long path capped with ellipsis");
assert.ok(measure(lines[0]!) <= W, "first chunk fits");
assert.ok(measure(lines[1]!) <= W, "second chunk fits");
});
it("a remainder that FITS the width rides as a plain last line (no false ellipsis)", () => {
// W=25 at 5px/char = 5 chars a line: "aa bb" fits exactly, the
// tail "cc dd" fits too — two plain lines, cap never binds.
assert.deepEqual(wrapRegionLabel("aa bb cc dd", 25, 3, measure), ["aa bb", "cc dd"]);
});
it("the ellipsis itself never exceeds the width", () => {
const lines = wrapRegionLabel("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", W, 2, measure);
assert.ok(measure(lines[1]!) <= W, "ellipsized tail fits");
});
});
+38 -6
View File
@@ -61,7 +61,7 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
describe("AgentSection (thin SSR)", () => {
it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
for (const slot of ["strong", "cheap", "vision"]) {
assert.ok(html.includes(`data-testid="slot-row-${slot}"`), slot);
// The shared model-picker dropdown as the opened menu (owner,
@@ -87,26 +87,58 @@ describe("AgentSection (thin SSR)", () => {
});
it("the search-engine row renders with its live reason (search wave 2026-09-20)", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
assert.ok(html.includes('data-testid="search-engine-row"'), "the row");
assert.ok(html.includes('data-testid="search-engine-trigger"'), "the trigger");
// SSR: /api/search never fired → no reason line yet, trigger reads Automatic
assert.ok(html.includes("Automatic"), "unset engine reads Automatic");
// the explicit preference shows the chosen engine in the trigger
const html2 = renderToStaticMarkup(
<AgentSection agent={{ search: { engine: "tavily" } }} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />,
<AgentSection agent={{ search: { engine: "tavily" } }} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />,
);
assert.ok(html2.includes("Tavily"), "pinned engine visible in the trigger");
});
it("the prober graph-tools row: FIRST in the Tools group, default ON, its own key (wave 7)", () => {
// Default state: graph on, graphTools absent → ON, the describing
// hint rides (the search-row hint discipline).
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
const toolsGroup = html.split('data-testid="group-tools"')[1] ?? "";
const rows = [...toolsGroup.matchAll(/data-testid="(graph-tools-row|tool-row-\w+)"/g)].map((m) => m[1]);
assert.equal(rows[0], "graph-tools-row", "graph tools is the FIRST row of the Tools group");
// (the apostrophe in "sessions'" HTML-escapes — match up to it)
assert.ok(html.includes("Connects prior sessions"), "the describing hint");
assert.ok((html.split('data-testid="graph-tools-row"')[1] ?? "").includes('aria-checked="true"'), "absent = on");
const rowHtml = html.split('data-testid="graph-tools-row"')[1]?.split('data-testid="tool-row-')[0] ?? "";
assert.ok(!rowHtml.includes("opacity-40"), "lit while the graph is on (the search row may dim — no keys in SSR)");
});
it("graph disabled dims the graph-tools row with the honest hint (the predicate composes)", () => {
const html = renderToStaticMarkup(
<AgentSection agent={undefined} models={undefined} graph={{ enabled: false }} onSectionChange={() => {}} onModelsChange={() => {}} />,
);
const row = html.split('data-testid="graph-tools-row"')[1]?.split('data-testid="tool-row-')[0] ?? "";
assert.ok(row.includes("opacity-40"), "row dims when the graph is off");
assert.ok(row.includes("Graph is disabled"), "the reason hint replaces the description");
assert.ok(row.includes("disabled"), "toggle untouchable while the graph is off");
});
it("an explicit graphTools off shows stored-off (the record never lies)", () => {
const html = renderToStaticMarkup(
<AgentSection agent={{ graphTools: { enabled: false } }} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />,
);
const row = html.split('data-testid="graph-tools-row"')[1] ?? "";
assert.ok(row.includes('aria-checked="false"'), "stored-off renders off");
});
it("renders the owner's FINAL tool list — research, demo, visual, pdf", () => {
assert.deepEqual([...TOGGLE_TOOLS], ["research", "demo", "visual", "pdf"]);
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
for (const id of TOGGLE_TOOLS) assert.ok(html.includes(`data-testid="tool-row-${id}"`), id);
});
it("tools default ON (absent key = enabled)", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
for (const id of TOGGLE_TOOLS) {
assert.ok(tagOf(html, `toggle-tool-${id}`).includes('aria-checked="true"'), `${id} defaults on`);
}
@@ -129,7 +161,7 @@ describe("AgentSection (thin SSR)", () => {
});
it("prompts show the loading state under SSR (effects never fire)", () => {
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
assert.ok(html.includes("Loading prompt segments"));
});
});
+6 -3
View File
@@ -25,13 +25,16 @@ function turn(over: Partial<TurnUsage> = {}): TurnUsage {
}
describe("stats bar", () => {
it("renders — everywhere when the record has no turns yet (doctrine)", () => {
it("renders NOTHING on the left when there are no turns yet (owner, wave 7 sweep)", () => {
const html = renderToStaticMarkup(
<StatsBar turns={[]} />,
);
assert.match(html, /data-testid="stats-bar"/);
assert.match(html, /data-testid="stats-context">—</);
assert.match(html, /data-testid="stats-cost">—</);
// pre-chat: no summary button, no "—" theater (the knowledge-context
// slider died with the injection — the left side is simply empty)
assert.ok(!html.includes('data-testid="stats-summary"'), "no summary button pre-chat");
assert.ok(!html.includes('data-testid="stats-context"'), "no context figure pre-chat");
assert.ok(!html.includes('data-testid="stats-cost"'), "no cost figure pre-chat");
// no turns, no settings, catalog not loaded → both control labels
// "—" (the thinking button shows just the level, like the model
// button shows just the model name — no prefix, no dot separator)