From 4abb9b6fc56b997b85de7263c10946b249618bb3 Mon Sep 17 00:00:00 2001 From: homen Date: Mon, 7 Sep 2026 15:20:01 -0700 Subject: [PATCH] Make session entities readable in a default List --- .tours/01-primary-user-flow.tour | 2 +- .tours/03-debug-and-recovery.tour | 4 +- HANDOFF.md | 12 + docs/START_HERE.md | 2 +- e2e/capture-journey-at-width.mjs | 256 +++++++++++++++++- src/app/styles.css | 32 ++- .../node-agent/components/GraphRailPanel.tsx | 112 ++++++-- tests/graphRailPanel.test.ts | 95 +++++++ 8 files changed, 467 insertions(+), 48 deletions(-) create mode 100644 tests/graphRailPanel.test.ts diff --git a/.tours/01-primary-user-flow.tour b/.tours/01-primary-user-flow.tour index 3aad758..de62416 100644 --- a/.tours/01-primary-user-flow.tour +++ b/.tours/01-primary-user-flow.tour @@ -77,7 +77,7 @@ }, { "file": "src/features/node-agent/components/GraphRailPanel.tsx", - "line": 17, + "line": 8, "anchor": "const snapshot = useSyncExternalStore", "description": "The side panel subscribes to that session, so the graph grows as the run proceeds. This is the entire state-management story — no library." }, diff --git a/.tours/03-debug-and-recovery.tour b/.tours/03-debug-and-recovery.tour index 2dcffb4..b8494be 100644 --- a/.tours/03-debug-and-recovery.tour +++ b/.tours/03-debug-and-recovery.tour @@ -23,13 +23,13 @@ }, { "file": "src/app/styles.css", - "line": 217, + "line": 242, "anchor": "@media (max-width: 960px)", "description": "The site of defect D1. This block used to say `.na-rail { display: none }`. CSS owned visibility, React owned mounting and gated on data — so a WebGL renderer was mounted into a 0x0 box, threw, and took the app down. The rail is now a bottom panel instead: one owner, no hidden-but-mounted state." }, { "file": "e2e/capture-journey-at-width.mjs", - "line": 225, + "line": 453, "anchor": "graphMounted:", "description": "The browser gate asserts the graph canvas is not zero-width — the CAUSE of D1, not its symptom. Re-hide the rail by any mechanism and this turns red." }, diff --git a/HANDOFF.md b/HANDOFF.md index 78b9c9e..77cc3b4 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,5 +1,17 @@ # NodeAgent developer handoff +## Session relationship reading (2026-09-07) + +The source app provides a default List of complete entity names and their typed relationships, with native expandable rows and an optional existing Map. Both read the same retained live session. Measured zero remains distinct from unknown; visits and traversal are labelled activity, not evidence strength. Map filters affect only Map, and returning to it can reset its camera/filter state without resetting the session. The vendored renderer is unchanged: its overlapping/clipped labels remain an optional-Map limitation, not a corrected canvas claim. + +Reproduce the source-app reading journey with `node e2e/capture-journey-at-width.mjs --width 360 --height 800 --label graph-list-360-new` and `node e2e/capture-journey-at-width.mjs --width 1440 --height 960 --keyboard --axe --turns 2 --label graph-list-keyboard-new`. Choose a fresh label and free `--port`; the command writes under `promotion/evidence`. It checks the actual List against the live snapshot, activates entity details and Map/List through native controls, and preserves the existing overflow/error/keyboard/axe checks. This source app is separate from the generated chat template. + +The implementation/proof record must distinguish List reading, optional Map behavior and the scripted no-key demo from provider, physical-device and full-product readiness. Historical graph deductions and all full-score holds remain source-bound; a successful List journey does not clear the unchanged Map label defect. + +The local source check passed 63 tests in ten files, types, library/UI builds, tours and the existing runtime/scaffold checks; its production audit reported zero findings. Convex was unconfigured, live-provider execution skipped, and the official Omnigent CLI was absent. Historical generated receipts were restored after retaining fresh outputs. Earlier failed checks and browser attempts remain evidence of the corrections, not passing runs. + +The reading proof covered seven viewport pairs from 320×800 through 1920×1080, native keyboard/two-turn draft continuity, touch emulation, computed doubled text at 390/1440 and reduced motion. A mobile sticky header initially covered the enlarged question; the header now scrolls in normal flow, and all six affected mobile/tablet conditions passed again. The existing harness checks every retained entity and typed adjacency against the live session, records native scroll coverage and keeps submission/reset observations distinct from graph updates. Use `--text200` or `--reduced-motion` for those bounded fixtures; touch emulation is `--touch`, not a physical-device certificate. Desktop results from before the mobile-only rule removal remain revision-bound; the final normal check rebuilt the compiled assets. The optional Map label defects, dense long sessions and whole-product/provider/device/assistive-technology readiness remain open. + ## Development tooling repair (2026-09-07) This source updates Vitest to 3.2.6, applies a tsup-only esbuild 0.28.1 override, and refreshes the affected development transitives. Production requirements, runtime/CLI exports, templates and test configuration are unchanged. The source-bound local run passed ordinary `npm ci`, `npm ls`, the unchanged `npm run check` (57 tests in nine files, types, library/UI builds, tours and local scaffold/runtime checks), full installed audit with zero findings, and `npm pack`. The earlier nine root-development advisories remain historical; generated-app audit findings are separate. diff --git a/docs/START_HERE.md b/docs/START_HERE.md index 769adaf..1f6a385 100644 --- a/docs/START_HERE.md +++ b/docs/START_HERE.md @@ -298,7 +298,7 @@ graphSession.observe( ``` ```tsx -// src/features/node-agent/components/GraphRailPanel.tsx:17-21 +// src/features/node-agent/components/GraphRailPanel.tsx:8-12 const snapshot = useSyncExternalStore( graphSession.subscribe, graphSession.getSnapshot, graphSession.getSnapshot, ); diff --git a/e2e/capture-journey-at-width.mjs b/e2e/capture-journey-at-width.mjs index 273c6a2..4a4745e 100644 --- a/e2e/capture-journey-at-width.mjs +++ b/e2e/capture-journey-at-width.mjs @@ -11,8 +11,8 @@ * 2. React is still mounted afterwards (#root has children) * 3. the memo card rendered * 4. no horizontal overflow at that width - * 5. the session graph is either populated or explicitly deferred — never - * mounted into a zero-width container + * 5. the session graph has a readable populated List or visible Map — never + * a zero-width canvas or a missing reading surface * * Two flags widen it to the gate conditions the audit tools cannot reach: * @@ -27,6 +27,7 @@ * Run: node e2e/capture-journey-at-width.mjs --width 375 --height 812 * node e2e/capture-journey-at-width.mjs --width 1440 --height 900 * node e2e/capture-journey-at-width.mjs --width 375 --keyboard --axe + * Reading fixtures: --touch, --text200 (all computed font sizes), --reduced-motion. * Exits NONZERO on any failed assertion. */ @@ -51,6 +52,10 @@ const PORT = Number(arg("port", 4306)); const LABEL = arg("label", `${WIDTH}`); const KEYBOARD = process.argv.includes("--keyboard"); const AXE = process.argv.includes("--axe"); +// These explicit observation fixtures do not change application data or handlers. +const TOUCH = process.argv.includes("--touch"); +const TEXT200 = process.argv.includes("--text200"); +const REDUCED = process.argv.includes("--reduced-motion"); // Serious and critical are the two axe impacts this gate treats as "major". const MAJOR_IMPACTS = new Set(["serious", "critical"]); const OUT_DIR = join(root, "promotion", "evidence"); @@ -95,11 +100,201 @@ let hardFailure = null; let loopMs = null; let keyboard = null; let axeResult = null; +let graphReading = null; +let browser = null; +let textStyles = null; +const textFixtures = []; + +async function applyTextFixture(page) { + if (!TEXT200) return; + const measured = await page.evaluate((saved) => { + for (const [el, style] of saved) { + if (!el.isConnected) { saved.delete(el); continue; } + if (style.value) el.style.setProperty("font-size", style.value, style.priority); + else el.style.removeProperty("font-size"); + } + // Measure every current computed size before writing any doubled size. + const sizes = [...document.querySelectorAll("*")].map((el) => { + if (!saved.has(el)) saved.set(el, { value: el.style.getPropertyValue("font-size"), priority: el.style.getPropertyPriority("font-size") }); + return [el, parseFloat(getComputedStyle(el).fontSize)]; + }); + for (const [el, size] of sizes) el.style.setProperty("font-size", `${size * 2}px`, "important"); + return { elements: sizes.length, mismatches: sizes.filter(([el, size]) => Math.abs(parseFloat(getComputedStyle(el).fontSize) - size * 2) > 0.01).length }; + }, textStyles); + textFixtures.push(measured); + if (measured.mismatches) throw new Error("computed text fixture did not double every current font"); +} + +// The Vite-served source module is the UI's existing live store. Reading it +// supplies an independent expected payload; this never injects graph data. +const readSession = (page) => page.evaluate(async () => { + const { graphSession } = await import("/src/features/node-agent/graph/agentGraphSession.ts"); + return structuredClone(graphSession.getSnapshot()); +}); + +async function activate(page, locator, key = "Enter") { + if (!KEYBOARD) return TOUCH ? locator.tap() : locator.click(); + for (let i = 0; i < 64; i += 1) { + if (await locator.evaluate((el) => el === document.activeElement)) { + if (key) await page.keyboard.press(key); + return; + } + await page.keyboard.press("Tab"); + } + throw new Error(`native keyboard could not reach ${await locator.textContent()}`); +} + +async function readList(page, snapshot, records, phase = "initial") { + await applyTextFixture(page); + const list = page.getByTestId("graph-entity-list"); + await list.waitFor({ state: "visible", timeout: 10_000 }); + const summaries = list.locator("summary"); + if (await summaries.count() !== snapshot.nodes.length) throw new Error("List does not contain every retained entity"); + for (let i = 0; i < snapshot.nodes.length; i += 1) { + const summary = summaries.nth(i); + await activate(page, summary); + const record = await summary.evaluate((el) => { + const details = el.closest("details"); + const label = el.querySelector(".na-entity-label"); + const r = el.getBoundingClientRect(); + const header = el.closest('[data-testid="graph-rail"]').querySelector(".na-rail-head"); + const range = document.createRange(); + range.selectNodeContents(label); + const lines = [...range.getClientRects()].map((v) => ({ left: v.left, right: v.right, top: v.top, bottom: v.bottom })); + const clips = []; + for (let p = el.parentElement; p; p = p.parentElement) { + if (/auto|scroll|hidden|clip/.test(getComputedStyle(p).overflowX)) { + const b = p.getBoundingClientRect(); + clips.push({ left: b.left + p.clientLeft, right: b.left + p.clientLeft + p.clientWidth, + top: b.top + p.clientTop, bottom: b.top + p.clientTop + p.clientHeight }); + } + } + return { + id: details.dataset.nodeId, open: details.open, + label: label.textContent, kind: el.querySelector(".na-entity-kind").textContent, + counts: [...details.querySelectorAll(".na-entity-counts dd")].map((n) => n.textContent), + edges: [...details.querySelectorAll("[data-edge-key]")].map((n) => ({ + key: n.dataset.edgeKey, + label: n.querySelector(".na-related-label").textContent, + kind: n.querySelector(".na-entity-kind").textContent, + type: n.querySelector(".na-relationship-type").textContent, + reading: n.querySelector("p").textContent, + })), + focused: document.activeElement === el, + stickyHeaderBottom: getComputedStyle(header).position === "sticky" ? header.getBoundingClientRect().bottom : null, + rect: { top: r.top, bottom: r.bottom, left: r.left, right: r.right }, + lines, clips, viewport: { width: innerWidth, height: innerHeight }, + }; + }); + records.push(record); + const node = snapshot.nodes[i]; + const incident = snapshot.edges.filter((e) => e.source === node.id || e.target === node.id); + if (!record.open || record.id !== node.id || record.label !== node.label || record.kind !== node.type) throw new Error(`wrong entity reading at ${i}`); + if (record.rect.top < -1 || record.rect.bottom > record.viewport.height + 1) throw new Error(`native summary reading is outside viewport: ${node.label}`); + if (record.stickyHeaderBottom !== null && record.rect.top < record.stickyHeaderBottom - 1) throw new Error(`native summary reading is covered by the sticky header: ${node.label}`); + if (record.clips.some((c) => record.rect.top < c.top - 1 || record.rect.bottom > c.bottom + 1)) throw new Error(`native summary reading is clipped by its scrollport: ${node.label}`); + if (KEYBOARD && !record.focused) throw new Error(`summary lost native focus at ${i}`); + if (!record.lines.length || record.lines.some((r) => r.right > record.viewport.width + 1 || r.left < -1 || record.clips.some((c) => r.left < c.left - 1 || r.right > c.right + 1))) throw new Error(`entity label clips horizontally: ${node.label}`); + const count = node.count === undefined ? "unknown — not measured" : node.count.toLocaleString(); + if (record.counts[0] !== count || record.counts[1] !== `${node.visits.toLocaleString()} — activity, not evidence strength`) throw new Error(`wrong measured/activity metadata: ${node.label}`); + const expectedKeys = incident.map((e) => JSON.stringify([[e.source, e.target].sort()[0], [e.source, e.target].sort()[1], e.type])).sort(); + if (JSON.stringify(record.edges.map((e) => e.key).sort()) !== JSON.stringify(expectedKeys)) throw new Error(`missing, duplicated or mistyped relationship: ${node.label}`); + for (const edge of incident) { + const key = JSON.stringify([...([edge.source, edge.target].sort()), edge.type]); + const actual = record.edges.find((e) => e.key === key); + const other = snapshot.nodes.find((n) => n.id === (edge.source === node.id ? edge.target : edge.source)); + const reading = edge.type === "evidence" ? `Measurement: ${edge.weight.toLocaleString()}` + : edge.type === "traversal" ? `Observed together: ${edge.weight.toLocaleString()} times — activity, not evidence.` + : `Curated claim · ${edge.receipt.source} · ${edge.receipt.release} · Receipt`; + if (actual.label !== other.label || actual.kind !== other.type || actual.type !== edge.type || actual.reading !== reading) throw new Error(`wrong typed relationship reading: ${node.label}`); + } + if (node.label.length === Math.max(...snapshot.nodes.map((n) => n.label.length))) { + await page.screenshot({ path: join(OUT_DIR, `journey-${LABEL}-long-entity-${phase}-${i}.png`), fullPage: false }); + const relationship = summary.locator("..").locator(".na-relationships > li").first(); + if (await relationship.count()) { + const reading = record.relationshipReading = { attempts: [], segments: [], complete: false }; + let covered = 0; + for (let scroll = 0; scroll < 64 && !reading.complete; scroll += 1) { + const b = await relationship.evaluate((el) => { + const r = el.getBoundingClientRect(), rail = el.closest('[data-testid="graph-rail"]'); + const c = rail.getBoundingClientRect(), header = rail.querySelector(".na-rail-head"); + const top = Math.max(0, c.top + rail.clientTop, + getComputedStyle(header).position === "sticky" ? header.getBoundingClientRect().bottom : 0); + return { top, bottom: Math.min(innerHeight, c.top + rail.clientTop + rail.clientHeight), + y: r.top, height: r.height, x: c.left + c.width / 2 }; + }); + reading.attempts.push(b); + const start = Math.max(0, b.top - b.y), end = Math.min(b.height, b.bottom - b.y); + if (start <= covered + 1 && end > covered + 1) { + const name = `journey-${LABEL}-relationship-${phase}-${i}-${reading.segments.length}.png`; + await page.screenshot({ path: join(OUT_DIR, name), fullPage: false }); + reading.segments.push({ start, end, file: name }); covered = end; + reading.complete = covered >= b.height - 1; + } + if (!reading.complete) { + const delta = start > covered + 1 ? b.y + covered - b.top + : covered === 0 ? b.y - b.top : Math.min((b.bottom - b.top) * 0.7, b.height - covered); + if (KEYBOARD) await page.keyboard.press(delta < 0 ? "ArrowUp" : "ArrowDown"); + else { + await page.mouse.move(b.x, (b.top + b.bottom) / 2); + await page.mouse.wheel(0, delta); + } + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + } + } + if (!reading.complete) { + await page.screenshot({ path: join(OUT_DIR, `journey-${LABEL}-relationship-failure.png`), fullPage: false }); + throw new Error("native scrolling left part of the typed relationship unread"); + } + } + } + await activate(page, summary); + if (await summary.evaluate((el) => el.closest("details").open)) throw new Error("native summary did not close"); + } + return records; +} + +async function readGraphJourney(page) { + const snapshot = await readSession(page); + const rail = page.getByTestId("graph-rail"); + const listButton = rail.getByRole("button", { name: "List", exact: true }); + const mapButton = rail.getByRole("button", { name: "Map", exact: true }); + if (await listButton.getAttribute("aria-pressed") !== "true" || await page.getByTestId("nodegraph-canvas").count()) throw new Error("List was not the default reading view"); + const initial = await page.getByTestId("graph-entity-list").locator("summary").first().evaluate((el) => { + const r = el.getBoundingClientRect(), rail = el.closest(".na-rail").getBoundingClientRect(); + return { top: r.top, bottom: r.bottom, railTop: rail.top, railBottom: rail.bottom, viewportHeight: innerHeight }; + }); + graphReading = { snapshot, initial, records: [], restoredSessionExact: false }; + if (initial.top < initial.railTop - 1 || initial.bottom > Math.min(initial.railBottom, initial.viewportHeight) + 1) throw new Error("first List entity is not visible at the initial reading position"); + const records = await readList(page, snapshot, graphReading.records); + await activate(page, mapButton); + const canvas = page.getByTestId("nodegraph-canvas"); + await canvas.waitFor({ state: "visible", timeout: 10_000 }); + await applyTextFixture(page); + const box = await canvas.boundingBox(); + if (!box || box.width <= 0 || box.height <= 0) throw new Error("Map mounted into an empty container"); + await activate(page, page.getByTestId("nodegraph-fit")); + const traversal = rail.locator('input[data-filter-type="traversal"]'); + await activate(page, traversal, "Space"); + if (await traversal.isChecked()) throw new Error("native traversal filter did not turn off"); + const visible = snapshot.edges.filter((e) => e.type !== "traversal").length; + const mapText = await page.getByTestId("nodegraph").locator("header").innerText(); + if (!mapText.includes(`${visible} of ${snapshot.edges.length} relationships shown`)) throw new Error("Map filter count does not match actual typed snapshot"); + await page.screenshot({ path: join(OUT_DIR, `journey-${LABEL}-map-filtered.png`), fullPage: false }); + await activate(page, listButton); + await page.getByTestId("graph-entity-list").waitFor({ state: "visible", timeout: 10_000 }); + if (await page.getByTestId("nodegraph-canvas").count()) throw new Error("Map remained mounted while List was selected"); + const after = await readSession(page); + if (JSON.stringify(after) !== JSON.stringify(snapshot)) throw new Error("reading/filtering changed the session"); + if (await page.locator("[data-edge-key]").count() !== snapshot.edges.length * 2) throw new Error("Map filtering leaked into the full List"); + await applyTextFixture(page); + return { snapshot, initial, records, map: { box, visibleEdges: visible, text: mapText }, restoredSessionExact: true, optionalMapLabelDefect: "unchanged; this is not a canvas-legibility pass" }; +} try { await waitForServer(); - const browser = await chromium.launch(); - const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT } }); + browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT }, hasTouch: TOUCH, reducedMotion: REDUCED ? "reduce" : "no-preference" }); const pageErrors = []; page.on("pageerror", (e) => pageErrors.push(String(e.message ?? e))); @@ -128,6 +323,7 @@ try { }); await page.goto(URL_, { waitUntil: "networkidle" }); + textStyles = await page.evaluateHandle(() => new Map()); // "attached", not "visible": on the pre-fix tree the rail is display:none at // <=960px, and waiting for visibility would fail the script before the // journey it is meant to measure ever runs. @@ -188,10 +384,28 @@ try { loopMs = memoRendered ? Date.now() - startedAt : null; await page.waitForTimeout(4_000); + mkdirSync(OUT_DIR, { recursive: true }); + await applyTextFixture(page); + await page.screenshot({ path: join(OUT_DIR, `journey-${LABEL}-default-list.png`), fullPage: false }); + if (memoRendered) graphReading = await readGraphJourney(page); + if (memoRendered && TURNS > 1) { const memosBefore = await page.locator(".na-memo").count(); - await page.fill(".na-composer-input", FOLLOW_UP); + await activate(page, page.getByTestId("graph-rail").getByRole("button", { name: "Map", exact: true })); + const liveCanvas = await page.getByTestId("nodegraph-canvas").elementHandle(); + const composer = page.locator(".na-composer-input"); + await activate(page, composer, null); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type(FOLLOW_UP); await page.keyboard.press("Enter"); + const composerState = () => composer.evaluate((el) => ({ value: el.value, focused: el === document.activeElement })); + graphReading.followUp = { records: [], composer: { afterEnter: await composerState() } }; + // State clears on send; wait for that cleared value to reach the DOM before + // native typing can feed the previous rendered value back into the composer. + await page.waitForFunction(() => document.querySelector(".na-composer-input")?.value === "", null, { timeout: 5_000 }); + graphReading.followUp.composer.afterReset = await composerState(); + await page.keyboard.type("Unsent review note"); + graphReading.followUp.composer.afterTyping = await composerState(); try { await page.waitForFunction( (n) => document.querySelectorAll(".na-memo").length > n, @@ -202,6 +416,18 @@ try { failures.push(`second turn never produced a memo (still ${memosBefore})`); } await page.waitForTimeout(4_000); + graphReading.followUp.composer.afterStream = await composerState(); + const focusKept = graphReading.followUp.composer.afterStream.focused; + const draftKept = graphReading.followUp.composer.afterStream.value === "Unsent review note"; + const mapKept = await liveCanvas.evaluate((el) => el.isConnected && el === document.querySelector('[data-testid="nodegraph-canvas"]')); + if (!focusKept || !draftKept || !mapKept) failures.push("streaming changed composer focus/draft or remounted the active Map"); + await activate(page, page.getByTestId("graph-rail").getByRole("button", { name: "List", exact: true })); + const snapshot = await readSession(page); + Object.assign(graphReading.followUp, { snapshot, focusKept, draftKept, mapKept }); + await readList(page, snapshot, graphReading.followUp.records, "follow-up"); + graphReading.followUp.composer.afterReading = await composerState(); + if (await composer.inputValue() !== "Unsent review note") failures.push("reading the updated List lost the unsent note"); + if (JSON.stringify(await readSession(page)) !== JSON.stringify(snapshot)) failures.push("reading the updated List mutated the session"); } observations = await page.evaluate(() => { @@ -209,8 +435,10 @@ try { const rail = document.querySelector('[data-testid="graph-rail"]'); const canvasHost = document.querySelector('[data-testid="nodegraph-canvas"]'); const deferred = document.querySelector('[data-testid="graph-rail-deferred"]'); + const list = document.querySelector('[data-testid="graph-entity-list"]'); const rect = rail ? rail.getBoundingClientRect() : null; return { + userAgent: navigator.userAgent, rootChildren: document.getElementById("root")?.childElementCount ?? 0, scrollWidth: doc.scrollWidth, clientWidth: doc.clientWidth, @@ -225,6 +453,8 @@ try { graphMounted: canvasHost !== null, graphCanvasWidth: canvasHost ? Math.round(canvasHost.getBoundingClientRect().width) : 0, graphDeferred: deferred !== null, + graphListVisible: !!list && list.getBoundingClientRect().width > 0 && list.getBoundingClientRect().height > 0, + graphListEntities: list?.querySelectorAll("details[data-node-id]").length ?? 0, }; }); @@ -277,8 +507,10 @@ try { // explicitly deferred with a visible substitute. if (observations.graphMounted && observations.graphCanvasWidth === 0) failures.push("session graph mounted into a zero-width container"); - if (!observations.graphMounted && !observations.graphDeferred && observations.railEntities > 0) - failures.push("session graph neither rendered nor explicitly deferred"); + if (!observations.graphMounted && !observations.graphListVisible && !observations.graphDeferred && observations.railEntities > 0) + failures.push("session graph has neither a visible Map, a readable List nor an explicit deferral"); + if (observations.graphListVisible && observations.graphListEntities !== observations.railEntities) + failures.push("readable List does not contain every retained entity"); if (failedRequests.length) failures.push(`own-origin failed requests: ${JSON.stringify(failedRequests)}`); if (consoleErrors.length) failures.push(`own-origin console errors: ${JSON.stringify(consoleErrors)}`); @@ -292,6 +524,8 @@ try { url: URL_, question: QUESTION, turns: TURNS, + observationFixtures: { touchEmulation: TOUCH, computedText200: TEXT200, reducedMotion: REDUCED }, + textFixtures, followUp: TURNS > 1 ? FOLLOW_UP : null, pageErrors, consoleErrors, @@ -302,6 +536,7 @@ try { loopMs, keyboard, axe: axeResult, + graphReading, ...observations, result: failures.length ? "FAIL" : "PASS", failures, @@ -312,11 +547,12 @@ try { )}\n`, ); - await browser.close(); } catch (err) { hardFailure = err instanceof Error ? err.message : String(err); + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(JSON_OUT, `${JSON.stringify({ result: "FAIL", hardFailure, observations, graphReading, failures }, null, 2)}\n`); } finally { - stop(); + try { if (browser) await browser.close(); } finally { stop(); } } if (hardFailure) { @@ -333,7 +569,7 @@ console.log( `PASS journey@${WIDTH}x${HEIGHT}: ${observations.toolCards} tool cards, first memo in ${loopMs} ms ` + `("${observations.memoHeading}"), ` + `rail ${observations.railEntities} entities / ${observations.railEdges} edges ` + - `(graph ${observations.graphMounted ? `${observations.graphCanvasWidth}px` : "deferred"}), ` + + `(graph ${observations.graphMounted ? `${observations.graphCanvasWidth}px Map` : observations.graphListVisible ? `${observations.graphListEntities} readable List entities` : "deferred"}), ` + (KEYBOARD ? `composer reached in ${keyboard.focusPath.at(-1).tabs} Tab presses, ` : "") + (AXE ? `axe ${axeResult.violations.length} violation(s) / 0 serious+critical, ` : "") + `no overflow -> ${SHOT}`, diff --git a/src/app/styles.css b/src/app/styles.css index 4dbe1e2..2541009 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -192,19 +192,44 @@ button { font-family: inherit; cursor: pointer; } /* live graph rail (vendored @homenshum/nodegraph-live) */ .na-rail { - width: 360px; min-width: 300px; + width: 360px; min-width: 300px; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; border-left: 1px solid var(--line); background: var(--paper); } .na-rail-head { - display: flex; align-items: center; justify-content: space-between; - padding: 10px 14px; border-bottom: 1px solid var(--line-faint); + display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 12px; + flex-shrink: 0; padding: 10px 14px; border-bottom: 1px solid var(--line-faint); font-size: 12px; font-weight: 700; } +.na-rail-head h2 { margin: 0; font: inherit; } .na-rail-meta { color: var(--ink-muted); font-size: 11px; font-weight: 500; } .na-rail-empty { padding: 14px; color: var(--ink-faint); font-size: 12px; } .na-rail-graph { padding: 8px; } +/* Full names are the default reading surface; Map remains optional exploration. */ +.na-graph-views { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px; flex-shrink: 0; } +.na-graph-views button { + min-width: 64px; min-height: 44px; padding: 8px 12px; + border: 1px solid var(--ink-muted); border-radius: var(--r-sm); + background: var(--surface); color: var(--ink); font: inherit; cursor: pointer; +} +.na-graph-views button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-dim); font-weight: 700; } +.na-rail > div:last-child { flex: 0 0 auto; min-width: 0; } +.na-graph-list, .na-relationships { list-style: none; padding: 0; margin: 0; } +.na-graph-list { padding: 0 8px 8px; } +.na-graph-list > li { margin-bottom: 8px; border: 1px solid var(--line); border-radius: var(--r-sm); } +.na-graph-list summary { min-height: 44px; padding: 10px 12px; cursor: pointer; } +.na-entity-label, .na-related-label { font-weight: 600; overflow-wrap: anywhere; } +.na-entity-kind { display: block; color: var(--ink-muted); font-size: 12px; } +.na-entity-body { padding: 0 12px 12px; font-size: 12px; overflow-wrap: anywhere; } +.na-entity-counts { margin: 0; } +.na-entity-counts dt { font-weight: 600; } +.na-entity-counts dd { margin: 0 0 8px; } +.na-relationships > li { border-top: 1px solid var(--line-faint); padding-top: 10px; margin-top: 10px; } +.na-relationship-type { display: block; margin-top: 6px; } +.na-relationships p { margin: 2px 0 0; } +.na-map-help { margin: 0 0 10px; font-size: 12px; color: var(--ink-muted); } + /* Below 960px there is no room beside the conversation, so the rail becomes a bottom panel — it is never hidden. It used to be `display: none` here, and that was defect D1: `display:none` is invisible to React, so GraphRailPanel @@ -221,7 +246,6 @@ button { font-family: inherit; cursor: pointer; } flex: 0 0 auto; max-height: 46vh; overflow-y: auto; border-left: none; border-top: 1px solid var(--line); } - .na-rail-head { position: sticky; top: 0; z-index: 1; background: var(--paper); } /* WIG Interactions — "Match visual & hit targets": 44px minimum on mobile. Measured at 375w before this rule: .na-link 66x19, .na-send 34x34, diff --git a/src/features/node-agent/components/GraphRailPanel.tsx b/src/features/node-agent/components/GraphRailPanel.tsx index 73c08f1..1ab92be 100644 --- a/src/features/node-agent/components/GraphRailPanel.tsx +++ b/src/features/node-agent/components/GraphRailPanel.tsx @@ -1,15 +1,6 @@ -/** - * GraphRailPanel — the live session graph as a right-rail panel. - * - * Renders the vendored @homenshum/nodegraph-live over the one - * per-app-session GraphSession. Data arrives via useSyncExternalStore, so the - * rail streams as the agent loop feeds `session.observe(...)` step by step. - * Edge grammar is the renderer's, not ours: measured evidence, curated - * assertions (never emitted here — see agentGraphSession.ts), and traversal - * history never look alike. - */ - -import { useSyncExternalStore } from "react"; +/** Readable entities and optional spatial exploration of one live session. */ +import { useId, useState, useSyncExternalStore } from "react"; +import type { GraphEdge } from "../../../../vendor/nodegraph-live/index.js"; import { NodeGraph } from "../../../../vendor/nodegraph-live/react.js"; import { graphSession } from "../graph/agentGraphSession"; @@ -19,36 +10,97 @@ export function GraphRailPanel() { graphSession.getSnapshot, graphSession.getSnapshot, ); + const [view, setView] = useState<"list" | "map">("list"); + const id = useId(); + const nodes = new Map(snapshot.nodes.map((node) => [node.id, node])); + const adjacent = new Map(snapshot.nodes.map((node) => [node.id, [] as GraphEdge[]])); + for (const edge of snapshot.edges) { + adjacent.get(edge.source)!.push(edge); + adjacent.get(edge.target)!.push(edge); + } return ( ); } diff --git a/tests/graphRailPanel.test.ts b/tests/graphRailPanel.test.ts new file mode 100644 index 0000000..fc718f7 --- /dev/null +++ b/tests/graphRailPanel.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { GraphSession } from "../vendor/nodegraph-live/session.js"; +import { GraphRailPanel } from "../src/features/node-agent/components/GraphRailPanel"; + +const fixture = vi.hoisted(() => ({ session: null as GraphSession | null })); +vi.mock("../src/features/node-agent/graph/agentGraphSession", () => ({ + graphSession: { + subscribe: (listener: () => void) => fixture.session!.subscribe(listener), + getSnapshot: () => fixture.session!.getSnapshot(), + visitsById: () => fixture.session!.visitsById(), + }, +})); +vi.mock("../vendor/nodegraph-live/react.js", () => ({ + NodeGraph: () => { throw new Error("The default reading view must not mount a canvas"); }, +})); + +beforeEach(() => { fixture.session = new GraphSession(); }); +const render = () => renderToStaticMarkup(createElement(GraphRailPanel)); +const room = { kind: "room", label: "Acme diligence" }; +const source = { kind: "source", label: "Runway evidence" }; + +describe("an analyst reading the retained session", () => { + it("starts with an honest empty view and native view controls", () => { + const html = render(); + expect(html).toContain("Ask the room a question"); + expect(html).toMatch(/aria-pressed="true"[^>]*>List<\/button>/); + expect(html).toMatch(/aria-pressed="false"[^>]*>Map<\/button>/); + expect(html).not.toContain("graph-entity-list"); + expect(fixture.session!.stats().nodes).toBe(0); + }); + + it("keeps unknown and measured zero distinct for equal names of different kinds", () => { + fixture.session!.observe([{ kind: "room", label: "Acme", count: 0 }]); + fixture.session!.observe([{ kind: "source", label: "Acme" }]); + const html = render(); + expect(html.match(//g)).toHaveLength(2); + expect(html).toContain('class="na-entity-kind">room'); + expect(html).toContain('class="na-entity-kind">source'); + expect(html).toContain("
Measured count
0
"); + expect(html).toContain("
Measured count
unknown — not measured
"); + expect(html).toContain("No relationships observed for this entity."); + }); + + it("reads both measurement and activity for the same pair without changing either", () => { + fixture.session!.observe([room, source], 0, { eventId: "measurement" }); + fixture.session!.observe([room, source], undefined, { eventId: "activity-1" }); + fixture.session!.observe([room, source], undefined, { eventId: "activity-2" }); + const before = JSON.stringify(fixture.session!.getSnapshot()); + const html = render(); + expect(html).toContain("2 entities · 2 relationships"); + expect(html.match(/data-edge-key=/g)).toHaveLength(4); + expect(html.match(/>evidence<\/strong>/g)).toHaveLength(2); + expect(html.match(/>traversal<\/strong>/g)).toHaveLength(2); + expect(html).toContain("Measurement: 0"); + expect(html).toContain("Observed together: 2 times — activity, not evidence."); + expect(html).toContain("3 — activity, not evidence strength"); + expect(JSON.stringify(fixture.session!.getSnapshot())).toBe(before); + }); + + it("retains long and HTML-like labels as complete text rather than markup or links", () => { + const label = 'Finance__' + "long_unbroken_source_".repeat(12); + fixture.session!.observe([{ kind: "source", label }]); + const html = render(); + expect(html).toContain('Finance_<script>alert("example")</script>_' + "long_unbroken_source_".repeat(12)); + expect(html).not.toContain("