From 7a17230f2633559ea8bde7243fc9ccd046a959b8 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 23 Sep 2026 15:57:59 +0300 Subject: [PATCH] Wave 7 close-out: the graphTools surface, wrapped cluster labels, the orphan sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- components/activity-dock.tsx | 6 +- components/chat.tsx | 8 +- components/dictation.tsx | 10 +-- components/graph-canvas.ts | 110 +++++++++++++++++++++++- components/home.tsx | 6 +- components/settings/agent-section.tsx | 39 +++++++++ components/settings/settings-dialog.tsx | 1 + components/stats-bar.tsx | 19 ++-- tests/graph-canvas.test.ts | 54 +++++++++++- tests/render-settings.test.tsx | 44 ++++++++-- tests/render-stats-bar.test.tsx | 9 +- 11 files changed, 266 insertions(+), 40 deletions(-) diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 897c02c..6f30cbc 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -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); } diff --git a/components/chat.tsx b/components/chat.tsx index 77e44f1..3d2c655 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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 diff --git a/components/dictation.tsx b/components/dictation.tsx index 9851359..c050eb8 100644 --- a/components/dictation.tsx +++ b/components/dictation.tsx @@ -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(() => 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(null); diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 6302ae5..4a98f81 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -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(); } diff --git a/components/home.tsx b/components/home.tsx index aafb28e..e614625 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -165,9 +165,9 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings: {/* 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). */} } turns={[] as TurnUsage[]} diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 97225ed..0f07aae 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -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({ + {/* 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 ( + + onSectionChange(composeGraphTools(agent, on))} + /> + + ); + })()} {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 diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index 9d100c8..9659720 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -344,6 +344,7 @@ export function SettingsDialogBody({ void write("models", next) } diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index cbe25ac..77b3dec 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -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 && (