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
+3 -3
View File
@@ -131,9 +131,9 @@ export function ActivityProvider({ children }: { children: React.ReactNode }) {
/** The activity feed seam for the rest of the shell: the dock's data +
* its refresh nudge. The chat page nudges on turn start (owner ground
* rules, 2026-09-15): a background process that starts at send — the
* knowledge-context injection — must be visible in the dock the moment
* it begins, without the dock polling faster while idle. */
* rules, 2026-09-15): background processes that start at send —
* extraction and sub-agent jobs — must be visible in the dock the
* moment they begin, without the dock polling faster while idle. */
export function useActivity(): { feed: ActivityFeed; refresh: () => void } {
return useContext(ActivityContext);
}
+4 -4
View File
@@ -402,8 +402,8 @@ export function Chat({
}, [chatId, setMessages, sendMessage, status]);
// ── Append-only record adoption (owner ground rules, 2026-09-15) ──
// Content delivered into the record server-side — the knowledge-context
// block, sub-agent artifacts, abort notes — must become visible the
// Content delivered into the record server-side — sub-agent
// artifacts, abort notes — must become visible the
// moment it persists, mid-turn included: it sits between messages, and
// nothing about a running turn should hide it. The full snapshot sync
// mid-stream is forbidden (it breaks the SDK's in-place continuation
@@ -431,8 +431,8 @@ export function Chat({
// self-terminating window of three record fetches (400ms/1s/2s) catches
// the insert and shows the marker during the turn, then stops. No
// standing poll — the window exists only for these seconds. The same
// instant nudges the activity feed: the knowledge-context job starts at
// send (server-side), and the dock must say so immediately.
// instant nudges the activity feed: extraction and sub-agent jobs
// start at send (server-side), and the dock must say so immediately.
const { refresh: refreshActivity } = useActivity();
const turnStartStatusRef = useRef(status);
// The window must survive the submitted→streaming flip (the stream starts
+5 -5
View File
@@ -38,9 +38,9 @@ import { Loader2Icon, MicIcon } from "lucide-react";
* only the meter. Live output may return as a toggleable setting at
* the settings milestone; deferred is the only behavior for now.
* - ADAPTIVE meter: the strip's cell count derives from its measured
* width (4px pitch, 8–48 cells, ResizeObserver) so narrow rows (draft/
* home, where the knowledge-context slider eats the middle) show
* fewer bars instead of overflowing under their neighbors.
* width (4px pitch, 8–48 cells, ResizeObserver) so narrow rows (the
* draft view's tighter column) show fewer bars instead of
* overflowing under their neighbors.
*
* Owner round 1 (2026-09-17, first live mic test):
* - ONE primary transcription input at all times: the pending
@@ -412,8 +412,8 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
const recording = phase === "recording" || phase === "stopping";
/** Adaptive cell count (owner round 2): derived from the meter's
* measured width — narrow rows (draft/home beside the knowledge-context
* slider) show fewer bars instead of overflowing under neighbors. */
* measured width — narrow rows show fewer bars instead of
* overflowing under neighbors. */
const [cellCount, setCellCount] = useState(METER_CELLS);
const meterRef = useRef<HTMLDivElement | null>(null);
+107 -3
View File
@@ -138,6 +138,82 @@ export interface RendererCallbacks {
const LABEL_SIZE = 12; // the reference label size the wrap-width ratio is taken against
const LABEL_WEIGHT = "500";
/** Region (cluster) labels — wave 7's session names made the labels
* LONG: imported-file paths and renamed chat titles. They wrap at a
* screen-fixed width (the labels are viewer-relative — this px value
* is exact on screen at any zoom), capped at a line count with an
* ellipsis on the last line (owner, 2026-09-23: wrap, cap 2–3 lines,
* ellipsize — a bounded footprint that keeps most of the path
* visible). */
const REGION_LABEL_MAX_WIDTH = 220; // screen px
const REGION_LABEL_MAX_LINES = 3;
const REGION_LABEL_LINE_HEIGHT = 1.3;
/** Wrap one region label: word-wise first (session names), then
* char-wise for words alone wider than the max (long file paths — the
* node-label discipline), capped at maxLines. Overflow folds into the
* last line, ellipsized to fit. measure is the canvas measureText (or a
* fake in tests) — pure otherwise. */
export function wrapRegionLabel(
text: string,
maxWidth: number,
maxLines: number,
measure: (s: string) => number,
): string[] {
if (maxLines < 1) return [text];
if (measure(text) <= maxWidth) return [text];
const lines: string[] = [];
const pending: string[] = text.split(/\s+/).filter(Boolean);
// Fill up to maxLines-1 lines; the remainder becomes the last line.
while (pending.length > 0 && lines.length < maxLines - 1) {
let line = "";
while (pending.length > 0) {
let word = pending[0]!;
const candidate = line ? `${line} ${word}` : word;
if (measure(candidate) <= maxWidth) {
line = candidate;
pending.shift();
continue;
}
if (!line) {
// The word alone overflows: char-split it, the remainder stays
// pending for the next line (paths have no spaces to help).
let chunk = "";
while (word.length > 0 && measure(chunk + word[0]) <= maxWidth) {
chunk += word[0];
word = word.slice(1);
}
if (chunk) {
lines.push(chunk);
pending[0] = word;
} else {
// Degenerate: one char exceeds the width — keep it whole so
// the label can never vanish entirely.
lines.push(word);
pending.shift();
}
line = "";
}
break;
}
if (line) lines.push(line);
if (!line && lines.length > 0 && pending.length > 0) continue;
if (!line && lines.length === 0) break;
}
// The remainder rides as the last line: plain if it fits, ellipsized
// down to the width if not.
let rest = pending.join(" ");
if (!rest) return lines;
if (measure(rest) <= maxWidth) {
lines.push(rest);
return lines;
}
let tail = rest;
while (tail.length > 1 && measure(`${tail}…`) > maxWidth) tail = tail.slice(0, -1);
lines.push(`${tail}…`);
return lines;
}
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;
@@ -510,7 +586,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; cx: number; cy: number; hue: number }[] = [];
private regionMeta: { 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). */
@@ -1200,10 +1276,29 @@ export class GraphCanvasRenderer {
cx: sumX / members,
cy: sumY / members,
hue: clusterHue(cluster.id),
lines: null,
});
}
});
// Wrap the region labels ONCE per geometry build, at their
// SCREEN-fixed size (viewer-relative labels — this px size is what
// lands on screen at any zoom, so the wrap never goes stale mid-
// camera-move). Session names are short; file paths are not (wave
// 7) — the cap + ellipsis keeps the footprint bounded.
const settings = this.settings;
if (settings) {
const ctx = this.ctx;
const prevFont = ctx.font;
ctx.font = `${LABEL_WEIGHT} ${settings.clusterLabelSize}px ${data.fontFamily}`;
for (const rm of this.regionMeta) {
rm.lines = rm.label
? wrapRegionLabel(rm.label, REGION_LABEL_MAX_WIDTH, REGION_LABEL_MAX_LINES, (s) => ctx.measureText(s).width)
: null;
}
ctx.font = prevFont;
}
// THE off switch (user 2026-09-15): compute is skipped, not just
// paint hidden — no splat, no blur, no readback, no bitmap, no
// blit. Everything below this return is the bitmap path.
@@ -1474,16 +1569,25 @@ export class GraphCanvasRenderer {
// Viewer-relative by design (UI, not graph content): drawn at the
// world centroid with a font of clusterLabelSize / k — a constant
// size on screen at any zoom (user-tunable since 2026-09-16).
// Wave 7: labels are session names / file paths — pre-wrapped at
// the geometry build (cap + ellipsis), drawn centered on the
// centroid line by line.
if (settings.clusterLabels && regionAlpha > 0.01) {
ctx.save();
ctx.globalAlpha = regionAlpha;
ctx.font = `${LABEL_WEIGHT} ${settings.clusterLabelSize / k}px ${data.fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// Screen px ÷ k = world units — the lines stay evenly spaced
// at a constant on-screen pitch at any zoom.
const lineH = (settings.clusterLabelSize * REGION_LABEL_LINE_HEIGHT) / k;
for (const rm of this.regionMeta) {
if (!rm.label) continue;
if (!rm.lines) continue;
ctx.fillStyle = `hsla(${rm.hue}, 55%, 78%, 0.85)`;
ctx.fillText(rm.label, rm.cx, rm.cy);
const firstOffset = (-(rm.lines.length - 1) / 2) * lineH;
for (let i = 0; i < rm.lines.length; i++) {
ctx.fillText(rm.lines[i]!, rm.cx, rm.cy + firstOffset + i * lineH);
}
}
ctx.restore();
}
+3 -3
View File
@@ -165,9 +165,9 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings:
</button>
</form>
{/* Same as the chat view's under-composer stats bar (owner) — no
session yet, so the free LEFT side carries the knowledge-context
budget (session-data slot), while the RIGHT keeps the strong-
slot control (fix wave: the pills write /api/settings/models). */}
session yet, so the LEFT side renders nothing (wave 7 sweep)
while the RIGHT keeps the strong-slot control (fix wave: the
pills write /api/settings/models). */}
<StatsBar
dictationSlot={<DictationBar dictation={dictation} />}
turns={[] as TurnUsage[]}
+39
View File
@@ -49,6 +49,7 @@ import type React from "react";
import {
MODEL_SLOTS,
type AgentSettings,
type GraphSettings,
type ModelCatalogEntry,
type ModelSlotName,
type ModelsSettings,
@@ -99,6 +100,14 @@ export function composeTool(agent: AgentSettings | undefined, id: string, on: bo
return { ...agent, tools: { ...agent?.tools, [id]: on } };
}
/** Compose the prober graph-tools toggle (wave 7): its own key in the
* agent section — never in the tool-toggles map (owner ruling). Absent =
* ON (the toggle discipline); the write is explicit either way, so an
* explicit-off record stays visible as stored. Pure — exported for tests. */
export function composeGraphTools(agent: AgentSettings | undefined, on: boolean): AgentSettings {
return { ...agent, graphTools: { enabled: on } };
}
/** The research tool runs on the search port — the teacher registry DROPS
* it when no engine is live (chat-handler's searchAvailable, the same
* /api/search signal the section fetches). Pure — exported for tests.
@@ -218,12 +227,17 @@ function MenuTrigger({
export function AgentSection({
agent,
models,
graph,
onSectionChange,
onModelsChange,
}: {
agent: AgentSettings | undefined;
/** The `models` section (slot pins — engine-level, PUT /api/settings/models). */
models: ModelsSettings | undefined;
/** The `graph` section — READ ONLY here: the graphTools row composes its
* master switch (graph.enabled off dims + disables the row; the
* effective gate is server-side, per-turn fresh). */
graph: GraphSettings | undefined;
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
onSectionChange: (next: AgentSettings) => void;
/** Writes the composed models section (slots — replace-PUT discipline). */
@@ -571,6 +585,31 @@ export function AgentSection({
</SettingsGroup>
<SettingsGroup label="Tools" testid="group-tools">
{/* The prober's graph tools (wave 7, owner ruling 2026-09-23):
FIRST row of the group — its subject is the map, its
mechanism is agent capability, so it lives in Tools but
NOT in the tool-toggles list. Its own agent-section key
(agent.graphTools), the search-row hint discipline: a
smaller line under it explaining what it is — this
connects prior sessions' knowledge into new ones. */}
{(() => {
const graphOn = graph?.enabled ?? true;
return (
<SettingsRow
label="Graph tools"
hint={graphOn ? "Connects prior sessions' knowledge into new ones — the prober reads the knowledge map" : "Graph is disabled"}
testid="graph-tools-row"
dimmed={!graphOn}
>
<Toggle
checked={agent?.graphTools?.enabled ?? true}
disabled={!graphOn}
label="graph tools"
onChange={(on) => onSectionChange(composeGraphTools(agent, on))}
/>
</SettingsRow>
);
})()}
{TOGGLE_TOOLS.map((id) => {
// Research needs a live search engine; the row + toggle gray
// on the same signal the registry drops the tool on (see
+1
View File
@@ -344,6 +344,7 @@ export function SettingsDialogBody({
<AgentSection
agent={record.agent}
models={record.models}
graph={record.graph}
onModelsChange={(next: ModelsSettings) =>
void write("models", next)
}
+7 -12
View File
@@ -11,11 +11,10 @@ import { ModelControl } from "@/components/model-picker";
* LEFT-aligned: context window + session cost; RIGHT-aligned: model +
* thinking level. Clicking the left side opens the per-turn detail popover.
*
* Pre-chat views (home, /chat/new) pass `leftSlot`: with no session there
* IS no session data (the summary would just read "—"), so that left side
* is free real estate — the knowledge-context budget slider lives there
* (vision-context-injection.md), vertically centered beside the model +
* thinking pills by the row's own geometry.
* Pre-chat views (home, /chat/new) render NO left side at all (owner,
* wave 7 sweep: the knowledge-context budget slider that used to live
* there died with the injection; a "— · —" summary over an empty
* popover is pure noise) — the dictation strip's middle simply widens.
*
* All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
* — no cost math lives here. Absent data renders "—", never zero (the
@@ -29,13 +28,9 @@ import { ModelControl } from "@/components/model-picker";
*/
export function StatsBar({
turns,
leftSlot,
dictationSlot,
}: {
turns: TurnUsage[];
/** Pre-chat only: replaces the (empty) session-data summary — home and
* the /chat/new draft mount the knowledge-context slider here. */
leftSlot?: ReactNode;
/** The dictation strip (STT wave, 2026-09-17): the row's middle is dead
* space between the session data and the model control — the mic
* button lives there, and while recording the meter unfurls to fill
@@ -84,9 +79,9 @@ export function StatsBar({
outside-close ref anchors HERE too (owner, round 3): on the whole
bar it wrapped the model control, so clicking model/thinking never
counted as outside — the asymmetry the owner observed. */}
{/* The left side: the session-data summary, or — pre-chat — the
caller's slot (the knowledge-context slider) in its place. */}
{leftSlot ?? (
{/* The left side: the session-data summary — only once a session
exists (turns). Pre-chat renders nothing (see the header). */}
{turns.length > 0 && (
<div ref={barRef} className="relative">
<button
type="button"
+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)