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:
@@ -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 +
|
/** 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
|
* its refresh nudge. The chat page nudges on turn start (owner ground
|
||||||
* rules, 2026-09-15): a background process that starts at send — the
|
* rules, 2026-09-15): background processes that start at send —
|
||||||
* knowledge-context injection — must be visible in the dock the moment
|
* extraction and sub-agent jobs — must be visible in the dock the
|
||||||
* it begins, without the dock polling faster while idle. */
|
* moment they begin, without the dock polling faster while idle. */
|
||||||
export function useActivity(): { feed: ActivityFeed; refresh: () => void } {
|
export function useActivity(): { feed: ActivityFeed; refresh: () => void } {
|
||||||
return useContext(ActivityContext);
|
return useContext(ActivityContext);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -402,8 +402,8 @@ export function Chat({
|
|||||||
}, [chatId, setMessages, sendMessage, status]);
|
}, [chatId, setMessages, sendMessage, status]);
|
||||||
|
|
||||||
// ── Append-only record adoption (owner ground rules, 2026-09-15) ──
|
// ── Append-only record adoption (owner ground rules, 2026-09-15) ──
|
||||||
// Content delivered into the record server-side — the knowledge-context
|
// Content delivered into the record server-side — sub-agent
|
||||||
// block, sub-agent artifacts, abort notes — must become visible the
|
// artifacts, abort notes — must become visible the
|
||||||
// moment it persists, mid-turn included: it sits between messages, and
|
// moment it persists, mid-turn included: it sits between messages, and
|
||||||
// nothing about a running turn should hide it. The full snapshot sync
|
// nothing about a running turn should hide it. The full snapshot sync
|
||||||
// mid-stream is forbidden (it breaks the SDK's in-place continuation
|
// 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
|
// self-terminating window of three record fetches (400ms/1s/2s) catches
|
||||||
// the insert and shows the marker during the turn, then stops. No
|
// the insert and shows the marker during the turn, then stops. No
|
||||||
// standing poll — the window exists only for these seconds. The same
|
// standing poll — the window exists only for these seconds. The same
|
||||||
// instant nudges the activity feed: the knowledge-context job starts at
|
// instant nudges the activity feed: extraction and sub-agent jobs
|
||||||
// send (server-side), and the dock must say so immediately.
|
// start at send (server-side), and the dock must say so immediately.
|
||||||
const { refresh: refreshActivity } = useActivity();
|
const { refresh: refreshActivity } = useActivity();
|
||||||
const turnStartStatusRef = useRef(status);
|
const turnStartStatusRef = useRef(status);
|
||||||
// The window must survive the submitted→streaming flip (the stream starts
|
// The window must survive the submitted→streaming flip (the stream starts
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ import { Loader2Icon, MicIcon } from "lucide-react";
|
|||||||
* only the meter. Live output may return as a toggleable setting at
|
* only the meter. Live output may return as a toggleable setting at
|
||||||
* the settings milestone; deferred is the only behavior for now.
|
* the settings milestone; deferred is the only behavior for now.
|
||||||
* - ADAPTIVE meter: the strip's cell count derives from its measured
|
* - ADAPTIVE meter: the strip's cell count derives from its measured
|
||||||
* width (4px pitch, 8–48 cells, ResizeObserver) so narrow rows (draft/
|
* width (4px pitch, 8–48 cells, ResizeObserver) so narrow rows (the
|
||||||
* home, where the knowledge-context slider eats the middle) show
|
* draft view's tighter column) show fewer bars instead of
|
||||||
* fewer bars instead of overflowing under their neighbors.
|
* overflowing under their neighbors.
|
||||||
*
|
*
|
||||||
* Owner round 1 (2026-09-17, first live mic test):
|
* Owner round 1 (2026-09-17, first live mic test):
|
||||||
* - ONE primary transcription input at all times: the pending
|
* - 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 [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
|
||||||
const recording = phase === "recording" || phase === "stopping";
|
const recording = phase === "recording" || phase === "stopping";
|
||||||
/** Adaptive cell count (owner round 2): derived from the meter's
|
/** Adaptive cell count (owner round 2): derived from the meter's
|
||||||
* measured width — narrow rows (draft/home beside the knowledge-context
|
* measured width — narrow rows show fewer bars instead of
|
||||||
* slider) show fewer bars instead of overflowing under neighbors. */
|
* overflowing under neighbors. */
|
||||||
const [cellCount, setCellCount] = useState(METER_CELLS);
|
const [cellCount, setCellCount] = useState(METER_CELLS);
|
||||||
const meterRef = useRef<HTMLDivElement | null>(null);
|
const meterRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
|||||||
+107
-3
@@ -138,6 +138,82 @@ export interface RendererCallbacks {
|
|||||||
|
|
||||||
const LABEL_SIZE = 12; // the reference label size the wrap-width ratio is taken against
|
const LABEL_SIZE = 12; // the reference label size the wrap-width ratio is taken against
|
||||||
const LABEL_WEIGHT = "500";
|
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_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_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;
|
const LABEL_LINE_HEIGHT = 1.3;
|
||||||
@@ -510,7 +586,7 @@ export class GraphCanvasRenderer {
|
|||||||
/** World rect the bitmap covers (drawImage target coords). */
|
/** World rect the bitmap covers (drawImage target coords). */
|
||||||
private regionRect: { minX: number; minY: number; w: number; h: number } | null = null;
|
private regionRect: { minX: number; minY: number; w: number; h: number } | null = null;
|
||||||
/** Region labels: cluster centroid + hue, computed at splat build. */
|
/** 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 regionTuning: RegionTuning = DEFAULT_REGION_TUNING;
|
||||||
private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING;
|
private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING;
|
||||||
/** Gravity center for the live sim (graph bbox centroid at setData). */
|
/** Gravity center for the live sim (graph bbox centroid at setData). */
|
||||||
@@ -1200,10 +1276,29 @@ export class GraphCanvasRenderer {
|
|||||||
cx: sumX / members,
|
cx: sumX / members,
|
||||||
cy: sumY / members,
|
cy: sumY / members,
|
||||||
hue: clusterHue(cluster.id),
|
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
|
// THE off switch (user 2026-09-15): compute is skipped, not just
|
||||||
// paint hidden — no splat, no blur, no readback, no bitmap, no
|
// paint hidden — no splat, no blur, no readback, no bitmap, no
|
||||||
// blit. Everything below this return is the bitmap path.
|
// 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
|
// Viewer-relative by design (UI, not graph content): drawn at the
|
||||||
// world centroid with a font of clusterLabelSize / k — a constant
|
// world centroid with a font of clusterLabelSize / k — a constant
|
||||||
// size on screen at any zoom (user-tunable since 2026-09-16).
|
// 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) {
|
if (settings.clusterLabels && regionAlpha > 0.01) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = regionAlpha;
|
ctx.globalAlpha = regionAlpha;
|
||||||
ctx.font = `${LABEL_WEIGHT} ${settings.clusterLabelSize / k}px ${data.fontFamily}`;
|
ctx.font = `${LABEL_WEIGHT} ${settings.clusterLabelSize / k}px ${data.fontFamily}`;
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "middle";
|
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) {
|
for (const rm of this.regionMeta) {
|
||||||
if (!rm.label) continue;
|
if (!rm.lines) continue;
|
||||||
ctx.fillStyle = `hsla(${rm.hue}, 55%, 78%, 0.85)`;
|
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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -165,9 +165,9 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings:
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/* Same as the chat view's under-composer stats bar (owner) — no
|
{/* Same as the chat view's under-composer stats bar (owner) — no
|
||||||
session yet, so the free LEFT side carries the knowledge-context
|
session yet, so the LEFT side renders nothing (wave 7 sweep)
|
||||||
budget (session-data slot), while the RIGHT keeps the strong-
|
while the RIGHT keeps the strong-slot control (fix wave: the
|
||||||
slot control (fix wave: the pills write /api/settings/models). */}
|
pills write /api/settings/models). */}
|
||||||
<StatsBar
|
<StatsBar
|
||||||
dictationSlot={<DictationBar dictation={dictation} />}
|
dictationSlot={<DictationBar dictation={dictation} />}
|
||||||
turns={[] as TurnUsage[]}
|
turns={[] as TurnUsage[]}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import type React from "react";
|
|||||||
import {
|
import {
|
||||||
MODEL_SLOTS,
|
MODEL_SLOTS,
|
||||||
type AgentSettings,
|
type AgentSettings,
|
||||||
|
type GraphSettings,
|
||||||
type ModelCatalogEntry,
|
type ModelCatalogEntry,
|
||||||
type ModelSlotName,
|
type ModelSlotName,
|
||||||
type ModelsSettings,
|
type ModelsSettings,
|
||||||
@@ -99,6 +100,14 @@ export function composeTool(agent: AgentSettings | undefined, id: string, on: bo
|
|||||||
return { ...agent, tools: { ...agent?.tools, [id]: on } };
|
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
|
/** The research tool runs on the search port — the teacher registry DROPS
|
||||||
* it when no engine is live (chat-handler's searchAvailable, the same
|
* it when no engine is live (chat-handler's searchAvailable, the same
|
||||||
* /api/search signal the section fetches). Pure — exported for tests.
|
* /api/search signal the section fetches). Pure — exported for tests.
|
||||||
@@ -218,12 +227,17 @@ function MenuTrigger({
|
|||||||
export function AgentSection({
|
export function AgentSection({
|
||||||
agent,
|
agent,
|
||||||
models,
|
models,
|
||||||
|
graph,
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
onModelsChange,
|
onModelsChange,
|
||||||
}: {
|
}: {
|
||||||
agent: AgentSettings | undefined;
|
agent: AgentSettings | undefined;
|
||||||
/** The `models` section (slot pins — engine-level, PUT /api/settings/models). */
|
/** The `models` section (slot pins — engine-level, PUT /api/settings/models). */
|
||||||
models: ModelsSettings | undefined;
|
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). */
|
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
||||||
onSectionChange: (next: AgentSettings) => void;
|
onSectionChange: (next: AgentSettings) => void;
|
||||||
/** Writes the composed models section (slots — replace-PUT discipline). */
|
/** Writes the composed models section (slots — replace-PUT discipline). */
|
||||||
@@ -571,6 +585,31 @@ export function AgentSection({
|
|||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup label="Tools" testid="group-tools">
|
<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) => {
|
{TOGGLE_TOOLS.map((id) => {
|
||||||
// Research needs a live search engine; the row + toggle gray
|
// Research needs a live search engine; the row + toggle gray
|
||||||
// on the same signal the registry drops the tool on (see
|
// on the same signal the registry drops the tool on (see
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ export function SettingsDialogBody({
|
|||||||
<AgentSection
|
<AgentSection
|
||||||
agent={record.agent}
|
agent={record.agent}
|
||||||
models={record.models}
|
models={record.models}
|
||||||
|
graph={record.graph}
|
||||||
onModelsChange={(next: ModelsSettings) =>
|
onModelsChange={(next: ModelsSettings) =>
|
||||||
void write("models", next)
|
void write("models", next)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ import { ModelControl } from "@/components/model-picker";
|
|||||||
* LEFT-aligned: context window + session cost; RIGHT-aligned: model +
|
* LEFT-aligned: context window + session cost; RIGHT-aligned: model +
|
||||||
* thinking level. Clicking the left side opens the per-turn detail popover.
|
* thinking level. Clicking the left side opens the per-turn detail popover.
|
||||||
*
|
*
|
||||||
* Pre-chat views (home, /chat/new) pass `leftSlot`: with no session there
|
* Pre-chat views (home, /chat/new) render NO left side at all (owner,
|
||||||
* IS no session data (the summary would just read "—"), so that left side
|
* wave 7 sweep: the knowledge-context budget slider that used to live
|
||||||
* is free real estate — the knowledge-context budget slider lives there
|
* there died with the injection; a "— · —" summary over an empty
|
||||||
* (vision-context-injection.md), vertically centered beside the model +
|
* popover is pure noise) — the dictation strip's middle simply widens.
|
||||||
* thinking pills by the row's own geometry.
|
|
||||||
*
|
*
|
||||||
* All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
|
* All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
|
||||||
* — no cost math lives here. Absent data renders "—", never zero (the
|
* — no cost math lives here. Absent data renders "—", never zero (the
|
||||||
@@ -29,13 +28,9 @@ import { ModelControl } from "@/components/model-picker";
|
|||||||
*/
|
*/
|
||||||
export function StatsBar({
|
export function StatsBar({
|
||||||
turns,
|
turns,
|
||||||
leftSlot,
|
|
||||||
dictationSlot,
|
dictationSlot,
|
||||||
}: {
|
}: {
|
||||||
turns: TurnUsage[];
|
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
|
/** 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
|
* space between the session data and the model control — the mic
|
||||||
* button lives there, and while recording the meter unfurls to fill
|
* 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
|
outside-close ref anchors HERE too (owner, round 3): on the whole
|
||||||
bar it wrapped the model control, so clicking model/thinking never
|
bar it wrapped the model control, so clicking model/thinking never
|
||||||
counted as outside — the asymmetry the owner observed. */}
|
counted as outside — the asymmetry the owner observed. */}
|
||||||
{/* The left side: the session-data summary, or — pre-chat — the
|
{/* The left side: the session-data summary — only once a session
|
||||||
caller's slot (the knowledge-context slider) in its place. */}
|
exists (turns). Pre-chat renders nothing (see the header). */}
|
||||||
{leftSlot ?? (
|
{turns.length > 0 && (
|
||||||
<div ref={barRef} className="relative">
|
<div ref={barRef} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -10,13 +10,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it as test } from "node:test";
|
import { describe, it, it as test } from "node:test";
|
||||||
import {
|
import {
|
||||||
arrowGeometry,
|
arrowGeometry,
|
||||||
boxBlurRound,
|
boxBlurRound,
|
||||||
fitTransform,
|
fitTransform,
|
||||||
labelAlpha,
|
labelAlpha,
|
||||||
pickNearest,
|
pickNearest,
|
||||||
|
wrapRegionLabel,
|
||||||
zoomRatioOf,
|
zoomRatioOf,
|
||||||
LABELS_FADE_RATIO,
|
LABELS_FADE_RATIO,
|
||||||
} from "../components/graph-canvas.ts";
|
} 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
|
|||||||
|
|
||||||
describe("AgentSection (thin SSR)", () => {
|
describe("AgentSection (thin SSR)", () => {
|
||||||
it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => {
|
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"]) {
|
for (const slot of ["strong", "cheap", "vision"]) {
|
||||||
assert.ok(html.includes(`data-testid="slot-row-${slot}"`), slot);
|
assert.ok(html.includes(`data-testid="slot-row-${slot}"`), slot);
|
||||||
// The shared model-picker dropdown as the opened menu (owner,
|
// 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)", () => {
|
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-row"'), "the row");
|
||||||
assert.ok(html.includes('data-testid="search-engine-trigger"'), "the trigger");
|
assert.ok(html.includes('data-testid="search-engine-trigger"'), "the trigger");
|
||||||
// SSR: /api/search never fired → no reason line yet, trigger reads Automatic
|
// SSR: /api/search never fired → no reason line yet, trigger reads Automatic
|
||||||
assert.ok(html.includes("Automatic"), "unset engine reads Automatic");
|
assert.ok(html.includes("Automatic"), "unset engine reads Automatic");
|
||||||
// the explicit preference shows the chosen engine in the trigger
|
// the explicit preference shows the chosen engine in the trigger
|
||||||
const html2 = renderToStaticMarkup(
|
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");
|
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", () => {
|
it("renders the owner's FINAL tool list — research, demo, visual, pdf", () => {
|
||||||
assert.deepEqual([...TOGGLE_TOOLS], ["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);
|
for (const id of TOGGLE_TOOLS) assert.ok(html.includes(`data-testid="tool-row-${id}"`), id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tools default ON (absent key = enabled)", () => {
|
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) {
|
for (const id of TOGGLE_TOOLS) {
|
||||||
assert.ok(tagOf(html, `toggle-tool-${id}`).includes('aria-checked="true"'), `${id} defaults on`);
|
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)", () => {
|
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"));
|
assert.ok(html.includes("Loading prompt segments"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,13 +25,16 @@ function turn(over: Partial<TurnUsage> = {}): TurnUsage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("stats bar", () => {
|
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(
|
const html = renderToStaticMarkup(
|
||||||
<StatsBar turns={[]} />,
|
<StatsBar turns={[]} />,
|
||||||
);
|
);
|
||||||
assert.match(html, /data-testid="stats-bar"/);
|
assert.match(html, /data-testid="stats-bar"/);
|
||||||
assert.match(html, /data-testid="stats-context">—</);
|
// pre-chat: no summary button, no "—" theater (the knowledge-context
|
||||||
assert.match(html, /data-testid="stats-cost">—</);
|
// 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
|
// no turns, no settings, catalog not loaded → both control labels
|
||||||
// "—" (the thinking button shows just the level, like the model
|
// "—" (the thinking button shows just the level, like the model
|
||||||
// button shows just the model name — no prefix, no dot separator)
|
// button shows just the model name — no prefix, no dot separator)
|
||||||
|
|||||||
Reference in New Issue
Block a user