From d8880b6d1884cf86898944d871dae52132365aac Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 1 Jun 2026 23:15:45 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(node-ui):=20S4=20=E2=80=94=20assertion?= =?UTF-8?q?=20detail=20view=20+=20lifecycle=20trail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a clickable assertion detail view (the twin of KADetailView): Entities / Triples / Graph tabs + an ASSERTION INFO + LIFECYCLE metadata rail, scoped to the assertion's own data graph. - AssertionsList rows are now click-through (onSelectAssertion threaded through LayerContent + LayerDetailView); the promote button stops propagation. Rows are keyboard-focusable. - New useAssertionState hook (a verdict lock-b, lazy): on mount it reads dkg:state + dkg:memoryLayer + dkg:assertionGraph + prov:wasAttributedTo for the lifecycle URN from /_meta. fetchAssertionTriples reads the assertion's own data graph for the Entities/Triples/Graph panes. - Lifecycle trail reuses .v10-ka-timeline / .v10-ka-event* with a new is-current halo (rgba mirrors the existing dot fills) + a discarded tone; layer tone wiring by trust layer; discarded renders a single muted event. - Header: 3-line structure (line 3 subgraph only when truthy); right-rail badge {glyph} {layer} · {state}. Promote CTA only when state==='created' && layer==='wm', reusing a shared useAssertionPromote hook (AssertionsList adopts it too — no duplicated daemon call). - Triples tab is a plain s/p/o table, no filter pills. - Graph tab uses the current viewport treatment with the required S7 follow-up comment at the mount site. - Edge cases: hydrating (CTA hidden, all-neutral trail, no state suffix) and state-fetch error (panel "state unavailable", quiet danger hint). - ProjectView: selectedAssertion state + openAssertionDetail (captures M2 origin), mutually exclusive with the entity detail; handleDetailClose, layer-switch and subgraph-select all clear it. Tests: T01-T03 (helpers), T06/T07/T20 (ProjectView navigation), T08/T09/T10/T17 (detail-view DOM), T19 (state/triples reads). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/node-ui/src/ui/api.ts | 201 ++++++++ .../node-ui/src/ui/hooks/useAssertionState.ts | 80 +++ packages/node-ui/src/ui/styles.css | 23 + packages/node-ui/src/ui/views/ProjectView.tsx | 75 ++- .../src/ui/views/project/components.tsx | 481 +++++++++++++++++- .../node-ui/src/ui/views/project/helpers.ts | 118 +++++ .../test/assertion-detail-helpers.test.ts | 97 ++++ .../test/assertion-detail-view.dom.test.ts | 398 +++++++++++++++ ...ontext-graph-empty-stat-components.test.ts | 39 ++ .../test/project-view-navigation.test.ts | 78 ++- .../node-ui/test/use-assertion-state.test.ts | 263 ++++++++++ 11 files changed, 1821 insertions(+), 32 deletions(-) create mode 100644 packages/node-ui/src/ui/hooks/useAssertionState.ts create mode 100644 packages/node-ui/test/assertion-detail-helpers.test.ts create mode 100644 packages/node-ui/test/assertion-detail-view.dom.test.ts create mode 100644 packages/node-ui/test/use-assertion-state.test.ts diff --git a/packages/node-ui/src/ui/api.ts b/packages/node-ui/src/ui/api.ts index 3712ababc..3ea089a0c 100644 --- a/packages/node-ui/src/ui/api.ts +++ b/packages/node-ui/src/ui/api.ts @@ -1,3 +1,5 @@ +import type { AssertionState } from '@origintrail-official/dkg-core'; + const BASE = ''; declare global { interface Window { __DKG_TOKEN__?: string; } @@ -766,6 +768,205 @@ export async function listAssertions( return result; } +/** Current lifecycle position of a single assertion. */ +export interface AssertionStateInfo { + /** `dkg:state` literal — created / promoted / published / finalized / discarded. */ + state: AssertionState; + /** `dkg:memoryLayer` literal mapped to the UI layer key (`WM`→`wm`, etc.). */ + layer: 'wm' | 'swm' | 'vm'; + /** + * `dkg:assertionGraph` — the assertion's DATA graph URI + * (`did:dkg:context-graph:[/]/assertion//`), + * where the assertion's actual triples live. Undefined for lifecycle + * records that predate the `dkg:assertionGraph` predicate. + */ + assertionGraph?: string; + /** `prov:wasAttributedTo` — the authoring agent's DID, when recorded. */ + createdBy?: string; +} + +/** + * Read an assertion's CURRENT lifecycle state (S4 — α verdict lock-b, + * lazy). `listAssertions` deliberately does not carry the state on each + * row (today's WM list is `dkg:memoryLayer "WM"`-filtered, so every + * listed WM row is implicitly `created`); the assertion DETAIL view + * fetches the state on mount instead. + * + * `dkg:state` + `dkg:memoryLayer` are MUTABLE literals on the assertion + * LIFECYCLE entity (the `urn:dkg:assertion:…` subject) in the `/_meta` + * graph, written by `generateAssertion{Created,Promoted,Published}Metadata` + * in `@origintrail-official/dkg-publisher`. + * + * The caller passes `AssertionInfo.graphUri`, whose SHAPE differs by layer: + * - WM (since #864 `listAssertions(wm)` partition enumeration): the + * assertion's DATA GRAPH URI + * (`did:dkg:context-graph:[/]/assertion//`). + * - SWM (`listAssertions(swm)`): the LIFECYCLE URN + * (`urn:dkg:assertion:[:]::`). + * `dkg:state` is written ONLY on the lifecycle URN (publisher + * `generateAssertion*Metadata`, `metadata.ts:1155`); `dkg:memoryLayer` + * is written on BOTH the lifecycle URN AND the data-graph URI (the + * daemon's `assertionCreate` adds the latter, `dkg-publisher.ts:~4001`). + * The two are linked by ` dkg:assertionGraph ` + * (`metadata.ts:1154`). + * + * So to reach `dkg:state` from EITHER input shape we bind `?lifecycle` + * via a UNION: it is the input directly (SWM lifecycle URN) OR it links + * to the input through `dkg:assertionGraph` (WM data-graph URI). Feeding + * a WM data-graph URI straight to `dkg:state` would never match → null → + * "state unavailable" for every WM assertion (the #864 silent-regression + * trap). The UNION keys off exactly what `listAssertions` provides for + * either layer — no `AssertionInfo` shape change, so the α data-shape + * verdict (lazy lock-b: don't extend AssertionInfo) still holds. + * + * Same `_meta`-scoped query shape as `listAssertions` / + * `useAssertionLifecycleEvents`: the explicit `GRAPH <…/_meta> { … }` + * makes the engine's `wrapWithGraph` early-return so the query runs raw + * and self-scopes to this CG's `_meta` partition. + * + * `assertionGraph` returns the data-graph URI to read triples from: the + * resolved `dkg:assertionGraph` (works for the SWM lifecycle-URN input), + * falling back to the input itself (the WM data-graph URI input). + * + * Returns `null` when no lifecycle entity resolves (e.g. a row predating + * lifecycle metadata) — the detail view treats `null` as the fetch-error + * / unavailable case and renders an all-neutral trail. + */ +export async function fetchAssertionState( + contextGraphId: string, + graphUri: string, +): Promise { + const DKG = 'http://dkg.io/ontology/'; + const PROV = 'http://www.w3.org/ns/prov#'; + const metaGraph = `did:dkg:context-graph:${contextGraphId}/_meta`; + // UNION admits both `graphUri` shapes (WM data-graph URI via the + // inverse `dkg:assertionGraph` link; SWM lifecycle URN directly). + const sparql = `SELECT ?state ?layer ?createdBy ?assertionGraph WHERE { + GRAPH <${metaGraph}> { + { <${graphUri}> <${DKG}assertionGraph> ?assertionGraph . + BIND(<${graphUri}> AS ?lifecycle) } + UNION + { ?lifecycle <${DKG}assertionGraph> <${graphUri}> . + BIND(<${graphUri}> AS ?assertionGraph) } + ?lifecycle <${DKG}state> ?state . + OPTIONAL { ?lifecycle <${DKG}memoryLayer> ?layer } + OPTIONAL { ?lifecycle <${PROV}wasAttributedTo> ?createdBy } + } + } LIMIT 1`; + const data = await executeQuery(sparql, contextGraphId); + const bindings: any[] = data?.result?.bindings ?? []; + const first = bindings[0]; + if (!first) return null; + const state = bv(first.state) as AssertionState | undefined; + if (!state) return null; + // `dkg:memoryLayer` is one of the `MemoryLayer` enum literals + // ("WM" / "SWM" / "VM"). Fall back to deriving the layer from the + // state when the (optional) literal is absent so a partial record + // still resolves a sane layer for the badge / CTA gate. + const rawLayer = bv(first.layer); + const layer: 'wm' | 'swm' | 'vm' = + rawLayer === 'WM' ? 'wm' : + rawLayer === 'SWM' ? 'swm' : + rawLayer === 'VM' ? 'vm' : + state === 'created' ? 'wm' : + state === 'promoted' ? 'swm' : + 'vm'; + return { + state, + layer, + // The data graph to read triples from: the resolved + // `dkg:assertionGraph` (correct for the SWM lifecycle-URN input), + // falling back to the input itself (the WM data-graph URI input). + assertionGraph: bv(first.assertionGraph) ?? graphUri, + createdBy: bv(first.createdBy), + }; +} + +/** A single triple of an assertion's data graph. */ +export interface AssertionTriple { + subject: string; + predicate: string; + object: string; +} + +/** + * Read the triples of an assertion's DATA graph (S4 — the detail view's + * Triples / Entities / Graph panes are scoped to exactly this assertion, + * not the whole layer). `assertionGraph` is the `dkg:assertionGraph` URI + * resolved by `fetchAssertionState`. + * + * A WM assertion's triples live verbatim in its + * `…/assertion//` data graph. We query that ONE graph + * directly with an explicit `GRAPH <…> { ?s ?p ?o }` (self-scoping, same + * shape as the other `_meta` reads), so the result is the assertion's + * own content with zero cross-assertion bleed. The 5k LIMIT mirrors the + * lifecycle-events query ceiling — far above any realistic single + * assertion. + * + * NOTE: after promote the assertion's data graph empties (the triples + * move into `/_shared_memory`); a `promoted` assertion's detail view + * therefore shows an empty content set today. That is the documented + * G-BACKEND-M5 gap (a promoted SWM assertion is not yet an + * independently-inspectable object); a `created` WM assertion — S4's + * shippable scope — works in full. + */ +export async function fetchAssertionTriples( + contextGraphId: string, + assertionGraph: string, +): Promise { + const sparql = `SELECT ?s ?p ?o WHERE { + GRAPH <${assertionGraph}> { ?s ?p ?o } + } LIMIT 5000`; + const data = await executeQuery(sparql, contextGraphId); + const bindings: any[] = data?.result?.bindings ?? []; + const out: AssertionTriple[] = []; + for (const b of bindings) { + const subject = rawBindingValue(b.s); + const predicate = rawBindingValue(b.p); + const object = rawBindingValue(b.o); + if (subject == null || predicate == null || object == null) continue; + out.push({ subject, predicate, object }); + } + return out; +} + +/** + * Extract the RAW binding value WITHOUT stripping the literal quoting — + * triple objects must keep their `"…"` / `"…"^^` / `"…"@lang` form + * so the graph + table renderers can distinguish a literal from an IRI + * (downstream classifies by a leading `"`, see `useMemoryEntities`). IRIs + * come back bare. Mirror of the daemon's two binding encodings (`{value}` + * object form and N-Triples string form), like `bv` but quote-preserving. + * + * Codex round-1: for the standard SPARQL-JSON object form we now (a) escape + * `\` and `"` inside the value so a literal containing a quote stays + * well-formed, and (b) PRESERVE the datatype (`^^`) / language tag + * (`@lang`) — previously dropped, contradicting this comment. The + * N-Triples string form is already fully encoded by the daemon, so it + * passes through verbatim. + */ +function rawBindingValue(v: unknown): string | undefined { + if (v == null) return undefined; + if (typeof v === 'object' && 'value' in (v as any)) { + const node = v as any; + if (node.type === 'literal' || node.type === 'typed-literal') { + // Escape per N-Triples so embedded quotes/backslashes don't break + // the leading-`"` classification or the renderers. + const escaped = String(node.value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + // Standard SPARQL JSON carries the datatype on `.datatype` and the + // language on `.xml:lang` (some serialisers use `.language`). + const lang = node['xml:lang'] ?? node.language; + const datatype = node.datatype; + if (lang) return `"${escaped}"@${lang}`; + if (datatype) return `"${escaped}"^^<${datatype}>`; + return `"${escaped}"`; + } + return String(node.value); + } + if (typeof v === 'string') return v; + return String(v); +} + /** * Promote an assertion from WM to SWM. * diff --git a/packages/node-ui/src/ui/hooks/useAssertionState.ts b/packages/node-ui/src/ui/hooks/useAssertionState.ts new file mode 100644 index 000000000..177562377 --- /dev/null +++ b/packages/node-ui/src/ui/hooks/useAssertionState.ts @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { fetchAssertionState, type AssertionStateInfo } from '../api.js'; + +/** + * Per-mount fetch of a single assertion's CURRENT lifecycle state for + * the S4 assertion detail view (α verdict lock-b — lazy: the state is + * fetched on detail-view mount, NOT carried on every `listAssertions` + * row). `dkg:state` + `dkg:memoryLayer` are mutable literals on the + * lifecycle entity in `/_meta`; see `fetchAssertionState` in + * `api.ts` for the query shape and the data-model writers. + * + * Three terminal shapes the detail view distinguishes: + * - `{ loading: true }` — hydrating; the CTA stays hidden and + * the trail renders all-neutral with + * no "you are here" marker. + * - `{ data: AssertionStateInfo }` — resolved; drives the trail + * `is-current` halo, the badge state + * suffix, and the Promote CTA gate. + * - `{ error: string }` / data null — fetch failed OR no lifecycle + * record; the metadata panel shows + * "state unavailable" and the trail + * renders all-neutral with a quiet + * danger hint. + */ +export interface UseAssertionStateResult { + data: AssertionStateInfo | null; + loading: boolean; + error: string | null; +} + +export function useAssertionState( + contextGraphId: string | undefined, + graphUri: string | undefined, + // Codex round-1 — bump this to force a re-fetch even when the URI is + // unchanged (e.g. after a promote FROM the detail view flips the state + // in `_meta`; the same `graphUri` would otherwise skip the effect and + // leave the trail/badge/CTA stale at the pre-promote state). + refreshKey?: number, +): UseAssertionStateResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(Boolean(contextGraphId && graphUri)); + const [error, setError] = useState(null); + + useEffect(() => { + if (!contextGraphId || !graphUri) { + setData(null); + setLoading(false); + setError(null); + return; + } + let cancelled = false; + // Codex round-1 — clear `data` when the assertion changes so a + // consumer reading `data` during the in-flight window can't see the + // PREVIOUS assertion's state (stale state/layer/assertionGraph). The + // detail view's `assertionGraph` derives from `data` and drives the + // triples fetch, so a stale value would briefly query the wrong + // graph. Mirror of the `triplesLoading` reset discipline. + setData(null); + setLoading(true); + setError(null); + fetchAssertionState(contextGraphId, graphUri) + .then((result) => { + if (cancelled) return; + setData(result); + setLoading(false); + }) + .catch((err) => { + if (cancelled) return; + // Leave any prior data in place is pointless here — the URI + // identity changed, so a stale state would be wrong. Clear it + // and surface the error so the detail view shows "unavailable". + setData(null); + setLoading(false); + setError(err instanceof Error ? err.message : 'Failed to load assertion state'); + }); + return () => { cancelled = true; }; + }, [contextGraphId, graphUri, refreshKey]); + + return { data, loading, error }; +} diff --git a/packages/node-ui/src/ui/styles.css b/packages/node-ui/src/ui/styles.css index 63838406d..24bbdd064 100644 --- a/packages/node-ui/src/ui/styles.css +++ b/packages/node-ui/src/ui/styles.css @@ -6027,6 +6027,9 @@ body.light .v10-me-graph-legend { background: rgba(255,255,255,0.8); } } .v10-item-row:hover { background: var(--bg-hover); } .v10-item-row.selected { background: var(--bg-active); } +/* S4 — keyboard-focus affordance for assertion rows now that they are + click-through (the base row is already `cursor: pointer`). */ +.v10-item-row-clickable:focus-visible { outline: 2px solid var(--text-link); outline-offset: -2px; } .v10-item-icon { font-size: 14px; width: 22px; text-align: center; flex-shrink: 0; color: var(--text-tertiary); } .v10-item-info { flex: 1; min-width: 0; } .v10-item-name { font-size: 12px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -6043,6 +6046,10 @@ body.light .v10-me-graph-legend { background: rgba(255,255,255,0.8); } .v10-trust-badge.vm { background: rgba(34,197,94,0.12); color: var(--text-success); } .v10-trust-badge.swm { background: rgba(245,158,11,0.12); color: var(--text-warning); } .v10-trust-badge.wm { background: rgba(100,116,139,0.12); color: var(--text-tertiary); } +/* S4 — a discarded assertion is layerless: neutral muted badge, no layer + color (never VM-green). Reuses the same elevated/border tokens as the + discarded lifecycle dot. */ +.v10-trust-badge-discarded { background: var(--bg-elevated); color: var(--text-tertiary); border: 1px solid var(--border-strong); } .v10-item-promote-btn { padding: 3px 10px; border-radius: 5px; font-size: 10px; font-weight: 600; border: 1px solid var(--border-default); color: var(--text-tertiary); @@ -6553,6 +6560,12 @@ body.light .v10-ka-graph-shell .v10-graph-canvas { padding: 4px 10px; cursor: pointer; font-family: var(--font-sans); transition: all 0.15s; } .v10-ka-back:hover { color: var(--text-primary); border-color: var(--border-strong); } +/* S4 — the assertion-detail header lays its title block and its + badge + Promote CTA out as a single row (the entity detail's header + is a column with a back button; the assertion detail has no back + button — the breadcrumb is the sole back-affordance). */ +.v10-ka-header-row { flex-direction: row; align-items: flex-start; justify-content: space-between; } +.v10-ka-header-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } .v10-ka-header-left { display: flex; flex-direction: column; gap: 2px; } .v10-ka-label { font-size: 10px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; } .v10-ka-name { font-size: 15px; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 8px; } @@ -6603,6 +6616,16 @@ body.light .v10-ka-graph-shell .v10-graph-canvas { .v10-ka-event-dot.created { border-color: var(--layer-working); background: rgba(100,116,139,0.15); } .v10-ka-event-dot.shared { border-color: var(--layer-shared); background: rgba(245,158,11,0.15); } .v10-ka-event-dot.verified { border-color: var(--layer-verified); background: rgba(34,197,94,0.15); } +/* S4 — the assertion lifecycle trail's "you are here" halo on the + current stage's dot. The halo rgba values mirror the dot fills in + the rules directly above (same WM slate / SWM amber / VM green + palette, just at 0.25 for a soft ring). `discarded` is the muted + terminal state — no halo, neutral surface. */ +.v10-ka-event-dot.created.is-current { box-shadow: 0 0 0 4px rgba(100,116,139,0.25); } +.v10-ka-event-dot.shared.is-current { box-shadow: 0 0 0 4px rgba(245,158,11,0.25); } +.v10-ka-event-dot.verified.is-current { box-shadow: 0 0 0 4px rgba(34,197,94,0.25); } +.v10-ka-event-dot.discarded { border-color: var(--border-strong); background: var(--bg-elevated); } +.v10-ka-trail-hint { font-size: 10px; margin-top: 4px; } .v10-ka-event-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 2px; } .v10-ka-event-title { font-size: 11px; font-weight: 600; color: var(--text-primary); } .v10-ka-event-time { font-family: var(--font-mono); font-size: 9px; color: var(--text-tertiary); white-space: nowrap; margin-left: auto; } diff --git a/packages/node-ui/src/ui/views/ProjectView.tsx b/packages/node-ui/src/ui/views/ProjectView.tsx index 21b876835..6ae563648 100644 --- a/packages/node-ui/src/ui/views/ProjectView.tsx +++ b/packages/node-ui/src/ui/views/ProjectView.tsx @@ -26,6 +26,7 @@ import { ProjectHeaderStrip, LayerSwitcher, KADetailView, + AssertionDetailView, SubGraphDetailView, ProjectOverviewCard, PendingJoinRequestsSection, @@ -36,6 +37,7 @@ import { ContextGraphQueryView, LayerDetailView, } from './project/components.js'; +import type { AssertionInfo } from '../api.js'; interface ProjectViewProps { contextGraphId: string; @@ -116,6 +118,12 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { const [showShare, setShowShare] = useState(false); const [activeLayer, setActiveLayer] = useState('overview'); const [selectedUri, setSelectedUri] = useState(null); + // S4 — an open assertion detail view. A peer of `selectedUri` (entity + // detail): only one detail overlay is open at a time, and opening an + // assertion clears any open entity (and vice-versa). The origin + // snapshot (M2) is captured at open so the breadcrumb / close restores + // the originating layer + Assertions subtab + scroll. + const [selectedAssertion, setSelectedAssertion] = useState(null); const [participantsState, setParticipantsState] = useState({ contextGraphId: null, list: [], @@ -211,6 +219,11 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // downstream memo that consumed `handleNavigate`. const selectedUriRef = useRef(null); useEffect(() => { selectedUriRef.current = selectedUri; }, [selectedUri]); + // S4 — mirror `selectedAssertion` so `openEntityDetail` (whose + // identity must stay stable) can see whether an assertion overlay is + // open without listing the state in its deps. + const selectedAssertionRef = useRef(null); + useEffect(() => { selectedAssertionRef.current = selectedAssertion; }, [selectedAssertion]); const [layerContentTabs, setLayerContentTabs] = useState>( DEFAULT_LAYER_TABS, ); @@ -278,10 +291,19 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // stays stable across navigation — listing `selectedUri` here would // recreate `openEntityDetail` on every entity click, which in turn // recreates `handleNavigate` and the cross-tab listener effect (R2-2). + // + // S4 — an entity can be opened while an assertion detail is already + // open (clicking an entity row inside the assertion view). In that + // case an origin is already captured (at assertion-open) and must be + // preserved, so the capture guard also checks `selectedAssertionRef`. + // Opening the entity replaces the assertion overlay (mutually + // exclusive) — clear it here. const openEntityDetail = useCallback((uri: string, originScrollKey?: string) => { - if (!selectedUriRef.current || !detailOriginRef.current) { + const hadDetail = selectedUriRef.current != null || selectedAssertionRef.current != null; + if (!hadDetail || !detailOriginRef.current) { detailOriginRef.current = captureDetailOrigin(originScrollKey); } + setSelectedAssertion(null); setSelectedUri(uri); }, [captureDetailOrigin]); @@ -289,6 +311,19 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { detailOriginRef.current = null; }, []); + // S4 — open an assertion's detail view. Captures the M2 origin + // snapshot (when none is pending) so the breadcrumb / close restores + // the originating layer + Assertions subtab + scroll, then clears any + // open entity detail (the two detail overlays are mutually exclusive). + const openAssertionDetail = useCallback((assertion: AssertionInfo) => { + if (!detailOriginRef.current) { + detailOriginRef.current = captureDetailOrigin(); + } + setSelectedUri(null); + setSelectedLayerContext(null); + setSelectedAssertion(assertion); + }, [captureDetailOrigin]); + useEffect(() => { if (selectedUri) return; const scroll = pendingScrollRestoreRef.current; @@ -593,6 +628,7 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { } } setSelectedUri(null); + setSelectedAssertion(null); setSelectedLayerContext(null); }, [clearDetailOrigin, setActiveSubGraphSync]); @@ -600,6 +636,7 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { clearDetailOrigin(); setActiveLayer(layer); setSelectedUri(null); + setSelectedAssertion(null); setSelectedLayerContext(null); // PR #793 sweep 5 Bug N — go through the sync helper so the // refs stay invariant with state. Pre-fix this path bypassed @@ -643,6 +680,10 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { detailOriginRef.current = null; setSelectedUri(null); setSelectedLayerContext(null); + // S4 — close path is shared by the entity detail AND the assertion + // detail (only one is open at a time); clear both so the same + // origin-restore serves either overlay (T16). + setSelectedAssertion(null); if (!origin) return; setActiveLayer(origin.activeLayer); // PR #793 sweep 5 Bug N — restoring the M2 origin's @@ -715,9 +756,11 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { const chipBarEntities = memoryReady ? rawMemory.entityList : undefined; const activePage = selectedEntity ? 'entity' - : activeSubGraph - ? 'subgraph' - : activeLayer; + : selectedAssertion + ? 'assertion' + : activeSubGraph + ? 'subgraph' + : activeLayer; const participantsForCurrentGraph = participantsState.contextGraphId === cg.id ? participantsState.list : []; @@ -765,11 +808,24 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { /> )} + {/* S4 — Assertion detail overlay. Peer of the entity detail + (mutually exclusive); the entity branch takes precedence so + opening an entity FROM the assertion view replaces it. */} + {selectedAssertion && !selectedEntity && ( + + )} + {/* Subgraph Explorer — page mode (specific chip or Root selected). First-class peer of the layer views; the page identity, intro and chip row are shared with the All / Subgraphs-overview state (rendered below in the graph-overview branch). */} - {activeSubGraph && !selectedEntity && ( + {activeSubGraph && !selectedEntity && !selectedAssertion && ( <> )} - {!activeSubGraph && activeLayer === 'query' && !selectedEntity && ( + {!activeSubGraph && activeLayer === 'query' && !selectedEntity && !selectedAssertion && ( )} {/* Layer Detail Views */} - {!activeSubGraph && (activeLayer === 'wm' || activeLayer === 'swm' || activeLayer === 'vm') && !selectedEntity && ( + {!activeSubGraph && (activeLayer === 'wm' || activeLayer === 'swm' || activeLayer === 'vm') && !selectedEntity && !selectedAssertion && ( <> handleLayerTabChange(activeLayer, tab)} diff --git a/packages/node-ui/src/ui/views/project/components.tsx b/packages/node-ui/src/ui/views/project/components.tsx index 1d15c8620..b5ea60fc3 100644 --- a/packages/node-ui/src/ui/views/project/components.tsx +++ b/packages/node-ui/src/ui/views/project/components.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react'; import type { ReactNode } from 'react'; +import type { AssertionState } from '@origintrail-official/dkg-core'; import { useFetch } from '../../hooks.js'; import { api } from '../../api-wrapper.js'; import { encodeDocTabId, resolveDocRef } from '../../lib/doc-tab-id.js'; @@ -9,17 +10,20 @@ import { listAssertions, promoteAssertion, describePromoteResult, describePromoteError, publishSharedMemory, listSwmEntities, executeQuery, writeProfileQueryCatalog, - fetchSubGraphs, - type AgentIdentity, type AssertionInfo, type PendingJoinRequest, type PromoteOutcome, type PublishResult, type SubGraphInfo, + fetchSubGraphs, fetchAssertionTriples, + type AgentIdentity, type AssertionInfo, type AssertionStateInfo, + type PendingJoinRequest, type PromoteOutcome, type PublishResult, type SubGraphInfo, } from '../../api.js'; import { ImportFilesModal } from '../../components/Modals/ImportFilesModal.js'; import { ShareProjectModal } from '../../components/Modals/ShareProjectModal.js'; import { useMemoryEntities, + buildMemoryEntities, canonicalEntityUri, isFirstClassEntity, type TrustLevel, type MemoryEntity, type Triple, type LayeredTriple, } from '../../hooks/useMemoryEntities.js'; +import { useAssertionState } from '../../hooks/useAssertionState.js'; import { decodeRdfStringLiteral } from '../../../rdf-literal.js'; import { useProjectProfile, ProjectProfileContext, useProjectProfileContext, @@ -62,6 +66,7 @@ import { matchesSearch, humanizeLabel, layerNoun, useLayerTriples, useCanonicalTriples, applyCanonicalAdmission, filterTriplesToEntities, admitTripleForScope, entityTimestamp, formatRelativeTime, formatTimelineBucket, formatTrailTimestamp, + canPromoteAssertion, assertionSubgraphLine, buildAssertionTrail, type LayerView, type LayerContentTab, type KAPane, type SubGraphTab, type SubGraphEntitySort, } from './helpers.js'; @@ -2076,6 +2081,7 @@ export function LayerContent({ activeTab, onTabChange, onSelectEntity, + onSelectAssertion, onNodeClick, footer, swmAttribution, @@ -2089,6 +2095,8 @@ export function LayerContent({ activeTab: LayerContentTab; onTabChange: (tab: LayerContentTab) => void; onSelectEntity: (uri: string) => void; + /** S4 — open an assertion's detail view from the Assertions subtab. */ + onSelectAssertion?: (assertion: AssertionInfo) => void; onNodeClick?: (node: any) => void; footer?: React.ReactNode; /** Codex Code6 (PR #656) — optional shared SWM attribution result @@ -2191,6 +2199,7 @@ export function LayerContent({ layer={layer} onComplete={memory.refresh} scrollKey={`layer:${layer}:assertions`} + onSelectAssertion={onSelectAssertion} /> )} @@ -2948,24 +2957,26 @@ export function ContextGraphQueryView({ contextGraphId }: { contextGraphId: stri ); } -// Small helper: compute unique triples for a given layer slice of memory. - -export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey }: { - contextGraphId: string; - layer: 'wm' | 'swm'; - onComplete: () => void; - scrollKey?: string; -}) { - const { data: assertions, loading, refresh } = useFetch( - () => listAssertions(contextGraphId, layer), - [contextGraphId, layer], - 0 - ); +// ─── Shared assertion-promote action ──────────────────────── +// The single-row promote/publish flow is invoked from BOTH the +// `AssertionsList` rows AND the S4 `AssertionDetailView` Promote CTA. +// Lifting it into one hook (rather than duplicating the daemon call + +// busy/result/error bookkeeping in two places) keeps the two call sites +// in lockstep — e.g. the PR #710 sub-graph-slug threading fix stays in +// one place. `busy` is keyed on the row's `graphUri` (PR #710 Fix D — +// a root + sub-graph pair can share a `name`), with `'__all__'` reserved +// for the list-level promote-all action. +export function useAssertionPromote( + contextGraphId: string, + layer: 'wm' | 'swm', + onComplete: () => void, + onAfter?: () => void, +) { const [busy, setBusy] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(null); - const handlePromote = useCallback(async (assertion: AssertionInfo) => { + const promoteOne = useCallback(async (assertion: AssertionInfo) => { // PR #710 Fix D — busy / React keys must use `graphUri`, not // `name`. A root + sub-graph pair can share a name and would // otherwise both highlight as busy on a single click. `graphUri` @@ -2990,17 +3001,17 @@ export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey }: await publishSharedMemory(contextGraphId, roots); setResult('Published to Verifiable Memory'); } - refresh(); onComplete(); + onAfter?.(); } catch (err: any) { const typed = describePromoteError(assertion.name, err); setError(typed ? typed.message : (err?.message ?? 'Action failed')); } finally { setBusy(null); } - }, [contextGraphId, layer, refresh, onComplete]); + }, [contextGraphId, layer, onComplete, onAfter]); - const handlePromoteAll = useCallback(async () => { + const promoteAll = useCallback(async (assertions: AssertionInfo[] | null | undefined) => { if (!assertions?.length) return; setBusy('__all__'); setResult(null); @@ -3033,15 +3044,39 @@ export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey }: await publishSharedMemory(contextGraphId, roots); setResult('Published all to Verifiable Memory'); } - refresh(); onComplete(); + onAfter?.(); } catch (err: any) { const typed = describePromoteError(currentAssertion ?? 'selected assertion', err); setError(typed ? typed.message : (err?.message ?? 'Action failed')); } finally { setBusy(null); } - }, [assertions, contextGraphId, layer, refresh, onComplete]); + }, [contextGraphId, layer, onComplete, onAfter]); + + return { busy, result, error, promoteOne, promoteAll }; +} + +export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey, onSelectAssertion }: { + contextGraphId: string; + layer: 'wm' | 'swm'; + onComplete: () => void; + scrollKey?: string; + /** S4 — open the assertion detail view for a clicked row. Optional so + * call sites that don't wire detail navigation (none today) degrade + * to a non-clickable list. */ + onSelectAssertion?: (assertion: AssertionInfo) => void; +}) { + const { data: assertions, loading, refresh } = useFetch( + () => listAssertions(contextGraphId, layer), + [contextGraphId, layer], + 0 + ); + const { busy, result, error, promoteOne, promoteAll } = + useAssertionPromote(contextGraphId, layer, onComplete, refresh); + + const handlePromote = promoteOne; + const handlePromoteAll = useCallback(() => promoteAll(assertions), [promoteAll, assertions]); const scrollRootStyle = { flex: 1, overflow: 'auto' } as const; @@ -3094,7 +3129,29 @@ export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey }: {result &&
✓ {result}
} {error &&
✕ {error}
} {assertions.map(a => ( -
+
onSelectAssertion(a) : undefined} + onKeyDown={onSelectAssertion + ? ev => { + // Codex round-1 — only the ROW itself activates on + // Enter/Space. `onKeyDown` bubbles, so without this guard + // pressing Enter/Space on the nested promote/publish + // button would ALSO open the detail (double action). + if (ev.target !== ev.currentTarget) return; + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + onSelectAssertion(a); + } + } + : undefined} + >
{a.name}
@@ -3131,6 +3188,7 @@ export function LayerDetailView({ memory, onNodeClick, onSelectEntity, + onSelectAssertion, contextGraphId, activeTab, onTabChange, @@ -3140,6 +3198,8 @@ export function LayerDetailView({ memory: ReturnType; onNodeClick: (node: any) => void; onSelectEntity: (uri: string) => void; + /** S4 — open an assertion's detail view from the Assertions subtab. */ + onSelectAssertion?: (assertion: AssertionInfo) => void; contextGraphId: string; activeTab: LayerContentTab; onTabChange: (tab: LayerContentTab) => void; @@ -3179,6 +3239,7 @@ export function LayerDetailView({ activeTab={activeTab} onTabChange={onTabChange} onSelectEntity={onSelectEntity} + onSelectAssertion={onSelectAssertion} onNodeClick={onNodeClick} swmAttribution={swmAttribution} /> @@ -3957,6 +4018,382 @@ export function TrailEvent({ ); } +// ─── S4 Assertion Detail View ─────────────────────────────── +// A clickable assertion's detail page — the twin of `KADetailView`: +// Entities / Triples / Graph tabs on the left, an ASSERTION INFO + +// LIFECYCLE metadata rail on the right. The assertion's content is +// scoped to its own data graph (resolved from `_meta` via +// `useAssertionState`), so the panes show exactly this assertion, not +// the whole layer. + +/** Lifecycle trail for the assertion detail rail. Reuses the + * `.v10-ka-timeline` / `.v10-ka-event*` markup family so it reads as + * one visual family with the entity-detail Provenance Trail (S13). The + * stage matching the current `state` carries the `is-current` halo. + * While the state is hydrating (`stage` array all-neutral, no current) + * or failed, the caller passes a `hint` rendered quietly at the base. */ +function AssertionLifecycleTrail({ + state, + hint, +}: { + state: AssertionState | null | undefined; + hint?: string | null; +}) { + const stages = buildAssertionTrail(state); + return ( +
+ {stages.map(stage => ( +
+
+
+ {stage.title} +
+
+ ))} + {hint && ( +
{hint}
+ )} +
+ ); +} + +export function AssertionDetailView({ + assertion, + contextGraphId, + onNavigate, + onComplete, + onOpenAgent, +}: { + assertion: AssertionInfo; + contextGraphId: string; + /** Open an entity from this assertion in the entity-detail view. */ + onNavigate: (uri: string) => void; + /** Refresh the underlying memory after a successful promote. */ + onComplete: () => void; + onOpenAgent?: (uri: string) => void; +}) { + // No back button by design (S4 + S5 lock): the breadcrumb in the + // persistent ProjectHeaderStrip is the sole back-affordance; the + // origin restore lives in ProjectView's `handleDetailClose`. + const [pane, setPane] = useState('content'); + const theme = useLayoutStore(s => s.theme); + const profile = useProjectProfileContext(); + const agents = useAgentsContext(); + + // Codex round-1 — a promote FROM this detail view flips the assertion's + // state in `_meta` but leaves `assertion.graphUri` unchanged, so neither + // the state fetch nor the triples fetch would re-run (their deps are + // URI-keyed). Bump this nonce on promote success to force both to + // refetch, so the trail / badge / Promote-CTA reflect the new state + // without a remount. + const [refreshNonce, setRefreshNonce] = useState(0); + + // Lifecycle state (α verdict lock-b — lazy, on mount). Resolves the + // assertion's data-graph URI + author DID alongside the state. + const { data: stateInfo, loading: stateLoading, error: stateError } = + useAssertionState(contextGraphId, assertion.graphUri, refreshNonce); + // While hydrating, the layer is unknown; once resolved we trust the + // `_meta` layer. A `created` WM assertion is the shippable scope; a + // promoted one resolves to `swm` (its data graph is empty — see + // `fetchAssertionTriples`). + const layer = stateInfo?.layer ?? 'wm'; + const layerConfig = LAYER_CONFIG[layer]; + const trustLevel: TrustLevel = LAYER_CONFIG[layer].trustLevel; + + // Promote CTA — reuses the SAME shared promote hook the list rows use + // (no duplication of the daemon call / busy bookkeeping). Only the WM + // single-row path is reachable here; the visibility gate below pins it + // to `created && wm`. + const { busy, error: promoteError, promoteOne } = + useAssertionPromote( + contextGraphId, + layer === 'swm' ? 'swm' : 'wm', + onComplete, + // onAfter fires on promote SUCCESS only — bump the nonce so this + // detail view re-fetches its own state + triples (the state flipped + // created → promoted in `_meta`). + () => setRefreshNonce(n => n + 1), + ); + const showPromote = canPromoteAssertion(stateInfo?.state, layer); + + // The assertion's own triples (scoped to its data graph). Fetched once + // the `_meta` lookup yields the data-graph URI. Tagged with the + // assertion's trust level so `buildMemoryEntities` classifies them. + const assertionGraph = stateInfo?.assertionGraph; + const [triples, setTriples] = useState([]); + const [triplesLoading, setTriplesLoading] = useState(false); + useEffect(() => { + if (!assertionGraph) { + // No data graph to read (promoted assertion — its triples moved to + // /_shared_memory — or a legacy record without dkg:assertionGraph). + // AssertionDetailView is NOT keyed in ProjectView, so this instance + // is reused across assertion switches: a prior in-flight fetch was + // cancelled but its `.finally` is gated by `cancelled`, so we MUST + // reset `triplesLoading` here or the Entities pane sticks on + // "Loading assertion entities…" forever and the empty-state never + // renders. + setTriples([]); + setTriplesLoading(false); + return; + } + let cancelled = false; + setTriplesLoading(true); + fetchAssertionTriples(contextGraphId, assertionGraph) + .then(rows => { if (!cancelled) setTriples(rows); }) + .catch(() => { if (!cancelled) setTriples([]); }) + .finally(() => { if (!cancelled) setTriplesLoading(false); }); + return () => { cancelled = true; }; + // `refreshNonce` re-runs the triples fetch after a promote (the data + // graph URI string is unchanged on promote, so it alone wouldn't). + }, [contextGraphId, assertionGraph, refreshNonce]); + + const layeredTriples = useMemo( + () => triples.map(t => ({ ...t, layer: trustLevel })), + [triples, trustLevel], + ); + const entities = useMemo(() => buildMemoryEntities(layeredTriples), [layeredTriples]); + // Root entities of the assertion: real first-class entities (drop the + // synthesised stubs for pure-object URIs the builder adds). + const rootEntities = useMemo( + () => [...entities.values()].filter(isFirstClassEntity), + [entities], + ); + const entityCount = rootEntities.length; + const tripleCount = triples.length; + + const author = stateInfo?.createdBy ? agents?.get(stateInfo.createdBy) ?? null : null; + const authorUri = stateInfo?.createdBy ?? null; + const subgraphLine = assertionSubgraphLine(assertion.subGraph); + + // Right-rail badge: `{glyph} {trustLabel} · {state}` once resolved; + // while hydrating the `· state` suffix is omitted (no flash). + const badgeState = stateLoading ? null : stateInfo?.state ?? null; + // Trail hint for the fetch-error / unavailable case (state couldn't + // load) — quiet danger text at the trail base; never on the happy path. + const trailHint = !stateLoading && (stateError || !stateInfo) + ? "Lifecycle state couldn't load" + : null; + + const graphOptions = useMemo(() => { + const focalColor = TRUST_COLORS[trustLevel]; + return { + labelMode: 'humanized' as const, + renderer: '2d' as const, + labels: memoryGraphLabels({ minZoomForLabels: 0.2 }), + style: { + namespaceColors: neutraliseBuiltinNamespaces(focalColor), + defaultNodeColor: focalColor, + defaultEdgeColor: '#475569', + edgeWidth: 1.0, + fontSize: 11, + }, + hexagon: { baseSize: 7, minSize: 4, maxSize: 10, scaleWithDegree: true }, + focus: { maxNodes: 500, hops: 999 }, + }; + }, [trustLevel]); + const graphViewConfig = useMemo(() => ({ + name: `assertion-${assertion.graphUri}-${theme}`, + palette: theme, + }), [assertion.graphUri, theme]); + + return ( +
+
+
+
+ ▤ {assertion.name} +
+
+ assertion · {entityCount} entities · {tripleCount} triples +
+ {subgraphLine &&
{subgraphLine}
} +
+
+ {/* Codex round-2 — a `discarded` assertion is LAYERLESS in the + core model: render a neutral muted badge (just `discarded`, + no layer glyph / no layer name / never VM-green). This is the + render-side guard for the unreachable-today `discarded → vm` + api.ts fallback (discarded isn't in any listAssertions, so + the detail never opens for it; this hardens our own code + against a future wiring). */} + {badgeState === 'discarded' ? ( + discarded + ) : ( + + {layerConfig.icon} {layerConfig.trustLabel}{badgeState ? ` · ${badgeState}` : ''} + + )} + {showPromote && ( + + )} +
+
+ +
+ {/* Left pane: Entities / Triples / Graph */} +
+
+ + + +
+ + {pane === 'content' && ( +
+ {triplesLoading && rootEntities.length === 0 ? ( +
Loading assertion entities...
+ ) : rootEntities.length === 0 ? ( + + ) : ( + rootEntities.map(e => { + const { icon, type } = entityMeta(e, profile); + return ( + + ); + }) + )} +
+ )} + + {pane === 'triples' && ( + // Plain s/p/o table, assertion-scoped — NO filter pills + // (an assertion has singular scope, so cross-layer / + // cross-subgraph pills would have nothing to filter). +
+ + + + + + {triples.slice(0, 200).map((t, i) => ( + + + + {/* Codex round-2: literal objects from + `fetchAssertionTriples`/`rawBindingValue` carry the + FULL N-Triples form (`"v"^^` / `"v"@lang`, + escaped) — required by the Graph tab's RdfGraph + + buildMemoryEntities. For DISPLAY, decode through the + shared `decodeRdfStringLiteral` (the same helper + `humanizeLabel` uses) so the datatype/lang suffix is + dropped and the body is unescaped — NOT a naive + outer-quote strip, which left `"42"^^` → + `42"^^`. IRIs render via `shortPred`. */} + + + ))} + +
SubjectPredicateObject
{shortPred(t.subject)}{shortPred(t.predicate)}{t.object.startsWith('"') ? decodeRdfStringLiteral(t.object).slice(0, 60) : shortPred(t.object)}
+
+ {Math.min(triples.length, 200)} of {triples.length} triples shown +
+
+ )} + + {pane === 'graph' && ( + // S4 ships with current viewport treatment. S7 (Track B, pending) + // will land the full-width / tall / expandable viewport upgrade + // for KADetailView's Graph tab AND AssertionDetailView's Graph + // tab simultaneously — same component family. + ( + layeredTriples.length > 0 ? ( + Loading graph...}> + n?.id && onNavigate(n.id)} + initialFit + /> + + ) : ( +
No assertion graph data
+ ) + )} + /> + )} +
+ + {/* Right pane: ASSERTION INFO + LIFECYCLE trail */} +
+
+
Assertion Info
+
+ State + + {stateLoading + ? 'loading…' + : stateInfo + // Discarded is layerless — no `(Layer)` suffix. + ? (stateInfo.state === 'discarded' + ? 'discarded' + : `${stateInfo.state} (${layerConfig.trustLabel})`) + : 'state unavailable'} + +
+ {(author || authorUri) && ( +
+ Created by + + + +
+ )} + {assertion.subGraph && ( +
+ Subgraph + {assertion.subGraph} +
+ )} + {assertionGraph && ( +
+ Graph + {assertionGraph} +
+ )} +
+ +
+
Lifecycle
+ +
+ + {promoteError && ( +
✕ {promoteError}
+ )} +
+
+
+ ); +} + // ─── Subgraph Explorer header (page identity + permanent intro) ──── // Shared between the All / Subgraphs-overview state and every chip diff --git a/packages/node-ui/src/ui/views/project/helpers.ts b/packages/node-ui/src/ui/views/project/helpers.ts index eb27013d9..f35e23ae2 100644 --- a/packages/node-ui/src/ui/views/project/helpers.ts +++ b/packages/node-ui/src/ui/views/project/helpers.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import type { AssertionState } from '@origintrail-official/dkg-core'; import { useMemoryEntities, canonicalEntityUri, @@ -893,3 +894,120 @@ export function formatRelativeTime(ts: number): string { // Older than a month — show the date so it's still useful at a glance. return new Date(ts).toISOString().slice(0, 10); } + +// ─── S4 Assertion detail view ──────────────────────────────── +// An assertion's lifecycle is a forward-only chain +// created (WM) → promoted (SWM) → published (VM) → finalized +// with `discarded` as a terminal off-ramp reachable only from +// `created` (mirrors `AssertionState` in `@origintrail-official/dkg-core`). +// The detail view's right-rail trail reuses the entity-detail +// Provenance Trail markup family (`.v10-ka-timeline` / `.v10-ka-event*`), +// so the assertion stage must map onto the SAME tone vocabulary the +// trail dots already paint: `created` (WM slate), `shared` (SWM amber), +// `verified` (VM green), plus a `discarded` muted tone. + +/** Lifecycle layer of an assertion, derived from its `dkg:state`. */ +export type AssertionDetailLayer = 'wm' | 'swm' | 'vm'; + +/** + * Map an assertion lifecycle `state` to the trail-dot tone class used + * by `.v10-ka-event-dot.{created,shared,verified,discarded}`. The tone + * is keyed on the TRUST LAYER the state corresponds to (not the + * state-machine name) so the trail's colour reads consistently with the + * rest of the trust-coding system: `created` → WM slate, `promoted` → + * SWM amber, `published`/`finalized` → VM green, `discarded` → muted. + * Unknown states fall back to the WM `created` tone (the safest neutral). + */ +export function assertionStageTone( + state: AssertionState | null | undefined, +): 'created' | 'shared' | 'verified' | 'discarded' { + switch (state) { + case 'created': return 'created'; + case 'promoted': return 'shared'; + case 'published': + case 'finalized': return 'verified'; + case 'discarded': return 'discarded'; + default: return 'created'; + } +} + +/** + * Promote CTA visibility predicate for the assertion detail header. + * A per-assertion forward action exists ONLY for a `created` WM + * assertion (`promote` is assertion-scoped — §2.3); every later state + * has no further per-assertion forward action (SWM→VM publish is + * entity-scoped). Returns false while the state is still hydrating + * (`state == null`) so the CTA never flashes in then hides. + */ +export function canPromoteAssertion( + state: AssertionState | null | undefined, + layer: AssertionDetailLayer | null | undefined, +): boolean { + return state === 'created' && layer === 'wm'; +} + +/** + * Header line-3 content for the assertion detail view. The mockup folded + * the sub-graph into the line-2 stat row, but a root-scoped assertion + * has no sub-graph — splitting it onto a conditional third line avoids a + * trailing `· subgraph: undefined` cliff. Returns `null` (render nothing) + * for root assertions; the `subgraph: ` string only when truthy. + */ +export function assertionSubgraphLine( + subGraph: string | null | undefined, +): string | null { + const slug = subGraph?.trim(); + return slug ? `subgraph: ${slug}` : null; +} + +/** One stage of the assertion lifecycle trail. */ +export interface AssertionTrailStage { + /** State this stage represents. */ + state: AssertionState; + /** Human title rendered as the event title. */ + title: string; + /** Trail-dot tone class (`created` / `shared` / `verified` / `discarded`). */ + tone: 'created' | 'shared' | 'verified' | 'discarded'; + /** True for the stage matching the assertion's CURRENT state ("you are here"). */ + isCurrent: boolean; +} + +/** + * Build the ordered lifecycle-trail stages for an assertion detail view. + * + * - A live (non-discarded) assertion always renders the full forward + * chain `created ▸ promoted ▸ published ▸ finalized` so the user sees + * the whole pipeline and where the assertion currently sits; the stage + * matching `state` carries `isCurrent` (the `is-current` halo). + * - A `discarded` assertion renders a SINGLE muted `discarded` event + * (not `created ▸ discarded`) — the data model carries no distinct + * `discardedAt`, so a single terminal event is the natural treatment + * and needs no connector-mask CSS. + * - While the state is still hydrating (`state == null`) the full chain + * renders: each stage at its STATIC stage-tone (created=slate / + * promoted=amber / published+finalized=green — a fixed pipeline legend, + * NOT a state signal) with NO `is-current` marker. The absent halo is + * the load-bearing "no false you-are-here during hydrate" guarantee + * (ux §4.7.1); the static tones are intentionally kept (graying them + * would couple the legend to fetch state for a sub-second flash and + * pop colour on resolve). + */ +export function buildAssertionTrail( + state: AssertionState | null | undefined, +): AssertionTrailStage[] { + if (state === 'discarded') { + return [{ state: 'discarded', title: 'Discarded', tone: 'discarded', isCurrent: true }]; + } + const chain: Array<{ state: AssertionState; title: string }> = [ + { state: 'created', title: 'Created in Working Memory' }, + { state: 'promoted', title: 'Promoted to Shared Working Memory' }, + { state: 'published', title: 'Published to Verifiable Memory' }, + { state: 'finalized', title: 'Finalized' }, + ]; + return chain.map(stage => ({ + state: stage.state, + title: stage.title, + tone: assertionStageTone(stage.state), + isCurrent: state != null && stage.state === state, + })); +} diff --git a/packages/node-ui/test/assertion-detail-helpers.test.ts b/packages/node-ui/test/assertion-detail-helpers.test.ts new file mode 100644 index 000000000..70b24d47d --- /dev/null +++ b/packages/node-ui/test/assertion-detail-helpers.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { + assertionStageTone, + canPromoteAssertion, + assertionSubgraphLine, + buildAssertionTrail, +} from '../src/ui/views/project/helpers.js'; + +// S4 — pure helpers behind the assertion detail view. These pin the +// lifecycle-trail tone mapping (T01), the Promote CTA visibility +// predicate (T02), the header subgraph-line conditional (T03), and the +// trail-stage builder + `is-current` marker. + +describe('assertionStageTone — lifecycle state → trail-dot tone (T01)', () => { + it('maps each state onto the trust-layer tone vocabulary', () => { + expect(assertionStageTone('created')).toBe('created'); // WM slate + expect(assertionStageTone('promoted')).toBe('shared'); // SWM amber + expect(assertionStageTone('published')).toBe('verified'); // VM green + expect(assertionStageTone('finalized')).toBe('verified'); // VM-internal + expect(assertionStageTone('discarded')).toBe('discarded'); + }); + + it('falls back to the neutral `created` tone for null/undefined/unknown', () => { + expect(assertionStageTone(null)).toBe('created'); + expect(assertionStageTone(undefined)).toBe('created'); + expect(assertionStageTone('bogus' as any)).toBe('created'); + }); +}); + +describe('canPromoteAssertion — Promote CTA visibility predicate (T02)', () => { + // Truth table: the CTA shows ONLY for a `created` WM assertion. Every + // later state and every non-WM layer hides it (no further + // per-assertion forward action). Hydrating (null state) hides it too + // so it never flashes in then disappears. + it('is true ONLY for created + wm', () => { + expect(canPromoteAssertion('created', 'wm')).toBe(true); + }); + + it('is false for created in a non-WM layer', () => { + expect(canPromoteAssertion('created', 'swm')).toBe(false); + expect(canPromoteAssertion('created', 'vm')).toBe(false); + }); + + it('is false for every non-created state, even in wm', () => { + expect(canPromoteAssertion('promoted', 'wm')).toBe(false); + expect(canPromoteAssertion('published', 'wm')).toBe(false); + expect(canPromoteAssertion('finalized', 'wm')).toBe(false); + expect(canPromoteAssertion('discarded', 'wm')).toBe(false); + }); + + it('is false while the state/layer is still hydrating (null/undefined)', () => { + expect(canPromoteAssertion(null, 'wm')).toBe(false); + expect(canPromoteAssertion('created', null)).toBe(false); + expect(canPromoteAssertion(undefined, undefined)).toBe(false); + }); +}); + +describe('assertionSubgraphLine — header line-3 conditional (T03)', () => { + it('returns the `subgraph: ` line only when a sub-graph is present', () => { + expect(assertionSubgraphLine('research')).toBe('subgraph: research'); + }); + + it('returns null for a root-scoped assertion (no trailing-undefined cliff)', () => { + expect(assertionSubgraphLine(undefined)).toBeNull(); + expect(assertionSubgraphLine(null)).toBeNull(); + expect(assertionSubgraphLine('')).toBeNull(); + expect(assertionSubgraphLine(' ')).toBeNull(); + }); +}); + +describe('buildAssertionTrail — lifecycle trail stages + is-current marker', () => { + it('renders the full forward chain with the current stage marked', () => { + const stages = buildAssertionTrail('created'); + expect(stages.map(s => s.state)).toEqual(['created', 'promoted', 'published', 'finalized']); + expect(stages.filter(s => s.isCurrent).map(s => s.state)).toEqual(['created']); + }); + + it('marks `promoted` as current on a promoted assertion', () => { + const stages = buildAssertionTrail('promoted'); + expect(stages.filter(s => s.isCurrent).map(s => s.state)).toEqual(['promoted']); + // Tones follow the trust layers, not the state names. + expect(stages.find(s => s.state === 'promoted')!.tone).toBe('shared'); + expect(stages.find(s => s.state === 'published')!.tone).toBe('verified'); + }); + + it('renders a SINGLE muted discarded event (not created ▸ discarded)', () => { + const stages = buildAssertionTrail('discarded'); + expect(stages).toHaveLength(1); + expect(stages[0]).toMatchObject({ state: 'discarded', tone: 'discarded', isCurrent: true }); + }); + + it('renders the full chain all-neutral (no current marker) while hydrating', () => { + const stages = buildAssertionTrail(null); + expect(stages.map(s => s.state)).toEqual(['created', 'promoted', 'published', 'finalized']); + expect(stages.some(s => s.isCurrent)).toBe(false); + }); +}); diff --git a/packages/node-ui/test/assertion-detail-view.dom.test.ts b/packages/node-ui/test/assertion-detail-view.dom.test.ts new file mode 100644 index 000000000..e0597e4c0 --- /dev/null +++ b/packages/node-ui/test/assertion-detail-view.dom.test.ts @@ -0,0 +1,398 @@ +// @vitest-environment happy-dom + +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProjectProfileContext, type ProjectProfile } from '../src/ui/hooks/useProjectProfile.js'; +import { AgentsContext, type AgentsData } from '../src/ui/hooks/useAgents.js'; +import type { AssertionInfo, AssertionStateInfo, AssertionTriple } from '../src/ui/api.js'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// S4 — DOM tests for AssertionDetailView (T08 / T09 / T10 / T17). We +// override only the two assertion data reads on the real api.js module +// (keeping every other export the component tree pulls in) plus stub the +// lazy RdfGraph so the Graph tab doesn't load the force-graph bundle. + +const stateMock = vi.hoisted(() => ({ + fetchAssertionState: vi.fn<(cg: string, uri: string) => Promise>(), + fetchAssertionTriples: vi.fn<(cg: string, g: string) => Promise>(), + promoteAssertion: vi.fn<() => Promise<{ promotedCount: number }>>(), +})); + +vi.mock('../src/ui/api.js', async () => { + const actual = await vi.importActual('../src/ui/api.js'); + return { + ...actual, + fetchAssertionState: stateMock.fetchAssertionState, + fetchAssertionTriples: stateMock.fetchAssertionTriples, + promoteAssertion: stateMock.promoteAssertion, + }; +}); + +vi.mock('@origintrail-official/dkg-graph-viz/react', () => ({ + RdfGraph: () => React.createElement('div', { 'data-testid': 'rdf-graph' }, 'graph'), +})); + +const { AssertionDetailView } = await import('../src/ui/views/project/components.js'); + +const profile: ProjectProfile = { + contextGraphId: 'cg-test', + displayName: 'Context Graph Test', + primaryColor: '#64748b', + accentColor: '#22c55e', + subGraphs: [], + typeBindings: [], + views: [], + filterChips: [], + queryCatalogs: [], + savedQueries: [], + loading: false, + forSubGraph: () => undefined, + forType: typeIri => ({ typeIri, label: typeIri.split(/[/#]/).pop() ?? typeIri, color: '#64748b' }), + view: () => undefined, + chipsFor: () => [], + savedQueryCatalogsFor: () => [], + savedQueriesFor: () => [], +}; + +const agents: AgentsData = { + agents: new Map(), + list: [], + loading: false, + get: () => undefined, + openAgent: vi.fn(), +}; + +const NAME = 'http://schema.org/name'; +const TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; + +const sampleTriples: AssertionTriple[] = [ + { subject: 'urn:e:battery', predicate: TYPE, object: 'http://schema.org/Thing' }, + { subject: 'urn:e:battery', predicate: NAME, object: '"Battery cell 003"' }, +]; + +function query(selector: string): HTMLElement { + const el = document.querySelector(selector); + if (!el) throw new Error(`Missing element ${selector}`); + return el; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await new Promise(r => setTimeout(r, 0)); + }); +} + +function render(root: Root, assertion: AssertionInfo) { + return act(async () => { + root.render( + React.createElement(ProjectProfileContext.Provider, { value: profile }, + React.createElement(AgentsContext.Provider, { value: agents }, + React.createElement(AssertionDetailView, { + assertion, + contextGraphId: 'cg-test', + onNavigate: vi.fn(), + onComplete: vi.fn(), + }))), + ); + }); +} + +async function mount(assertion: AssertionInfo): Promise { + document.body.innerHTML = '
'; + const root = createRoot(query('#root')); + await render(root, assertion); + await flush(); + return root; +} + +// Post-#864 `AssertionInfo.graphUri` is the DATA-GRAPH (partition) URI. +const wmAssertion: AssertionInfo = { name: 'epcis-demo', graphUri: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', subGraph: 'demo' }; + +describe('AssertionDetailView', () => { + let root: Root | undefined; + + beforeEach(() => { + stateMock.fetchAssertionState.mockReset(); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.promoteAssertion.mockReset(); + stateMock.fetchAssertionTriples.mockResolvedValue(sampleTriples); + stateMock.promoteAssertion.mockResolvedValue({ promotedCount: 3 }); + }); + + afterEach(async () => { + if (root) { + await act(async () => { root!.unmount(); }); + root = undefined; + } + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + it('renders the 3-line header for a sub-graph-scoped WM created assertion (T09 / T17)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + createdBy: 'did:dkg:agent:0xabc', + }); + root = await mount(wmAssertion); + + // Line 1: ▤ + mono name. + expect(query('.v10-ka-name').textContent).toContain('epcis-demo'); + // Line 2: assertion · N entities · M triples. + const uals = document.querySelectorAll('.v10-ka-ual'); + expect(uals[0].textContent).toContain('assertion ·'); + expect(uals[0].textContent).toContain('2 triples'); + // Line 3: subgraph (only because this assertion is scoped). + expect([...uals].some(u => u.textContent?.includes('subgraph: demo'))).toBe(true); + // Right-rail badge shows the layer glyph + label + state. + const badge = query('.v10-trust-badge'); + expect(badge.textContent).toContain('Working'); + expect(badge.textContent).toContain('created'); + }); + + it('omits the line-3 subgraph row for a root-scoped assertion (T09 conditional)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/assertion/0xabc/root-doc', + }); + root = await mount({ name: 'root-doc', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/root-doc' }); + const uals = [...document.querySelectorAll('.v10-ka-ual')]; + expect(uals.some(u => u.textContent?.includes('subgraph:'))).toBe(false); + }); + + it('shows the Promote CTA only for created + wm (T08)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + expect(query('.v10-ka-header-actions').textContent).toContain('Promote to SWM'); + }); + + it('hides the Promote CTA for a promoted (SWM) assertion (T08)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'promoted', layer: 'swm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + expect(query('.v10-ka-header-actions').textContent).not.toContain('Promote to SWM'); + // Badge reflects the promoted state. + expect(query('.v10-trust-badge').textContent).toContain('promoted'); + }); + + // Codex round-2 finding 1 (hardening) — a discarded assertion is + // LAYERLESS: the badge is a neutral muted `discarded`, NO layer glyph / + // name and NEVER VM-green (the api.ts discarded→vm fallback is + // unreachable today, but this render guard makes it harmless). State + // row also drops the `(Layer)` suffix. + it('renders a discarded assertion with a neutral layerless badge (no vm-green)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + // memoryLayer absent (discard deletes it) → the api fallback would + // pick vm; the render guard must override to neutral. + state: 'discarded', layer: 'vm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + const badge = query('.v10-ka-header-actions .v10-trust-badge, .v10-ka-header-actions .v10-trust-badge-discarded') as HTMLElement; + expect(badge.classList.contains('v10-trust-badge-discarded')).toBe(true); + expect(badge.classList.contains('vm')).toBe(false); // never VM-green + expect(badge.textContent).toBe('discarded'); + expect(badge.textContent).not.toContain('Verifiable'); + expect(badge.textContent).not.toContain('◉'); // no layer glyph + // No Promote CTA on a discarded assertion. + expect(query('.v10-ka-header-actions').textContent).not.toContain('Promote to SWM'); + // State row is layerless ("discarded", no "(Layer)"). + expect(document.body.textContent).toContain('discarded'); + expect(document.body.textContent).not.toContain('discarded ('); + }); + + it('Triples tab renders a plain s/p/o table with NO filter pills (T10)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + // Switch to the Triples tab. + const tabs = [...document.querySelectorAll('.v10-content-tab')]; + const triplesTab = tabs.find(t => t.textContent === 'Triples')!; + await act(async () => { triplesTab.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + expect(document.querySelector('.v10-ka-triples-table')).not.toBeNull(); + // The entity-detail filter-pill chrome must NOT appear here. + expect(document.querySelector('.v10-triples-filter-pills')).toBeNull(); + expect(document.querySelector('.v10-ka-pill')).toBeNull(); + expect(query('.v10-ka-triples-table').textContent).toContain('Battery cell 003'); + }); + + // Codex round-2 finding 3 — the Triples table must DISPLAY literals + // decoded (datatype/lang suffix dropped, body unescaped), not the raw + // N-Triples form. The producer (rawBindingValue) keeps the full form for + // the Graph tab + buildMemoryEntities; the table renders through + // decodeRdfStringLiteral. Pin typed / lang / escaped cases. + it('Triples tab decodes typed / lang / escaped literals for display (no mangled suffix)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockResolvedValue([ + { subject: 'urn:e:1', predicate: 'http://schema.org/quantity', object: '"42"^^' }, + { subject: 'urn:e:1', predicate: 'http://schema.org/greeting', object: '"bonjour"@fr' }, + { subject: 'urn:e:1', predicate: 'http://schema.org/quote', object: '"say \\"hi\\""' }, + ]); + root = await mount(wmAssertion); + const tabs = [...document.querySelectorAll('.v10-content-tab')]; + await act(async () => { tabs.find(t => t.textContent === 'Triples')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + const table = query('.v10-ka-triples-table').textContent ?? ''; + // Decoded values shown; raw suffixes/escapes NOT shown. + expect(table).toContain('42'); + expect(table).not.toContain('^^<'); + expect(table).toContain('bonjour'); + expect(table).not.toContain('@fr'); + expect(table).toContain('say "hi"'); // unescaped + expect(table).not.toContain('\\"'); // no raw backslash-escape + }); + + it('hydrating: badge omits the · state suffix and the Promote CTA stays hidden', async () => { + // Never-resolving state fetch keeps the view in the hydrating phase. + stateMock.fetchAssertionState.mockReturnValue(new Promise(() => {})); + root = await mount(wmAssertion); + const badge = query('.v10-trust-badge'); + expect(badge.textContent).toContain('Working'); + expect(badge.textContent).not.toContain('·'); + expect(query('.v10-ka-header-actions').textContent).not.toContain('Promote to SWM'); + }); + + it('state-fetch error: panel shows "state unavailable" + quiet trail hint, CTA hidden', async () => { + stateMock.fetchAssertionState.mockResolvedValue(null); + root = await mount(wmAssertion); + expect(document.body.textContent).toContain('state unavailable'); + expect(document.querySelector('.v10-ka-trail-hint')?.textContent) + .toContain("Lifecycle state couldn't load"); + expect(query('.v10-ka-header-actions').textContent).not.toContain('Promote to SWM'); + }); + + // ux-lead §4.7.1 sibling edge — a created-WM assertion with a PRESENT + // but EMPTY data graph (assertionGraph truthy, zero triples) must show + // the "no extracted entities" copy, NOT the promoted line. The branch + // keys purely on state==='promoted', so created → the generic empty + // copy regardless of why it's empty. (This case does NOT hit the + // triplesLoading bug — assertionGraph is truthy, the fetch resolves, + // `.finally` unsticks loading — the risk is purely copy selection.) + it('created-WM with a present-but-empty data graph shows "no extracted entities", not the promoted line', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockResolvedValue([]); // present graph, zero triples + root = await mount(wmAssertion); + + expect(document.body.textContent).not.toContain('Loading assertion entities'); + expect(document.body.textContent).toContain('No entities in this assertion'); + expect(document.body.textContent).toContain('This assertion has no extracted entities.'); + // The promoted forward-path line must NOT appear for a created assertion. + expect(document.body.textContent).not.toContain('now live in Shared Working Memory'); + // A created-WM assertion still offers the Promote CTA. + expect(query('.v10-ka-header-actions').textContent).toContain('Promote to SWM'); + }); + + // Local-review 🟡 — AssertionDetailView is NOT keyed in ProjectView, so + // switching `selectedAssertion` REUSES the instance. Navigating from + // assertion A (triples mid-load) to assertion B that resolves WITHOUT a + // dkg:assertionGraph (a promoted assertion, or a legacy record) must + // reset triplesLoading in the effect's early-return — otherwise the + // Entities pane sticks on "Loading assertion entities…" forever and the + // promoted empty-state never renders. This pins the fix. + it('A (triples mid-load) → B with no assertionGraph: triplesLoading resets, empty-state renders', async () => { + // A — state resolves WITH a data graph; its triples fetch never + // settles (stuck mid-load). + stateMock.fetchAssertionState.mockResolvedValueOnce({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockReturnValueOnce(new Promise(() => {})); + root = await mount(wmAssertion); + // A is mid-load → the Entities pane shows the loading text. + expect(document.body.textContent).toContain('Loading assertion entities'); + + // B — a PROMOTED assertion: state resolves with NO assertionGraph + // (its triples moved to /_shared_memory). Re-render the SAME instance + // (no unmount) with a new assertion — the real ProjectView trigger. + stateMock.fetchAssertionState.mockResolvedValueOnce({ state: 'promoted', layer: 'swm' }); + await render(root, { name: 'promoted-b', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/promoted-b' }); + await flush(); + + // triplesLoading must have reset → no stuck loading text, and the + // promoted empty-state renders with ux-lead's locked forward-path + // copy ("entities" not "contents"; points to the SWM tab). + expect(document.body.textContent).not.toContain('Loading assertion entities'); + expect(document.body.textContent).toContain('No entities in this assertion'); + expect(document.body.textContent).toContain( + 'its entities now live in Shared Working Memory. Open the Shared Working Memory tab to view them.', + ); + }); + + // Codex round-1 finding 1 — on assertion switch the hook must CLEAR + // `data` so the previous assertion's state isn't briefly visible while + // the new fetch is in flight. A → B where B's state never resolves: the + // badge/State row must NOT show A's resolved state. + it('switching to a slow-resolving assertion does not leave the prior state visible (stale-data clear)', async () => { + // A — resolves to created/wm with a recognisable subgraph badge state. + stateMock.fetchAssertionState.mockResolvedValueOnce({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + expect(query('.v10-trust-badge').textContent).toContain('created'); + + // B — state never resolves (stuck hydrating). After the switch the + // badge must drop the `· created` suffix (hydrating), NOT keep A's. + stateMock.fetchAssertionState.mockReturnValueOnce(new Promise(() => {})); + await render(root, { name: 'b', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/b' }); + await flush(); + const badge = query('.v10-trust-badge'); + expect(badge.textContent).not.toContain('created'); // A's state is gone + expect(badge.textContent).not.toContain('·'); // hydrating: no state suffix + }); + + // Codex round-1 finding 2 — promoting FROM the detail view flips the + // state in `_meta` but leaves graphUri unchanged. The view must REFETCH + // its state + triples so the badge/CTA reflect the new state without a + // remount. + it('promoting from the detail refetches state + triples (post-promote staleness)', async () => { + // Mount: created/wm → Promote CTA visible. + stateMock.fetchAssertionState.mockResolvedValueOnce({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + expect(query('.v10-trust-badge').textContent).toContain('created'); + expect(query('.v10-ka-header-actions').textContent).toContain('Promote to SWM'); + expect(stateMock.fetchAssertionState).toHaveBeenCalledTimes(1); + + // After promote success the nonce bumps → state refetches and now + // resolves to promoted/swm. + stateMock.fetchAssertionState.mockResolvedValueOnce({ + state: 'promoted', layer: 'swm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + const cta = [...document.querySelectorAll('.v10-ka-header-actions button')] + .find(b => b.textContent?.includes('Promote to SWM'))!; + await act(async () => { cta.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + + // promoteAssertion fired; state refetched (≥2 calls); badge now promoted; + // CTA gone (promoted is not created+wm). + expect(stateMock.promoteAssertion).toHaveBeenCalledTimes(1); + expect(stateMock.fetchAssertionState.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(query('.v10-trust-badge').textContent).toContain('promoted'); + expect(query('.v10-ka-header-actions').textContent).not.toContain('Promote to SWM'); + }); +}); diff --git a/packages/node-ui/test/context-graph-empty-stat-components.test.ts b/packages/node-ui/test/context-graph-empty-stat-components.test.ts index b6e63d992..5becba741 100644 --- a/packages/node-ui/test/context-graph-empty-stat-components.test.ts +++ b/packages/node-ui/test/context-graph-empty-stat-components.test.ts @@ -586,4 +586,43 @@ describe('Context Graph shared empty/stat patterns', () => { await unmount(); }); + + // Codex round-1 finding 4 — the row's onKeyDown bubbles, so Enter/Space + // on the nested promote button must NOT also open the detail. The guard + // (`ev.target !== ev.currentTarget → return`) restricts row activation + // to the row itself. + it('Enter on the promote button does not open the detail; Enter on the row does (keyboard guard)', async () => { + apiMocks.listAssertions.mockResolvedValueOnce([ + { name: 'kbd-doc', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/kbd-doc', tripleCount: 4 }, + ]); + const onSelectAssertion = vi.fn(); + const { container, unmount } = await render( + React.createElement(AssertionsList, { + contextGraphId: 'cg-test', + layer: 'wm', + onComplete: vi.fn(), + onSelectAssertion, + }), + ); + await waitForText(container, 'kbd-doc'); + + const row = container.querySelector('.v10-item-row')!; + const promoteBtn = row.querySelector('button')!; + + // Enter on the nested promote button: bubbles to the row's onKeyDown, + // but the guard (target !== currentTarget) must suppress navigation. + await act(async () => { + promoteBtn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + expect(onSelectAssertion).not.toHaveBeenCalled(); + + // Enter on the ROW itself opens the detail. + await act(async () => { + row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + expect(onSelectAssertion).toHaveBeenCalledTimes(1); + expect(onSelectAssertion).toHaveBeenCalledWith(expect.objectContaining({ name: 'kbd-doc' })); + + await unmount(); + }); }); diff --git a/packages/node-ui/test/project-view-navigation.test.ts b/packages/node-ui/test/project-view-navigation.test.ts index 4cd46d47d..69e39a254 100644 --- a/packages/node-ui/test/project-view-navigation.test.ts +++ b/packages/node-ui/test/project-view-navigation.test.ts @@ -292,6 +292,14 @@ vi.mock('../src/ui/views/project/components.js', () => ({ React.createElement('div', {}, entity.label), React.createElement('button', { 'data-testid': 'open-related-entity', onClick: () => onNavigate('urn:entity:other') }, 'Open related'), React.createElement('button', { 'data-testid': 'detail-back', onClick: onClose }, 'Back to Context Graph')), + // S4 — assertion detail overlay. The mock surfaces the assertion + // identity + an "open entity from assertion" button so the + // mutually-exclusive overlay + close-to-origin behaviour can be + // asserted without the real fetch/render path. + AssertionDetailView: ({ assertion, onNavigate }: { assertion: any; onNavigate: (uri: string) => void }) => + React.createElement('section', { 'data-testid': 'assertion-detail', 'data-assertion': assertion.graphUri, 'data-name': assertion.name, 'data-subgraph': assertion.subGraph ?? '' }, + React.createElement('div', {}, assertion.name), + React.createElement('button', { 'data-testid': 'assertion-open-entity', onClick: () => onNavigate('urn:entity:demo') }, 'Open entity from assertion')), SubGraphDetailView: ({ slug, activeTab = 'items', onTabChange, onSelectEntity, initialLayer, initialEnabledLayers, onEnabledLayersChange }: { slug: string; activeTab?: string; @@ -408,18 +416,22 @@ vi.mock('../src/ui/views/project/components.js', () => ({ SubGraphExplorerHeader: () => React.createElement('div', { 'data-testid': 'subgraph-explorer-header' }, 'Subgraph Explorer'), ContextGraphQueryView: () => null, - LayerDetailView: ({ layer, activeTab, onTabChange, onSelectEntity, onNodeClick }: { + LayerDetailView: ({ layer, activeTab, onTabChange, onSelectEntity, onSelectAssertion, onNodeClick }: { layer: string; activeTab: string; onTabChange: (tab: string) => void; onSelectEntity: (uri: string) => void; + onSelectAssertion?: (assertion: any) => void; onNodeClick: (node: any) => void; }) => React.createElement('section', { 'data-testid': 'layer-detail', 'data-layer': layer, 'data-tab': activeTab }, React.createElement('button', { 'data-testid': 'layer-tab-graph', onClick: () => onTabChange('graph') }, 'Graph'), + React.createElement('button', { 'data-testid': 'layer-tab-assertions', onClick: () => onTabChange('assertions') }, 'Assertions'), React.createElement('div', { 'data-testid': 'layer-scroll', 'data-cg-scroll-key': `layer:${layer}:${activeTab}` }, React.createElement('button', { 'data-testid': 'open-layer-entity', onClick: () => onSelectEntity('urn:entity:working') }, 'Open layer entity'), React.createElement('button', { 'data-testid': 'open-layer-overlap-entity', onClick: () => onSelectEntity('urn:entity:overlap') }, 'Open overlap entity'), + // S4 — open an assertion detail from the Assertions subtab. + React.createElement('button', { 'data-testid': 'open-layer-assertion', onClick: () => onSelectAssertion?.({ name: 'demo-assertion', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/demo-assertion', subGraph: undefined }) }, 'Open assertion'), React.createElement('button', { 'data-testid': 'open-layer-graph-node', onClick: () => onNodeClick({ id: 'urn:entity:overlap', trustLayer: layer }) }, 'Open graph node'))), })); @@ -1288,4 +1300,68 @@ describe('ProjectView entity detail navigation', () => { expect(query('subgraph-bar').dataset.enabledScope).toBe('shared,working'); }); + // ─── S4 — assertion detail navigation ────────────────────── + + it('clicking an assertion row opens the AssertionDetailView (T06 / T20)', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + + await click('open-layer-assertion'); + await flush(); + + const detail = query('assertion-detail'); + expect(detail.dataset.name).toBe('demo-assertion'); + expect(detail.dataset.assertion).toBe('did:dkg:context-graph:cg-test/assertion/0xabc/demo-assertion'); + // The layer page is replaced by the overlay. + expect(document.querySelector('[data-testid="layer-detail"]')).toBeNull(); + // data-view on the page main reflects the assertion route. + expect(query('active-layer').dataset.layer).toBe('wm'); + }); + + it('closing the assertion detail returns to the originating layer + Assertions subtab (T07)', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + await click('open-layer-assertion'); + await flush(); + expect(query('assertion-detail').dataset.name).toBe('demo-assertion'); + + // No back button on the assertion detail (S5 lock) — the layer + // switcher is the always-visible exit. Switching back to WM + // restores the layer page; the Assertions subtab persists. + await click('switch-wm'); + await flush(); + expect(document.querySelector('[data-testid="assertion-detail"]')).toBeNull(); + expect(query('layer-detail').dataset.layer).toBe('wm'); + }); + + it('opening an entity from the assertion detail replaces it (mutually exclusive overlays)', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + await click('open-layer-assertion'); + await flush(); + expect(query('assertion-detail').dataset.name).toBe('demo-assertion'); + + await click('assertion-open-entity'); + await flush(); + // Entity detail takes over; the assertion overlay is gone. + expect(query('entity-detail').dataset.entity).toBe('urn:entity:demo'); + expect(document.querySelector('[data-testid="assertion-detail"]')).toBeNull(); + }); + + it('switching layers from the assertion detail exits the overlay (no stranded detail)', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + await click('open-layer-assertion'); + await flush(); + + await click('switch-swm'); + await flush(); + expect(document.querySelector('[data-testid="assertion-detail"]')).toBeNull(); + expect(query('layer-detail').dataset.layer).toBe('swm'); + }); + }); diff --git a/packages/node-ui/test/use-assertion-state.test.ts b/packages/node-ui/test/use-assertion-state.test.ts new file mode 100644 index 000000000..6f038e01b --- /dev/null +++ b/packages/node-ui/test/use-assertion-state.test.ts @@ -0,0 +1,263 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetchAssertionState, fetchAssertionTriples, listAssertions } from '../src/ui/api.js'; + +// S4 — pins the `_meta`-scoped assertion-state read + the assertion +// data-graph triple read (T19 + the data-shape contract). Mocks the +// transport boundary (`globalThis.fetch`, used by api.ts's `post`) so +// the parser code-path runs verbatim — same pattern as +// `list-assertions.test.ts`. + +function jsonResponse(body: unknown) { + return { ok: true, status: 200, json: async () => body } as any; +} + +describe('fetchAssertionState — reads dkg:state + memoryLayer from _meta (T19)', () => { + let originalFetch: typeof globalThis.fetch | undefined; + let fetchMock: ReturnType; + + beforeEach(() => { + originalFetch = globalThis.fetch; + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as any; + }); + afterEach(() => { + if (originalFetch) globalThis.fetch = originalFetch; + }); + + function setBindings(bindings: unknown[]) { + fetchMock.mockResolvedValueOnce(jsonResponse({ result: { bindings } })); + } + + // Post-#864, `AssertionInfo.graphUri` is the DATA-GRAPH (partition) URI, + // NOT the lifecycle URN. `fetchAssertionState` must take that partition + // URI and reach `dkg:state` via the INVERSE `dkg:assertionGraph` link + // (`?lifecycle dkg:assertionGraph ` → `?lifecycle dkg:state`). + const PARTITION = 'did:dkg:context-graph:cg-A/assertion/0xabc/notes'; + + it('queries /_meta keyed on the DATA-GRAPH URI via the inverse dkg:assertionGraph link (#864)', async () => { + setBindings([{ state: { value: 'created' }, layer: { value: 'WM' } }]); + await fetchAssertionState('cg-A', PARTITION); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.sparql).toContain('did:dkg:context-graph:cg-A/_meta'); + // The subject is bound via the inverse link, NOT used directly as the + // lifecycle subject (feeding the partition URI to `dkg:state` directly + // would never match → "state unavailable" silent regression). + expect(body.sparql).toContain('assertionGraph'); + expect(body.sparql).toContain(`<${PARTITION}>`); + expect(body.sparql).toContain('?lifecycle'); + expect(body.sparql).toContain('state'); + expect(body.sparql).toContain('memoryLayer'); + // The partition URI must NOT be wrapped as the direct dkg:state subject. + expect(body.sparql).not.toContain(`<${PARTITION}> `); + }); + + it('returns the created/wm shape; assertionGraph echoes the input partition URI', async () => { + setBindings([{ + state: { value: 'created' }, + layer: { value: 'WM' }, + createdBy: { value: 'did:dkg:agent:0xabc' }, + }]); + const out = await fetchAssertionState('cg-A', PARTITION); + expect(out).toEqual({ + state: 'created', + layer: 'wm', + assertionGraph: PARTITION, // echoed input — the data graph to read triples from + createdBy: 'did:dkg:agent:0xabc', + }); + }); + + it('maps SWM / VM memoryLayer literals to swm / vm', async () => { + setBindings([{ state: { value: 'promoted' }, layer: { value: 'SWM' } }]); + expect((await fetchAssertionState('cg-A', PARTITION))!.layer).toBe('swm'); + setBindings([{ state: { value: 'published' }, layer: { value: 'VM' } }]); + expect((await fetchAssertionState('cg-A', PARTITION))!.layer).toBe('vm'); + }); + + it('derives the layer from the state when the memoryLayer literal is absent', async () => { + setBindings([{ state: { value: 'created' } }]); + expect((await fetchAssertionState('cg-A', PARTITION))!.layer).toBe('wm'); + setBindings([{ state: { value: 'promoted' } }]); + expect((await fetchAssertionState('cg-A', PARTITION))!.layer).toBe('swm'); + setBindings([{ state: { value: 'finalized' } }]); + expect((await fetchAssertionState('cg-A', PARTITION))!.layer).toBe('vm'); + }); + + it('returns null when no lifecycle entity links to this data graph', async () => { + setBindings([]); + expect(await fetchAssertionState('cg-A', PARTITION)).toBeNull(); + }); + + it('returns null when the binding has no state literal', async () => { + setBindings([{ layer: { value: 'WM' } }]); + expect(await fetchAssertionState('cg-A', PARTITION)).toBeNull(); + }); +}); + +describe('fetchAssertionTriples — reads the assertion data graph', () => { + let originalFetch: typeof globalThis.fetch | undefined; + let fetchMock: ReturnType; + + beforeEach(() => { + originalFetch = globalThis.fetch; + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as any; + }); + afterEach(() => { + if (originalFetch) globalThis.fetch = originalFetch; + }); + + function setBindings(bindings: unknown[]) { + fetchMock.mockResolvedValueOnce(jsonResponse({ result: { bindings } })); + } + + it('queries the exact assertion data-graph URI', async () => { + setBindings([]); + await fetchAssertionTriples('cg-A', 'did:dkg:context-graph:cg-A/assertion/0xabc/notes'); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.sparql).toContain('GRAPH '); + }); + + it('maps s/p/o bindings, preserving literal quoting on objects', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'http://schema.org/name' }, o: { type: 'literal', value: 'Battery cell' } }, + { s: { value: 'urn:e:1' }, p: { value: 'http://example/ref' }, o: { value: 'urn:e:2' } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + expect(out).toHaveLength(2); + // Literal object is re-wrapped in quotes so downstream renderers + // classify it as a literal (not an IRI). + expect(out[0].object).toBe('"Battery cell"'); + // IRI object stays bare. + expect(out[1].object).toBe('urn:e:2'); + }); + + it('skips incomplete bindings', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'p' }, o: { value: 'o' } }, + { s: { value: 'urn:e:2' }, p: { value: 'p' } }, // no ?o → skipped + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + expect(out).toHaveLength(1); + }); + + // Codex round-1 — rawBindingValue must (a) PRESERVE datatype/lang + // (the doc comment claimed it but the code dropped them) and (b) ESCAPE + // embedded `"`/`\` so a quote-containing literal stays well-formed and + // still classifies as a literal (leading `"`). + it('preserves datatype on a typed literal', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'p' }, o: { type: 'typed-literal', value: '42', datatype: 'http://www.w3.org/2001/XMLSchema#integer' } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + expect(out[0].object).toBe('"42"^^'); + }); + + it('preserves the language tag on a lang-tagged literal', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'p' }, o: { type: 'literal', value: 'bonjour', 'xml:lang': 'fr' } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + expect(out[0].object).toBe('"bonjour"@fr'); + }); + + it('escapes embedded quotes and backslashes in a literal', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'p' }, o: { type: 'literal', value: 'say "hi"\\done' } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + // Backslash → \\, quote → \" ; still starts with `"` (literal marker). + expect(out[0].object).toBe('"say \\"hi\\"\\\\done"'); + expect(out[0].object.startsWith('"')).toBe(true); + }); +}); + +// Make-or-break (#864 rebase): the REAL list→detail data flow, NOT a +// state-fetch mock. `listAssertions(wm)` now yields the partition URI as +// `graphUri`; feeding THAT into `fetchAssertionState` must still resolve +// `dkg:state` (via the inverse dkg:assertionGraph link). A direct-subject +// query would silently return null → "state unavailable" for every WM +// assertion. This drives both fns against the real fetch transport with +// the post-#864 partition shape end-to-end. +describe('listAssertions(wm) partition graphUri → fetchAssertionState resolves state (#864 regression guard)', () => { + let originalFetch: typeof globalThis.fetch | undefined; + let fetchMock: ReturnType; + + beforeEach(() => { + originalFetch = globalThis.fetch; + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as any; + }); + afterEach(() => { + if (originalFetch) globalThis.fetch = originalFetch; + }); + + it('the graphUri from listAssertions(wm) feeds fetchAssertionState and hydrates created/wm', async () => { + const PARTITION = 'did:dkg:context-graph:cg-A/assertion/0xabc/notes'; + + // Call 1 — listAssertions(wm): #864 partition enumeration. ?g is the + // DATA-GRAPH (partition) URI; ?cnt the triple count. + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ g: { value: PARTITION }, cnt: { value: '5' } }] }, + })); + const rows = await listAssertions('cg-A', 'wm'); + expect(rows).toHaveLength(1); + // Post-#864 the row's graphUri IS the partition URI (the regression risk). + expect(rows[0].graphUri).toBe(PARTITION); + expect(rows[0].tripleCount).toBe(5); + + // Call 2 — fetchAssertionState(graphUri): must resolve via the inverse + // dkg:assertionGraph link. The daemon returns the lifecycle entity's + // state for the bound `?lifecycle`. + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ state: { value: 'created' }, layer: { value: 'WM' } }] }, + })); + const stateInfo = await fetchAssertionState('cg-A', rows[0].graphUri); + + // NOT null → no "state unavailable" silent regression. + expect(stateInfo).not.toBeNull(); + expect(stateInfo!.state).toBe('created'); + expect(stateInfo!.layer).toBe('wm'); + // assertionGraph echoes the partition URI → the triples pane reads it. + expect(stateInfo!.assertionGraph).toBe(PARTITION); + + // The state query keyed on the partition URI via the inverse link. + const stateBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(stateBody.sparql).toContain('assertionGraph'); + expect(stateBody.sparql).toContain(`<${PARTITION}>`); + }); + + // SWM rows carry a DIFFERENT graphUri shape: `listAssertions(swm)` sets + // graphUri = the LIFECYCLE URN (urn:dkg:assertion:…), not the data-graph + // URI. The SWM AssertionsList is click-through too (layer !== 'vm'), so + // fetchAssertionState must also resolve when fed the lifecycle URN + // directly. The UNION's first branch (input IS the lifecycle subject) + // handles that; the resolved dkg:assertionGraph gives the data graph to + // read triples from. + it('SWM lifecycle-URN graphUri also resolves state (UNION direct-subject branch)', async () => { + const LIFECYCLE = 'urn:dkg:assertion:cg-A:0xabc:notes'; + const DATA_GRAPH = 'did:dkg:context-graph:cg-A/assertion/0xabc/notes'; + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ + state: { value: 'promoted' }, + layer: { value: 'SWM' }, + assertionGraph: { value: DATA_GRAPH }, // resolved off the lifecycle subject + }] }, + })); + const stateInfo = await fetchAssertionState('cg-A', LIFECYCLE); + expect(stateInfo).not.toBeNull(); + expect(stateInfo!.state).toBe('promoted'); + expect(stateInfo!.layer).toBe('swm'); + // For the lifecycle-URN input, assertionGraph comes from the resolved + // dkg:assertionGraph (so the triples pane reads the data graph, not the + // lifecycle URN). + expect(stateInfo!.assertionGraph).toBe(DATA_GRAPH); + // The query admits the lifecycle URN as the direct subject AND keys + // off dkg:assertionGraph (the UNION covers both shapes). + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.sparql).toContain(`<${LIFECYCLE}>`); + expect(body.sparql).toContain('UNION'); + expect(body.sparql).toContain('assertionGraph'); + }); +}); From 2f6daba9fc898444c30b5b73f0b2c221a3bd7398 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 1 Jun 2026 23:25:31 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(node-ui):=20S5=20=E2=80=94=20breadcrum?= =?UTF-8?q?b=20navigation;=20drop=20in-detail=20back=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the in-detail "← Back to Context Graph" button with a persistent breadcrumb in ProjectHeaderStrip, the sole back-affordance for both the entity and assertion detail views. - buildBreadcrumbHops: Context Graph › {Layer full-name | Subgraph displayName} › {entity | assertion name}. Middle hop is EITHER the layer OR the subgraph, never both (§4.7.1). First hop → overview; middle hop (when a detail is open) → restore the M2 origin; trailing hop is a non-interactive span ("you are here"). - ProjectHeaderStrip renders the hops inline in the existing .v10-project-strip flex row (does NOT stack), reusing .v10-project-strip-sep (now 6px each-side padding) for the › glyph. Clickable hops: --text-link + underline-on-hover + focus-visible ring; per-hop max-width 200px + ellipsis (first hop stays intact); unconditional title= tooltip on every hop. Removes the now-orphaned .v10-project-strip-name / -sg / -sg-icon rules. - KADetailView drops its .v10-ka-back button + onClose prop; the breadcrumb drives close via ProjectView.handleDetailClose. AssertionDetailView never had a back button (S4 lock). - ProjectView: ProjectHeaderStrip wired with activeLayer + detailLabel (entity label / assertion name) + onOverview (→ overview) + onRestoreOrigin (= handleDetailClose); KADetailView onClose removed. Note: the .v10-ka-back CSS class is retained — AgentProfileView's unrelated "← Back" still uses it (a shell-level back, sibling of the protected PanelCenter "Back to Project"). The "Back to Context Graph" label and the detail-view button are gone (T13 guards both). Tests: T04/T05 (breadcrumb hop construction + cross-subgraph update, helpers); T11/T12 (ellipsis tooltip + clickable/trailing affordance, DOM); T13 (back-affordance-removed source guard); existing M2 close tests rewired to the breadcrumb restore path; ka-detail-label updated to assert the back button is gone. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/node-ui/src/ui/styles.css | 44 ++++--- packages/node-ui/src/ui/views/ProjectView.tsx | 24 +++- .../src/ui/views/project/components.tsx | 92 ++++++++------ .../node-ui/src/ui/views/project/helpers.ts | 117 +++++++++++++++++ .../test/assertion-detail-helpers.test.ts | 79 ++++++++++++ packages/node-ui/test/breadcrumb.dom.test.ts | 119 ++++++++++++++++++ packages/node-ui/test/ka-detail-label.test.ts | 18 +-- .../test/project-view-navigation.test.ts | 42 +++++-- .../test/s5-back-affordance-removed.test.ts | 48 +++++++ 9 files changed, 506 insertions(+), 77 deletions(-) create mode 100644 packages/node-ui/test/breadcrumb.dom.test.ts create mode 100644 packages/node-ui/test/s5-back-affordance-removed.test.ts diff --git a/packages/node-ui/src/ui/styles.css b/packages/node-ui/src/ui/styles.css index 24bbdd064..9a290cd53 100644 --- a/packages/node-ui/src/ui/styles.css +++ b/packages/node-ui/src/ui/styles.css @@ -7974,31 +7974,47 @@ body.light .v10-ka-graph-shell .v10-graph-canvas { box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-color, #a855f7) 18%, transparent); flex-shrink: 0; } -.v10-project-strip-name { +/* S5 — breadcrumb hops live inline in the project strip. The + separator is the existing `.v10-project-strip-sep`, now with 6px of + each-side padding per the §4.7.1 visual spec. The prior + `.v10-project-strip-name` / `.v10-project-strip-sg` rules are gone — + the breadcrumb hops replace the project-name button + subgraph chip. */ +.v10-breadcrumb { display: flex; align-items: center; min-width: 0; } +.v10-breadcrumb-hop { font-size: 13px; font-weight: 600; - color: var(--text-primary); - background: none; border: none; padding: 0; - cursor: pointer; font-family: var(--font-sans); + /* Shrink toward the middle hops; first hop keeps its natural width, + trailing hop is preserved most. */ + flex: 0 1 auto; min-width: 0; max-width: 200px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +/* First hop ("Context Graph") stays intact — it's the orientation + anchor; only the middle/trailing hops character-truncate. */ +.v10-breadcrumb-hop:first-child { flex: 0 0 auto; max-width: none; } +.v10-breadcrumb-hop.link { + background: none; border: none; padding: 0; + color: var(--text-link); cursor: pointer; transition: color 0.15s; } -.v10-project-strip-name:disabled { - cursor: default; opacity: 1; +.v10-breadcrumb-hop.link:hover { + text-decoration: underline; text-decoration-color: currentColor; text-underline-offset: 2px; } -.v10-project-strip-name:not(:disabled):hover { - color: var(--sg-color, #a855f7); +.v10-breadcrumb-hop.link:focus-visible { + outline: 2px solid var(--text-link); outline-offset: 2px; border-radius: 2px; +} +/* Trailing "you are here" hop — three intentional differences from + clickable hops (secondary color, weight 500, no underline, default + cursor) communicate the current location without a label. */ +.v10-breadcrumb-hop.current { + color: var(--text-secondary); font-weight: 500; cursor: default; } .v10-project-strip-sep { color: var(--text-ghost); font-size: 14px; font-weight: 400; + padding: 0 6px; + flex-shrink: 0; } -.v10-project-strip-sg { - display: inline-flex; align-items: center; gap: 5px; - font-size: 12px; font-weight: 600; - color: var(--text-secondary); -} -.v10-project-strip-sg-icon { font-size: 13px; line-height: 1; } .v10-project-strip-desc { font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/node-ui/src/ui/views/ProjectView.tsx b/packages/node-ui/src/ui/views/ProjectView.tsx index 6ae563648..ad3a11207 100644 --- a/packages/node-ui/src/ui/views/ProjectView.tsx +++ b/packages/node-ui/src/ui/views/ProjectView.tsx @@ -729,6 +729,14 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { openTab(CONTEXT_GRAPH_PRIMER_TAB); }, [openTab]); + // S5 — first breadcrumb hop ("Context Graph") navigates to the + // overview, clearing any open detail / subgraph page. Reuses the + // layer-switch reset (clears detail origin + refs) so it can't strand + // a stale subgraph/detail context. + const handleBreadcrumbOverview = useCallback(() => { + handleLayerSwitch('overview'); + }, [handleLayerSwitch]); + if (!cg) { return (
@@ -741,6 +749,15 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // across sub-graph / layer / overview routes. const activeSubGraphBinding = activeSubGraph ? profile.forSubGraph(activeSubGraph) : null; + // S5 — the breadcrumb's trailing hop is the open detail's name (entity + // label or assertion name); null when no detail is open (the middle hop + // is then the current location). + const breadcrumbDetailLabel = selectedEntity + ? selectedEntity.label + : selectedAssertion + ? selectedAssertion.name + : null; + // Codex Bug C — gate the `entities`-driven chip-count path on a // fully-loaded memory snapshot. While `useMemoryEntities` is mid- // hydration or a layer query is in-flight, `entityList` is partial @@ -778,8 +795,11 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { handleSelectSubGraph(null)} + detailLabel={breadcrumbDetailLabel} + onOverview={handleBreadcrumbOverview} + onRestoreOrigin={handleDetailClose} /> {/* Layer Switcher — always visible now. Clicking a layer from within @@ -802,9 +822,9 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { allEntities={detailEntities} allTriples={detailTriples} onNavigate={handleNavigate} - onClose={handleDetailClose} contextGraphId={contextGraphId} onRefresh={rawMemory.refresh} + onOpenAgent={openAgent} /> )} diff --git a/packages/node-ui/src/ui/views/project/components.tsx b/packages/node-ui/src/ui/views/project/components.tsx index b5ea60fc3..495a2ed96 100644 --- a/packages/node-ui/src/ui/views/project/components.tsx +++ b/packages/node-ui/src/ui/views/project/components.tsx @@ -67,6 +67,7 @@ import { filterTriplesToEntities, admitTripleForScope, entityTimestamp, formatRelativeTime, formatTimelineBucket, formatTrailTimestamp, canPromoteAssertion, assertionSubgraphLine, buildAssertionTrail, + buildBreadcrumbHops, type LayerView, type LayerContentTab, type KAPane, type SubGraphTab, type SubGraphEntitySort, } from './helpers.js'; @@ -517,15 +518,38 @@ export function LayerSwitcher({ active, counts, onSwitch, onShare, onImport, onR export function ProjectHeaderStrip({ cg, profile, + activeLayer, activeSubGraph, - onClearSubGraph, + detailLabel, + onOverview, + onRestoreOrigin, }: { cg: { id: string; name?: string; description?: string }; profile: ReturnType; + /** Current layer route (drives the middle hop when no subgraph). */ + activeLayer: LayerView; activeSubGraph: ReturnType['forSubGraph'] extends (s: string) => infer R ? R | null : null; - onClearSubGraph: () => void; + /** Open detail's name (entity / assertion), or null when none is open. */ + detailLabel?: string | null; + /** Navigate to the Context Graph overview (first-hop click). */ + onOverview: () => void; + /** Restore the M2 origin — close the open detail back to where it was + * opened (middle-hop click when a detail is open). */ + onRestoreOrigin: () => void; }) { const name = cg.name || profile.displayName || cg.id; + // S5 — the breadcrumb is the persistent navigation + back-affordance. + // It replaces the prior project-name + subgraph-chip rendering AND the + // old `.v10-ka-back` button (deleted on the detail views). + const hops = buildBreadcrumbHops({ + contextGraphName: name, + activeLayer, + activeSubGraph: activeSubGraph?.slug ?? null, + subGraphDisplayName: activeSubGraph?.displayName ?? activeSubGraph?.slug ?? null, + detailLabel, + }); + // Subgraph context still tints the strip + surfaces the description. + const description = activeSubGraph?.description ?? cg.description; return (
- - {activeSubGraph ? ( - <> - - - - {activeSubGraph.icon ?? '•'} - - {activeSubGraph.displayName ?? activeSubGraph.slug} - - {activeSubGraph.description && ( - - {activeSubGraph.description} - - )} - - ) : ( - cg.description && ( - - {cg.description} - - ) + + {description && ( + + {description} + )}
); @@ -3592,12 +3608,11 @@ export function VerifyOnDkgButton({ ); } -export function KADetailView({ entity, allEntities, allTriples, onNavigate, onClose, contextGraphId, onRefresh, onOpenAgent }: { +export function KADetailView({ entity, allEntities, allTriples, onNavigate, contextGraphId, onRefresh, onOpenAgent }: { entity: MemoryEntity; allEntities: Map; allTriples: Triple[]; onNavigate: (uri: string) => void; - onClose: () => void; contextGraphId: string; onRefresh: () => void; onOpenAgent?: (uri: string) => void; @@ -3721,7 +3736,8 @@ export function KADetailView({ entity, allEntities, allTriples, onNavigate, onCl return (
- + {/* S5 — no back button here; the persistent breadcrumb in + ProjectHeaderStrip is the sole back-affordance. */}
{detailNoun}
diff --git a/packages/node-ui/src/ui/views/project/helpers.ts b/packages/node-ui/src/ui/views/project/helpers.ts index f35e23ae2..0d4c95d3a 100644 --- a/packages/node-ui/src/ui/views/project/helpers.ts +++ b/packages/node-ui/src/ui/views/project/helpers.ts @@ -1011,3 +1011,120 @@ export function buildAssertionTrail( isCurrent: state != null && stage.state === state, })); } + +// ─── S5 Breadcrumb navigation ─────────────────────────────── +// `Context Graph › {Layer | Subgraph} › {Entity | Assertion}`. Lives +// inline in the persistent ProjectHeaderStrip. Hop content rules +// (§4.7.1): the middle hop is EITHER the layer full-name OR the subgraph +// displayName, never both (layer scope inside a subgraph is signalled by +// the in-page active-layer chip, not the breadcrumb). The trailing hop +// is the current location — a non-interactive span, three intentional +// styling differences from clickable hops ("you are here" without a +// label). + +/** Where a clicked breadcrumb hop navigates. */ +export type BreadcrumbTarget = + | 'overview' // the Context Graph overview (first hop) + | 'origin' // restore the M2 origin snapshot (close the open detail) + | 'current'; // the trailing hop — not interactive + +export interface BreadcrumbHop { + /** Stable React key. */ + key: string; + /** Truncatable display label. */ + label: string; + /** Full text for the unconditional `title=` tooltip. */ + title: string; + /** Navigation target; `'current'` hops render as a non-interactive span. */ + target: BreadcrumbTarget; +} + +const LAYER_FULL_NAME: Record = { + wm: 'Working Memory', + swm: 'Shared Working Memory', + vm: 'Verifiable Memory', +}; + +type MemoryLayerKey = 'wm' | 'swm' | 'vm'; + +/** + * Build the breadcrumb hops for the current ProjectView location. + * + * - `Context Graph` is always the first hop, clickable to overview + * (except when it is itself the current page — then it's the trailing + * `current` hop with no further hops). + * - The middle hop is the subgraph displayName when a subgraph page is + * active, otherwise the active layer's full name. Only one, never both. + * - The trailing hop (`current`) is the open detail's name (entity or + * assertion) when a detail is open; otherwise the middle hop itself is + * the current location. + * + * Navigation semantics are attached by the renderer via `target`: + * non-trailing hops are clickable; clicking the first hop goes to + * overview, clicking the middle hop (when a detail is open) restores the + * M2 origin (closes the detail back to where it was opened). + */ +export function buildBreadcrumbHops(input: { + /** The CG display name (first hop label). */ + contextGraphName: string; + activeLayer: LayerView; + /** Active subgraph slug, or null when not on a subgraph page. */ + activeSubGraph: string | null; + /** Active subgraph display name (falls back to slug). */ + subGraphDisplayName?: string | null; + /** Open detail's name, or null when no detail is open. */ + detailLabel?: string | null; +}): BreadcrumbHop[] { + const { contextGraphName, activeLayer, activeSubGraph, subGraphDisplayName, detailLabel } = input; + const hops: BreadcrumbHop[] = []; + + // First hop — Context Graph. It is the CURRENT page only when we are on + // the overview with no subgraph and no open detail. + const onOverview = !activeSubGraph && activeLayer === 'overview' && !detailLabel; + hops.push({ + key: 'cg', + label: contextGraphName, + title: contextGraphName, + target: onOverview ? 'current' : 'overview', + }); + if (onOverview) return hops; + + // Middle hop — subgraph displayName OR layer full-name (never both). + const detailOpen = !!detailLabel; + let middle: { label: string; title: string } | null = null; + if (activeSubGraph) { + const name = subGraphDisplayName?.trim() || activeSubGraph; + middle = { label: name, title: name }; + } else if (activeLayer === 'wm' || activeLayer === 'swm' || activeLayer === 'vm') { + const name = LAYER_FULL_NAME[activeLayer]; + middle = { label: name, title: name }; + } else if (activeLayer === 'graph-overview') { + middle = { label: 'Subgraphs', title: 'Subgraphs' }; + } else if (activeLayer === 'query') { + middle = { label: 'Query Catalogue', title: 'Query Catalogue' }; + } + + if (middle) { + hops.push({ + key: 'middle', + label: middle.label, + title: middle.title, + // When a detail is open the middle hop is clickable (restores the + // origin). When no detail is open the middle hop IS the current + // location. + target: detailOpen ? 'origin' : 'current', + }); + } + + // Trailing hop — the open detail's name (current location). + if (detailOpen) { + hops.push({ + key: 'detail', + label: detailLabel!, + title: detailLabel!, + target: 'current', + }); + } + + return hops; +} diff --git a/packages/node-ui/test/assertion-detail-helpers.test.ts b/packages/node-ui/test/assertion-detail-helpers.test.ts index 70b24d47d..d1405df32 100644 --- a/packages/node-ui/test/assertion-detail-helpers.test.ts +++ b/packages/node-ui/test/assertion-detail-helpers.test.ts @@ -4,6 +4,7 @@ import { canPromoteAssertion, assertionSubgraphLine, buildAssertionTrail, + buildBreadcrumbHops, } from '../src/ui/views/project/helpers.js'; // S4 — pure helpers behind the assertion detail view. These pin the @@ -95,3 +96,81 @@ describe('buildAssertionTrail — lifecycle trail stages + is-current marker', ( expect(stages.some(s => s.isCurrent)).toBe(false); }); }); + +describe('buildBreadcrumbHops — S5 breadcrumb hop construction (T04)', () => { + const CG = 'Hello World'; + + it('overview: a single non-interactive Context Graph hop', () => { + const hops = buildBreadcrumbHops({ contextGraphName: CG, activeLayer: 'overview', activeSubGraph: null }); + expect(hops).toHaveLength(1); + expect(hops[0]).toMatchObject({ label: CG, target: 'current' }); + }); + + it('on a layer page: Context Graph (link) › Layer full-name (current)', () => { + const hops = buildBreadcrumbHops({ contextGraphName: CG, activeLayer: 'wm', activeSubGraph: null }); + expect(hops.map(h => h.label)).toEqual([CG, 'Working Memory']); + expect(hops[0].target).toBe('overview'); // clickable to overview + expect(hops[1].target).toBe('current'); // current location + }); + + it('uses the full layer name for SWM / VM', () => { + expect(buildBreadcrumbHops({ contextGraphName: CG, activeLayer: 'swm', activeSubGraph: null })[1].label) + .toBe('Shared Working Memory'); + expect(buildBreadcrumbHops({ contextGraphName: CG, activeLayer: 'vm', activeSubGraph: null })[1].label) + .toBe('Verifiable Memory'); + }); + + it('on a subgraph page: the middle hop is the subgraph displayName, NOT the layer (never both)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, activeLayer: 'wm', + activeSubGraph: 'demo', subGraphDisplayName: 'Demo Subgraph', + }); + expect(hops.map(h => h.label)).toEqual([CG, 'Demo Subgraph']); + // The layer name must NOT appear when a subgraph is the middle hop. + expect(hops.some(h => h.label === 'Working Memory')).toBe(false); + }); + + it('with an open detail: Context Graph (link) › middle (link → origin) › detail name (current)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, activeLayer: 'wm', activeSubGraph: null, + detailLabel: 'Battery cell 003', + }); + expect(hops.map(h => h.label)).toEqual([CG, 'Working Memory', 'Battery cell 003']); + expect(hops[0].target).toBe('overview'); + expect(hops[1].target).toBe('origin'); // middle hop closes the detail + expect(hops[2].target).toBe('current'); // trailing = you are here + }); + + it('every hop carries a title for the unconditional tooltip', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, activeLayer: 'wm', activeSubGraph: 'demo', + subGraphDisplayName: 'Demo', detailLabel: 'X', + }); + expect(hops.every(h => typeof h.title === 'string' && h.title.length > 0)).toBe(true); + }); +}); + +describe('buildBreadcrumbHops — cross-subgraph update (T05)', () => { + // M2(b) makes a cross-subgraph entity jump switch activeSubGraph; S5's + // breadcrumb must reflect the NEW subgraph on the middle hop. This pins + // that the hop model is a pure function of the current subgraph — so + // when activeSubGraph changes, the rendered middle hop changes. + it('reflects the active subgraph on the middle hop', () => { + const before = buildBreadcrumbHops({ + contextGraphName: 'CG', activeLayer: 'wm', + activeSubGraph: 'demo', subGraphDisplayName: 'Demo', + detailLabel: 'Entity A', + }); + expect(before[1].label).toBe('Demo'); + + // Entity A linked to an entity in subgraph "other" → activeSubGraph + // follows (M2 option b); the breadcrumb middle hop updates. + const after = buildBreadcrumbHops({ + contextGraphName: 'CG', activeLayer: 'wm', + activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + }); + expect(after[1].label).toBe('Other'); + expect(after[2].label).toBe('Entity B'); + }); +}); diff --git a/packages/node-ui/test/breadcrumb.dom.test.ts b/packages/node-ui/test/breadcrumb.dom.test.ts new file mode 100644 index 000000000..03e73a5b7 --- /dev/null +++ b/packages/node-ui/test/breadcrumb.dom.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProjectHeaderStrip } from '../src/ui/views/project/components.js'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// S5 — DOM tests for the breadcrumb in ProjectHeaderStrip (T11 / T12) +// plus the T13 grep guard that the old back-affordance is gone. + +const profile = { + displayName: 'Hello World', + primaryColor: '#64748b', +} as any; + +const cg = { id: 'cg-test', name: 'Hello World', description: 'A demo graph.' }; + +function query(selector: string): HTMLElement { + const el = document.querySelector(selector); + if (!el) throw new Error(`Missing element ${selector}`); + return el; +} +function all(selector: string): HTMLElement[] { + return [...document.querySelectorAll(selector)]; +} + +describe('ProjectHeaderStrip breadcrumb', () => { + let root: Root; + + beforeEach(() => { + document.body.innerHTML = '
'; + root = createRoot(query('#root')); + }); + afterEach(async () => { + await act(async () => { root.unmount(); }); + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + async function render(props: Partial>) { + await act(async () => { + root.render(React.createElement(ProjectHeaderStrip, { + cg, profile, + activeLayer: 'overview', + activeSubGraph: null, + detailLabel: null, + onOverview: vi.fn(), + onRestoreOrigin: vi.fn(), + ...props, + } as any)); + }); + } + + it('renders Context Graph › Layer with the layer as the trailing current hop (T12)', async () => { + await render({ activeLayer: 'wm' }); + const hops = all('.v10-breadcrumb-hop'); + expect(hops.map(h => h.textContent)).toEqual(['Hello World', 'Working Memory']); + // First hop is a clickable link button; trailing hop is a span. + expect(hops[0].tagName).toBe('BUTTON'); + expect(hops[0].classList.contains('link')).toBe(true); + expect(hops[1].tagName).toBe('SPAN'); + expect(hops[1].classList.contains('current')).toBe(true); + expect(hops[1].getAttribute('aria-current')).toBe('page'); + }); + + it('non-trailing hops are clickable and fire the right handler (T12)', async () => { + const onOverview = vi.fn(); + const onRestoreOrigin = vi.fn(); + await render({ activeLayer: 'wm', detailLabel: 'Battery cell 003', onOverview, onRestoreOrigin }); + const hops = all('.v10-breadcrumb-hop'); + expect(hops.map(h => h.textContent)).toEqual(['Hello World', 'Working Memory', 'Battery cell 003']); + + // First hop → overview. + await act(async () => { hops[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + expect(onOverview).toHaveBeenCalledTimes(1); + + // Middle hop (link → origin restore). + expect(hops[1].tagName).toBe('BUTTON'); + await act(async () => { hops[1].dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + expect(onRestoreOrigin).toHaveBeenCalledTimes(1); + + // Trailing hop is a non-interactive span (no button). + expect(hops[2].tagName).toBe('SPAN'); + }); + + it('every hop carries an unconditional title tooltip (T11 overflow affordance)', async () => { + const longName = 'x'.repeat(200); + await render({ + activeLayer: 'wm', + activeSubGraph: { slug: 'demo', displayName: longName, color: '#38bdf8' } as any, + detailLabel: longName, + }); + const hops = all('.v10-breadcrumb-hop'); + expect(hops.every(h => (h.getAttribute('title') ?? '').length > 0)).toBe(true); + // The middle (subgraph) + trailing hops carry the truncation CSS + // (max-width + ellipsis); the title preserves the full text. + const middle = hops[1]; + expect(middle.getAttribute('title')).toBe(longName); + }); + + it('the subgraph displayName is the middle hop, never the layer name (T12 hop rule)', async () => { + await render({ + activeLayer: 'wm', + activeSubGraph: { slug: 'demo', displayName: 'Demo Subgraph', color: '#38bdf8' } as any, + }); + const labels = all('.v10-breadcrumb-hop').map(h => h.textContent); + expect(labels).toEqual(['Hello World', 'Demo Subgraph']); + expect(labels).not.toContain('Working Memory'); + }); + + it('reuses the existing .v10-project-strip-sep separator between hops', async () => { + await render({ activeLayer: 'wm', detailLabel: 'E' }); + // 3 hops → 2 separators. + expect(all('.v10-project-strip-sep')).toHaveLength(2); + }); +}); diff --git a/packages/node-ui/test/ka-detail-label.test.ts b/packages/node-ui/test/ka-detail-label.test.ts index 163cf7b27..4b7862f98 100644 --- a/packages/node-ui/test/ka-detail-label.test.ts +++ b/packages/node-ui/test/ka-detail-label.test.ts @@ -89,8 +89,7 @@ describe('KADetailView navigation label', () => { vi.clearAllMocks(); }); - it('renders Back to Context Graph and calls onClose', async () => { - const onClose = vi.fn(); + it('S5 — no back button (the breadcrumb is the sole back-affordance)', async () => { await act(async () => { root.render( React.createElement(ProjectProfileContext.Provider, { value: profile }, @@ -100,22 +99,16 @@ describe('KADetailView navigation label', () => { allEntities: new Map([[entity.uri, entity]]), allTriples: [], onNavigate: vi.fn(), - onClose, contextGraphId: 'cg-test', onRefresh: vi.fn(), }))), ); }); - const back = query('.v10-ka-back'); - expect(back.textContent).toContain('Back to Context Graph'); - expect(back.textContent).not.toContain('Back to Project'); - - await act(async () => { - back.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(onClose).toHaveBeenCalledTimes(1); + // The old in-detail "← Back to Context Graph" button is gone — S5 + // moved the back-affordance to the persistent breadcrumb. + expect(document.querySelector('.v10-ka-back')).toBeNull(); + expect(document.body.textContent).not.toContain('Back to Context Graph'); }); it('uses layer-aware nouns in the detail header', async () => { @@ -129,7 +122,6 @@ describe('KADetailView navigation label', () => { allEntities: new Map([[testEntity.uri, testEntity]]), allTriples: [], onNavigate: vi.fn(), - onClose: vi.fn(), contextGraphId: 'cg-test', onRefresh: vi.fn(), }))), diff --git a/packages/node-ui/test/project-view-navigation.test.ts b/packages/node-ui/test/project-view-navigation.test.ts index 69e39a254..9709b7e58 100644 --- a/packages/node-ui/test/project-view-navigation.test.ts +++ b/packages/node-ui/test/project-view-navigation.test.ts @@ -280,18 +280,36 @@ vi.mock('../src/ui/components/SubGraphBar.js', () => ({ })); vi.mock('../src/ui/views/project/components.js', () => ({ - ProjectHeaderStrip: ({ activeSubGraph }: { activeSubGraph: any }) => - React.createElement('div', { 'data-testid': 'active-subgraph' }, activeSubGraph?.slug ?? 'none'), + // S5 — the breadcrumb (in ProjectHeaderStrip) is now the sole back + // affordance: `detail-back` is wired to `onRestoreOrigin` (the + // breadcrumb middle-hop click = handleDetailClose) and + // `breadcrumb-overview` to the first-hop click. The detail views + // themselves no longer render a back button. + ProjectHeaderStrip: ({ activeLayer, activeSubGraph, detailLabel, onOverview, onRestoreOrigin }: { + activeLayer: string; + activeSubGraph: any; + detailLabel?: string | null; + onOverview: () => void; + onRestoreOrigin: () => void; + }) => + React.createElement('div', { 'data-testid': 'project-strip', 'data-layer': activeLayer, 'data-detail-label': detailLabel ?? '' }, + // Keep `active-subgraph` text == slug only (existing M2 tests + // assert its exact textContent); the breadcrumb buttons live in a + // sibling node so they don't pollute that assertion. + React.createElement('span', { 'data-testid': 'active-subgraph' }, activeSubGraph?.slug ?? 'none'), + React.createElement('button', { 'data-testid': 'detail-back', onClick: onRestoreOrigin }, 'breadcrumb restore'), + React.createElement('button', { 'data-testid': 'breadcrumb-overview', onClick: onOverview }, 'Context Graph')), LayerSwitcher: ({ active, onSwitch }: { active: string; onSwitch: (layer: string) => void }) => React.createElement('div', { 'data-testid': 'active-layer', 'data-layer': active }, React.createElement('button', { 'data-testid': 'switch-wm', onClick: () => onSwitch('wm') }, 'WM'), React.createElement('button', { 'data-testid': 'switch-swm', onClick: () => onSwitch('swm') }, 'SWM'), React.createElement('button', { 'data-testid': 'switch-subgraphs', onClick: () => onSwitch('graph-overview') }, 'Subgraphs')), - KADetailView: ({ entity, onNavigate, onClose }: { entity: any; onNavigate: (uri: string) => void; onClose: () => void }) => + // S5 — KADetailView no longer renders a back button (the breadcrumb + // is the sole back-affordance); the mock matches that shape. + KADetailView: ({ entity, onNavigate }: { entity: any; onNavigate: (uri: string) => void }) => React.createElement('section', { 'data-testid': 'entity-detail', 'data-entity': entity.uri, 'data-trust': entity.trustLevel, 'data-connections': String(entity.connections.length), 'data-subgraphs': [...entity.subGraphs].sort().join(',') }, React.createElement('div', {}, entity.label), - React.createElement('button', { 'data-testid': 'open-related-entity', onClick: () => onNavigate('urn:entity:other') }, 'Open related'), - React.createElement('button', { 'data-testid': 'detail-back', onClick: onClose }, 'Back to Context Graph')), + React.createElement('button', { 'data-testid': 'open-related-entity', onClick: () => onNavigate('urn:entity:other') }, 'Open related')), // S4 — assertion detail overlay. The mock surfaces the assertion // identity + an "open entity from assertion" button so the // mutually-exclusive overlay + close-to-origin behaviour can be @@ -1319,21 +1337,25 @@ describe('ProjectView entity detail navigation', () => { expect(query('active-layer').dataset.layer).toBe('wm'); }); - it('closing the assertion detail returns to the originating layer + Assertions subtab (T07)', async () => { + it('closing the assertion detail via the breadcrumb restores layer + Assertions subtab (T07 / T16)', async () => { await click('switch-wm'); await click('layer-tab-assertions'); await flush(); await click('open-layer-assertion'); await flush(); expect(query('assertion-detail').dataset.name).toBe('demo-assertion'); + // The breadcrumb trailing hop reflects the open assertion. + expect(query('project-strip').dataset.detailLabel).toBe('demo-assertion'); - // No back button on the assertion detail (S5 lock) — the layer - // switcher is the always-visible exit. Switching back to WM - // restores the layer page; the Assertions subtab persists. - await click('switch-wm'); + // S5 — no back button on the detail; the breadcrumb middle-hop + // (mock `detail-back` → onRestoreOrigin → handleDetailClose) closes + // it back to the originating layer + Assertions subtab (T16: the + // SAME handleDetailClose serves the assertion overlay). + await click('detail-back'); await flush(); expect(document.querySelector('[data-testid="assertion-detail"]')).toBeNull(); expect(query('layer-detail').dataset.layer).toBe('wm'); + expect(query('layer-detail').dataset.tab).toBe('assertions'); }); it('opening an entity from the assertion detail replaces it (mutually exclusive overlays)', async () => { diff --git a/packages/node-ui/test/s5-back-affordance-removed.test.ts b/packages/node-ui/test/s5-back-affordance-removed.test.ts new file mode 100644 index 000000000..10947b128 --- /dev/null +++ b/packages/node-ui/test/s5-back-affordance-removed.test.ts @@ -0,0 +1,48 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +// S5 (T13) — the in-detail "Back to Context Graph" affordance is gone; +// the persistent breadcrumb is the sole back-affordance. This is the +// grep guard from the handoff, run as a node-env source scan. +// +// Mirrors `grep -r "v10-ka-back\|Back to Context Graph" packages/node-ui/src` +// with one carve-out the handoff didn't anticipate: AgentProfileView's +// "← Back" button is an UNRELATED shell-level back (a sibling of the +// explicitly-protected PanelCenter "Back to Project") that legitimately +// reuses the `.v10-ka-back` class. So: +// - "Back to Context Graph" — must be ZERO across all of src. +// - `className="v10-ka-back"` — must be absent from the context-graph +// detail views (components.tsx); the class may still exist in +// AgentProfileView (out of scope) + styles.css (the class def). + +const here = fileURLToPath(new URL('.', import.meta.url)); +const SRC = resolve(here, '../src/ui'); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const full = resolve(dir, name); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (/\.(ts|tsx)$/.test(name)) out.push(full); + } + return out; +} + +describe('S5 back-affordance removal (T13)', () => { + it('no "Back to Context Graph" label remains anywhere in node-ui/src', () => { + const offenders = walk(SRC).filter(f => readFileSync(f, 'utf8').includes('Back to Context Graph')); + expect(offenders).toEqual([]); + }); + + it('the context-graph detail components render no .v10-ka-back button', () => { + const components = readFileSync(resolve(SRC, 'views/project/components.tsx'), 'utf8'); + expect(components).not.toMatch(/className="v10-ka-back"/); + }); + + it('the breadcrumb separator (v10-project-strip-sep) is still defined (reused, not dropped)', () => { + const css = readFileSync(resolve(SRC, 'styles.css'), 'utf8'); + expect(css).toMatch(/\.v10-project-strip-sep\s*\{/); + }); +}); From 9f3025ce5dc602a1eec3812e1cbfd23a191af78b Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 1 Jun 2026 23:29:17 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(node-ui):=20M2=20option=20(b)=20?= =?UTF-8?q?=E2=80=94=20silent=20cross-subgraph=20switch=20on=20link=20foll?= =?UTF-8?q?ow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable the cross-subgraph auto-switch alongside S5. When following a link to an entity that lives in a DIFFERENT sub-graph than the one in scope, handleNavigate switches activeSubGraph to the target's sub-graph via setActiveSubGraphSync (PR #793 Bug N) so the breadcrumb's React tree sees it synchronously. The breadcrumb now makes the move visible (Context Graph › › Entity), which is what made option (b) acceptable (the plan tied (b) to the breadcrumb existing). Ground-truth note: there was no option-(a) "suppression branch" to reverse — the prior handleNavigate did NO subgraph switching at all (option (a) was the ABSENCE of a switch). So this ADDS the follow logic. - primarySubGraphOf(entity): first non-meta subgraph slug (mirrors the SubGraphBadge rule); the follow decision input. - entitiesRef mirrors rawMemory.entities so handleNavigate resolves the target's subgraph synchronously without churning its (deliberately stable) callback identity. - The follow fires ONLY when already on a sub-graph page — opening an entity from a plain layer list does NOT spuriously jump into a sub-graph just because the entity belongs to one. - The M2 origin snapshot is still captured at first open, so closing returns to the ORIGINATING page, not the followed-into one (the origin-restore overwrites any mid-open switch). Tests: T14 (cross-subgraph follow switches activeSubGraph + breadcrumb reflects + close returns to origin), T15 (plain-layer open does not jump into a subgraph; M2 origin model intact), T16 (handleDetailClose serves both detail kinds — covered by the entity + assertion close tests), primarySubGraphOf unit truth table. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/node-ui/src/ui/views/ProjectView.tsx | 42 ++++++++++++++++--- .../node-ui/src/ui/views/project/helpers.ts | 15 +++++++ .../test/assertion-detail-helpers.test.ts | 27 ++++++++++++ .../test/project-view-navigation.test.ts | 37 ++++++++++++++-- 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/packages/node-ui/src/ui/views/ProjectView.tsx b/packages/node-ui/src/ui/views/ProjectView.tsx index ad3a11207..61d0f844a 100644 --- a/packages/node-ui/src/ui/views/ProjectView.tsx +++ b/packages/node-ui/src/ui/views/ProjectView.tsx @@ -11,6 +11,7 @@ import { useMemoryEntities, type LayeredTriple, type TrustLevel, + type MemoryEntity, } from '../hooks/useMemoryEntities.js'; import { useProjectProfile, ProjectProfileContext } from '../hooks/useProjectProfile.js'; import { useAgents, AgentsContext } from '../hooks/useAgents.js'; @@ -21,7 +22,7 @@ import { ActivityFeed } from '../components/ActivityFeed.js'; import { SubGraphBar } from '../components/SubGraphBar.js'; import { CONTEXT_GRAPH_PRIMER_TAB } from '../lib/contextGraphPrimer.js'; import { useTabsStore } from '../stores/tabs.js'; -import { shouldFetchSwmAttribution, type LayerView, type LayerContentTab, type SubGraphTab } from './project/helpers.js'; +import { shouldFetchSwmAttribution, primarySubGraphOf, type LayerView, type LayerContentTab, type SubGraphTab } from './project/helpers.js'; import { ProjectHeaderStrip, LayerSwitcher, @@ -224,6 +225,11 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // open without listing the state in its deps. const selectedAssertionRef = useRef(null); useEffect(() => { selectedAssertionRef.current = selectedAssertion; }, [selectedAssertion]); + // M2 option (b) — mirror the full entity map so `handleNavigate` can + // resolve a navigated entity's primary sub-graph synchronously + // (to decide whether to follow a cross-subgraph link) WITHOUT listing + // the map in its deps and churning the callback identity. + const entitiesRef = useRef>(new Map()); const [layerContentTabs, setLayerContentTabs] = useState>( DEFAULT_LAYER_TABS, ); @@ -391,6 +397,9 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // DashboardView caller, which already opts in for the same // failed-vs-empty-distinct reason. const rawMemory = useMemoryEntities(contextGraphId, { signalErrors: true }); + // M2 option (b) — keep the entity-map ref in sync so `handleNavigate` + // can resolve a navigated entity's sub-graph synchronously. + useEffect(() => { entitiesRef.current = rawMemory.entities; }, [rawMemory.entities]); // SWM attribution drives the SWM graph's agent-tint legend (its // sole remaining consumer). PR #694 review — the Overview no // longer reads this stream (lifecycle source replaced it), so the @@ -658,10 +667,6 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { setSubGraphTabs(prev => prev[slug] === tab ? prev : { ...prev, [slug]: tab }); }, []); - // M2 keeps the user's origin stable: linked entities open in the detail - // pane, but the underlying layer/sub-graph page does not silently change - // until S5 adds breadcrumbs that can make that movement visible. - // // Intent: a brand-new top-level open (no selected entity yet) resets // the layer context; in-detail navigation (a click inside an open // detail) keeps the prior layer context. We read both `selectedUri` @@ -669,11 +674,36 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // `prev` argument) so the callback identity stays stable — listing // them in deps would re-create `handleNavigate` on every navigation // and rebuild every downstream memo / callback that consumes it. + // + // M2 option (b) — silent cross-subgraph switch, ENABLED alongside S5. + // When following a link to an entity that lives in a DIFFERENT + // sub-graph than the one currently in scope, switch `activeSubGraph` + // to the target's sub-graph. Pre-S5 this was suppressed (option (a): + // no switch) because the move was invisible; now the breadcrumb makes + // it visible (`Context Graph › › Entity`), so the + // switch is acceptable. Routed through `setActiveSubGraphSync` (PR + // #793 Bug N) so the breadcrumb's React tree — and the ref-keyed + // discriminators — see the new subgraph synchronously. The M2 origin + // snapshot was captured at the FIRST open, so closing still returns to + // the originating page, not the followed-into one. + // + // Only fires when ALREADY in a sub-graph page (`activeSubGraphRef`): + // opening an entity from a plain layer list must NOT spuriously jump + // into a sub-graph just because the entity happens to belong to one. const handleNavigate = useCallback((uri: string, originScrollKey?: string, layerContext?: MemoryLayerView) => { const hadSelection = selectedUriRef.current != null; openEntityDetail(uri, originScrollKey); setSelectedLayerContext(prev => layerContext ?? (hadSelection ? prev : null)); - }, [openEntityDetail]); + const currentSubGraph = activeSubGraphRef.current; + if (currentSubGraph) { + const targetSubGraph = primarySubGraphOf( + entitiesRef.current.get(uri) ?? entitiesRef.current.get(canonicalEntityUri(uri)), + ); + if (targetSubGraph && targetSubGraph !== currentSubGraph) { + setActiveSubGraphSync(targetSubGraph); + } + } + }, [openEntityDetail, setActiveSubGraphSync]); const handleDetailClose = useCallback(() => { const origin = detailOriginRef.current; diff --git a/packages/node-ui/src/ui/views/project/helpers.ts b/packages/node-ui/src/ui/views/project/helpers.ts index 0d4c95d3a..2154839c5 100644 --- a/packages/node-ui/src/ui/views/project/helpers.ts +++ b/packages/node-ui/src/ui/views/project/helpers.ts @@ -1012,6 +1012,21 @@ export function buildAssertionTrail( })); } +/** + * The primary (first non-`meta`) sub-graph slug an entity has triples + * in, or null when it lives only in the root bucket / meta. Mirrors the + * `SubGraphBadge` rule (lowest-rank binding wins; most entities live in + * exactly one sub-graph). Used by M2 option (b) to decide whether a + * cross-subgraph entity jump should switch `activeSubGraph`. + */ +export function primarySubGraphOf(entity: MemoryEntity | undefined | null): string | null { + if (!entity) return null; + for (const s of entity.subGraphs) { + if (s !== 'meta') return s; + } + return null; +} + // ─── S5 Breadcrumb navigation ─────────────────────────────── // `Context Graph › {Layer | Subgraph} › {Entity | Assertion}`. Lives // inline in the persistent ProjectHeaderStrip. Hop content rules diff --git a/packages/node-ui/test/assertion-detail-helpers.test.ts b/packages/node-ui/test/assertion-detail-helpers.test.ts index d1405df32..4d0b43a70 100644 --- a/packages/node-ui/test/assertion-detail-helpers.test.ts +++ b/packages/node-ui/test/assertion-detail-helpers.test.ts @@ -5,7 +5,17 @@ import { assertionSubgraphLine, buildAssertionTrail, buildBreadcrumbHops, + primarySubGraphOf, } from '../src/ui/views/project/helpers.js'; +import type { MemoryEntity } from '../src/ui/hooks/useMemoryEntities.js'; + +function entity(subGraphs: string[]): MemoryEntity { + return { + uri: 'urn:e', label: 'E', types: [], trustLevel: 'working', + layers: new Set(['working']), subGraphs: new Set(subGraphs), + properties: new Map(), connections: [], + }; +} // S4 — pure helpers behind the assertion detail view. These pin the // lifecycle-trail tone mapping (T01), the Promote CTA visibility @@ -174,3 +184,20 @@ describe('buildBreadcrumbHops — cross-subgraph update (T05)', () => { expect(after[2].label).toBe('Entity B'); }); }); + +describe('primarySubGraphOf — M2(b) cross-subgraph follow decision (T14)', () => { + it('returns the first non-meta subgraph slug', () => { + expect(primarySubGraphOf(entity(['demo']))).toBe('demo'); + expect(primarySubGraphOf(entity(['meta', 'research']))).toBe('research'); + }); + + it('returns null for a root-only / meta-only entity', () => { + expect(primarySubGraphOf(entity([]))).toBeNull(); + expect(primarySubGraphOf(entity(['meta']))).toBeNull(); + }); + + it('returns null for a missing entity', () => { + expect(primarySubGraphOf(undefined)).toBeNull(); + expect(primarySubGraphOf(null)).toBeNull(); + }); +}); diff --git a/packages/node-ui/test/project-view-navigation.test.ts b/packages/node-ui/test/project-view-navigation.test.ts index 9709b7e58..cecd08f5d 100644 --- a/packages/node-ui/test/project-view-navigation.test.ts +++ b/packages/node-ui/test/project-view-navigation.test.ts @@ -684,28 +684,59 @@ describe('ProjectView entity detail navigation', () => { expect(query('overview-card').dataset.participantsStatus).toBe('loading'); }); - it('keeps the originating subgraph stable while following cross-subgraph entity links', async () => { + // M2 option (b) — ENABLED alongside S5. Following a link to an entity + // in a DIFFERENT sub-graph switches activeSubGraph (the breadcrumb + // makes the move visible); closing still returns to the ORIGINATING + // sub-graph page (the M2 origin captured at first open). (T14 / T15) + it('follows a cross-subgraph entity link → activeSubGraph switches; close returns to origin', async () => { await click('switch-subgraphs'); await click('select-subgraph-demo'); await click('subgraph-tab-graph'); expect(query('active-subgraph').textContent).toBe('demo'); expect(query('subgraph-detail').dataset.tab).toBe('graph'); + // Open an entity that lives in the CURRENT subgraph — no switch. await click('open-subgraph-entity'); expect(query('entity-detail').dataset.entity).toBe('urn:entity:demo'); expect(query('active-subgraph').textContent).toBe('demo'); + // Follow a link to urn:entity:other (subGraphs: {'other'}) — M2(b) + // switches activeSubGraph to 'other'; the breadcrumb reflects it via + // the active-subgraph mirror. await click('open-related-entity'); expect(query('entity-detail').dataset.entity).toBe('urn:entity:other'); - expect(query('active-subgraph').textContent).toBe('demo'); + expect(query('active-subgraph').textContent).toBe('other'); + // The breadcrumb's trailing hop tracks the followed entity. + expect(query('project-strip').dataset.detailLabel).toBe('Other entity'); + // Close — origin restore returns to the ORIGINATING subgraph (demo), + // not the followed-into one (other). (T15 — origin model intact.) await click('detail-back'); await flush(); - expect(query('subgraph-detail').dataset.slug).toBe('demo'); expect(query('subgraph-detail').dataset.tab).toBe('graph'); }); + // T15 — the M2 ORIGINAL behavior (no cross-subgraph involved) is + // untouched: open from a layer list → close → same layer / subtab / + // scroll, and opening from a plain layer does NOT spuriously jump into + // a subgraph just because the entity belongs to one. + it('opening an entity from a plain layer list does NOT switch into a subgraph (M2(b) guard)', async () => { + await click('switch-wm'); + // urn:entity:overlap has subGraphs {'demo'} but we open it from the + // WM layer list (no active subgraph) — must stay layer-scoped. + await click('open-layer-overlap-entity'); + await flush(); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:overlap'); + // No subgraph page was entered. + expect(query('active-subgraph').textContent).toBe('none'); + + await click('detail-back'); + await flush(); + expect(query('layer-detail').dataset.layer).toBe('wm'); + expect(document.querySelector('[data-testid="subgraph-detail"]')).toBeNull(); + }); + it('clears stale detail origin when the selected entity disappears', async () => { await click('switch-swm'); await click('open-layer-overlap-entity'); From b742f6f606d2f3b9e5f4f1e08b3b5c7813f470eb Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Tue, 2 Jun 2026 09:08:02 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(node-ui):=20round-3=20Codex=20=E2=80=94?= =?UTF-8?q?=20SWM=20assertionGraph=20guard=20+=20VM/discarded=20empty-stat?= =?UTF-8?q?e=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review fixes on the S4 assertion detail view (kept as a focused 4th commit rather than folded into C1 — the fixup's helpers.ts hunk overlaps C2's breadcrumb helpers + C3's primarySubGraphOf, so an autosquash into C1 conflicts mechanically; a standalone commit is the clean, conflict-free shape team-lead OK'd). - Finding 2 (api.ts fetchAssertionState): the assertionGraph fallback was URI-shape-unsafe. For an SWM input (a `urn:dkg:assertion:…` lifecycle URN) whose `dkg:assertionGraph` did NOT resolve (legacy/partial `_meta` row), echoing the URN made fetchAssertionTriples query `GRAPH ` (a graph that never holds triples) → bogus render. Now: urn-input + unresolved → assertionGraph: undefined (Triples/Entities fall to their empty-state). WM data-graph-URI input still echoes itself. - Finding 3 (assertionEmptyStateCopy, ux §4.7.1 locked): the empty-state copy now keys off `dkg:state` (4 branches) instead of special-casing promoted only — published/finalized show the VM / Knowledge-Assets line ("entities → Knowledge Assets" at the VM boundary, §4.8), discarded is terminal, created/promoted unchanged. Plain text, no links. - Finding 1 (remote SWM state) reply-not-valid (no replicated state source); finding-1 round-2 discarded-neutral badge + finding-2 round-2 literal-display decode already shipped earlier in C1. Tests: SWM-legacy-no-assertionGraph guard + WM-echo counterpart (use-assertion-state); assertionEmptyStateCopy 4-branch truth table (assertion-detail-helpers); published empty-state DOM render (assertion-detail-view). Finding-2 guard negative-proofed. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/node-ui/src/ui/api.ts | 70 +++- .../node-ui/src/ui/hooks/useMemoryEntities.ts | 13 +- packages/node-ui/src/ui/views/ProjectView.tsx | 77 +++- .../src/ui/views/project/components.tsx | 348 ++++++++++++------ .../node-ui/src/ui/views/project/helpers.ts | 131 ++++++- .../test/assertion-detail-helpers.test.ts | 158 +++++++- .../test/assertion-detail-view.dom.test.ts | 241 +++++++++++- packages/node-ui/test/breadcrumb.dom.test.ts | 157 ++++++++ ...ontext-graph-empty-stat-components.test.ts | 4 +- .../test/project-view-navigation.test.ts | 176 ++++++++- .../node-ui/test/use-assertion-state.test.ts | 87 +++++ 11 files changed, 1298 insertions(+), 164 deletions(-) diff --git a/packages/node-ui/src/ui/api.ts b/packages/node-ui/src/ui/api.ts index 3ea089a0c..073af0552 100644 --- a/packages/node-ui/src/ui/api.ts +++ b/packages/node-ui/src/ui/api.ts @@ -841,10 +841,22 @@ export async function fetchAssertionState( const metaGraph = `did:dkg:context-graph:${contextGraphId}/_meta`; // UNION admits both `graphUri` shapes (WM data-graph URI via the // inverse `dkg:assertionGraph` link; SWM lifecycle URN directly). + // + // Codex round-4 — branch A binds the input AS `?lifecycle` + // UNCONDITIONALLY and resolves `dkg:assertionGraph` only OPTIONALLY, so + // a legacy/partial SWM row whose lifecycle URN carries `dkg:state` but + // NOT `dkg:assertionGraph` still resolves its state (pre-fix, branch A + // required the assertionGraph triple to bind `?lifecycle`, so such a + // row read no state → "state unavailable" though the state exists). + // The outer `?lifecycle dkg:state` then gates the row: for a WM + // data-graph-URI input, branch A binds `?lifecycle = ` + // but ` dkg:state` never matches (state lives on the + // lifecycle URN), so branch A contributes nothing and branch B handles + // WM exactly as before. So WM→B, SWM→A, SWM-no-assertionGraph→A. const sparql = `SELECT ?state ?layer ?createdBy ?assertionGraph WHERE { GRAPH <${metaGraph}> { - { <${graphUri}> <${DKG}assertionGraph> ?assertionGraph . - BIND(<${graphUri}> AS ?lifecycle) } + { BIND(<${graphUri}> AS ?lifecycle) + OPTIONAL { <${graphUri}> <${DKG}assertionGraph> ?assertionGraph } } UNION { ?lifecycle <${DKG}assertionGraph> <${graphUri}> . BIND(<${graphUri}> AS ?assertionGraph) } @@ -871,13 +883,23 @@ export async function fetchAssertionState( state === 'created' ? 'wm' : state === 'promoted' ? 'swm' : 'vm'; + // The data graph to read triples from. Prefer the resolved + // `dkg:assertionGraph`. Codex round-3 — the fallback to `graphUri` + // itself is only safe when the INPUT is already a data-graph URI (the + // WM shape). For an SWM input (a `urn:dkg:assertion:…` lifecycle URN) + // whose `dkg:assertionGraph` did NOT resolve (legacy/partial `_meta` + // row), echoing the URN would make `fetchAssertionTriples` query + // `GRAPH ` — a graph that never holds triples — + // and render a bogus "data" set. In that case return `undefined` so + // the Triples/Entities panes fall to their empty-state instead. + const resolvedAssertionGraph = bv(first.assertionGraph); + const inputIsLifecycleUrn = graphUri.startsWith('urn:dkg:assertion:'); + const assertionGraph = resolvedAssertionGraph + ?? (inputIsLifecycleUrn ? undefined : graphUri); return { state, layer, - // The data graph to read triples from: the resolved - // `dkg:assertionGraph` (correct for the SWM lifecycle-URN input), - // falling back to the input itself (the WM data-graph URI input). - assertionGraph: bv(first.assertionGraph) ?? graphUri, + assertionGraph, createdBy: bv(first.createdBy), }; } @@ -945,14 +967,38 @@ export async function fetchAssertionTriples( * N-Triples string form is already fully encoded by the daemon, so it * passes through verbatim. */ +/** + * Escape a literal body for the N-Triples `"…"` form: backslash + quote, + * the canonical short escapes for tab/newline/carriage-return, and any + * other C0 control char as `\\uXXXX`. Order matters — backslash first so + * the escapes we add aren't re-escaped. + */ +function escapeNTriplesLiteral(value: string): string { + let out = ""; + for (const ch of value) { + const code = ch.codePointAt(0)!; + if (ch === '\\') out += '\\\\'; + else if (ch === '"') out += '\\"'; + else if (code === 0x09) out += '\\t'; + else if (code === 0x0a) out += '\\n'; + else if (code === 0x0d) out += '\\r'; + else if (code < 0x20) out += '\\u' + code.toString(16).padStart(4, '0').toUpperCase(); + else out += ch; + } + return out; +} + function rawBindingValue(v: unknown): string | undefined { if (v == null) return undefined; if (typeof v === 'object' && 'value' in (v as any)) { const node = v as any; if (node.type === 'literal' || node.type === 'typed-literal') { - // Escape per N-Triples so embedded quotes/backslashes don't break - // the leading-`"` classification or the renderers. - const escaped = String(node.value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + // Escape per N-Triples so embedded quotes/backslashes/control chars + // don't break the leading-`"` classification or the renderers. + // Codex round-6 — control chars (raw \n/\r/\t in a multiline + // extracted value, plus other C0 controls) were previously left + // raw → invalid N-Triples → broke the graph/triple parsers. + const escaped = escapeNTriplesLiteral(String(node.value)); // Standard SPARQL JSON carries the datatype on `.datatype` and the // language on `.xml:lang` (some serialisers use `.language`). const lang = node['xml:lang'] ?? node.language; @@ -961,6 +1007,12 @@ function rawBindingValue(v: unknown): string | undefined { if (datatype) return `"${escaped}"^^<${datatype}>`; return `"${escaped}"`; } + // Codex round-5 — a blank node must come back as `_:`, not the + // bare identifier; otherwise downstream (buildMemoryEntities + the + // graph) misclassify it (a bare id is neither a leading-`"` literal + // nor a recognisable resource → bnode RDF structure disappears / + // mislabels). Branch BEFORE the generic fallthrough. + if (node.type === 'bnode') return `_:${String(node.value)}`; return String(node.value); } if (typeof v === 'string') return v; diff --git a/packages/node-ui/src/ui/hooks/useMemoryEntities.ts b/packages/node-ui/src/ui/hooks/useMemoryEntities.ts index 5b5ea5ffc..89baf5ad5 100644 --- a/packages/node-ui/src/ui/hooks/useMemoryEntities.ts +++ b/packages/node-ui/src/ui/hooks/useMemoryEntities.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { postQueryDeduped } from '../api.js'; import { useMemoryGraphEvents } from './useNodeEvents.js'; import { MEMORY_LABEL_PREDICATES } from '../lib/memoryLabels.js'; +import { decodeRdfStringLiteral } from '../../rdf-literal.js'; export type TrustLevel = 'working' | 'shared' | 'verified'; export type MemoryLayerKey = 'wm' | 'swm' | 'vm'; @@ -107,7 +108,11 @@ export function canonicalEntityUri(uri: string): string { function shortLabel(uri: string): string { if (!uri) return '—'; - if (uri.startsWith('"')) return uri.replace(/^"|"$/g, ''); + // Codex round-6 — decode RDF literals (drop the `^^`/`@lang` + // suffix + unescape the body) instead of a bare outer-quote strip, so + // `"Hola"@es` renders `Hola`, not `Hola"@es`. Idempotent for a + // suffix-free `"Hola"` and a no-op for non-literal IRIs. + if (uri.startsWith('"')) return decodeRdfStringLiteral(uri); const hash = uri.lastIndexOf('#'); const slash = uri.lastIndexOf('/'); const cut = Math.max(hash, slash); @@ -487,7 +492,11 @@ export function buildEntities(layered: LayeredTriple[]): Map(null); + // Codex round-11 (11-1) — the layer the selected assertion's list row came + // from ('wm' | 'swm'). Seeds AssertionDetailView's badge + graph tone so it + // shows the KNOWN-true layer during the state hydrate instead of inventing + // 'wm'. Set together with `selectedAssertion` in `openAssertionDetail`. + const [selectedAssertionLayer, setSelectedAssertionLayer] = useState<'wm' | 'swm'>('wm'); const [participantsState, setParticipantsState] = useState({ contextGraphId: null, list: [], @@ -321,22 +326,35 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // snapshot (when none is pending) so the breadcrumb / close restores // the originating layer + Assertions subtab + scroll, then clears any // open entity detail (the two detail overlays are mutually exclusive). - const openAssertionDetail = useCallback((assertion: AssertionInfo) => { + const openAssertionDetail = useCallback((assertion: AssertionInfo, sourceLayer: 'wm' | 'swm') => { if (!detailOriginRef.current) { detailOriginRef.current = captureDetailOrigin(); } setSelectedUri(null); setSelectedLayerContext(null); + // Codex round-11 (11-1) — capture the source layer the list row came + // from so AssertionDetailView seeds its badge/tone immediately (no + // `?? 'wm'` invention during the state hydrate). The list is the + // authoritative layer source: a detail only opens from a WM/SWM list. + setSelectedAssertionLayer(sourceLayer); setSelectedAssertion(assertion); }, [captureDetailOrigin]); + // Consumes a pending scroll restore once the detail overlay has + // actually closed. S4 — the close path is shared by the entity AND + // the assertion overlay (`handleDetailClose` queues `origin.scroll` + // for both), so the guard must wait for BOTH to clear and the deps + // must include `selectedAssertion`. Without it, closing an assertion + // back to the same layer it opened from changes none of the other + // listed deps, the effect never re-fires, and the queued scroll is + // never restored — an M2-parity gap vs the entity-close path (7-1). useEffect(() => { - if (selectedUri) return; + if (selectedUri || selectedAssertion) return; const scroll = pendingScrollRestoreRef.current; if (!scroll) return; pendingScrollRestoreRef.current = null; restoreScroll(scroll); - }, [selectedUri, activeLayer, activeSubGraph, layerContentTabs, subGraphTabs, restoreScroll]); + }, [selectedUri, selectedAssertion, activeLayer, activeSubGraph, layerContentTabs, subGraphTabs, restoreScroll]); // Cross-tab entity open — e.g. the agent profile page in another tab // fires a CustomEvent("v10:open-entity", { contextGraphId, entityUri }) @@ -696,11 +714,20 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { setSelectedLayerContext(prev => layerContext ?? (hadSelection ? prev : null)); const currentSubGraph = activeSubGraphRef.current; if (currentSubGraph) { - const targetSubGraph = primarySubGraphOf( - entitiesRef.current.get(uri) ?? entitiesRef.current.get(canonicalEntityUri(uri)), - ); - if (targetSubGraph && targetSubGraph !== currentSubGraph) { - setActiveSubGraphSync(targetSubGraph); + const entity = entitiesRef.current.get(uri) ?? entitiesRef.current.get(canonicalEntityUri(uri)); + // Codex round-8 (8-3) — prefer the CURRENT subgraph for a multi-homed + // entity. M2(b) switches to `primarySubGraphOf` (first non-meta), + // which is arbitrary when the entity lives in several subgraphs. If + // the entity is ALREADY in the current subgraph, stay — don't jump to + // an arbitrary sibling. Only switch when the entity isn't in the + // current subgraph at all; then `primarySubGraphOf` is the must-move + // fallback (M2(b)'s reason for existing: un-strand an entity that + // doesn't belong to the page you're on). `subGraphs` is a Set. + if (!entity?.subGraphs.has(currentSubGraph)) { + const targetSubGraph = primarySubGraphOf(entity); + if (targetSubGraph && targetSubGraph !== currentSubGraph) { + setActiveSubGraphSync(targetSubGraph); + } } } }, [openEntityDetail, setActiveSubGraphSync]); @@ -788,6 +815,29 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { ? selectedAssertion.name : null; + // Codex round-8 (8-2) — when a detail is open the breadcrumb's middle + // hop must label the M2 ORIGIN (where the middle-hop click returns you), + // not the current location. After an M2(b) cross-subgraph follow the + // active subgraph diverges from the origin, so the current-scope label + // would name the followed-into subgraph while clicking returns to the + // origin. Read the origin snapshot (captured at detail-open) and resolve + // its subgraph display name via the profile. Only meaningful when a + // detail is open — `breadcrumbDetailLabel` gates the read, and the + // detail-open selection state drives the render, so the ref is populated + // by the time we read it here. + const breadcrumbOrigin = breadcrumbDetailLabel ? detailOriginRef.current : null; + const breadcrumbOriginSubGraph = breadcrumbOrigin?.activeSubGraph ?? null; + // Codex round-10 (10-1) — `forSubGraph` returns undefined for a subgraph + // with NO profile binding, so `.displayName` would throw before the + // breadcrumb renders (opening a detail from an unprofiled subgraph would + // crash). Optional-chain and degrade to the slug — the SAME fallback the + // no-origin-subgraph case uses. (The 9-2 chrome accesses in + // ProjectHeaderStrip already optional-chain `forSubGraph`; this is the one + // un-guarded access, which resolves only the breadcrumb label.) + const breadcrumbOriginSubGraphName = breadcrumbOriginSubGraph + ? profile.forSubGraph(breadcrumbOriginSubGraph)?.displayName ?? breadcrumbOriginSubGraph + : null; + // Codex Bug C — gate the `entities`-driven chip-count path on a // fully-loaded memory snapshot. While `useMemoryEntities` is mid- // hydration or a layer query is in-flight, `entityList` is partial @@ -828,6 +878,9 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { activeLayer={activeLayer} activeSubGraph={activeSubGraphBinding} detailLabel={breadcrumbDetailLabel} + originLayer={breadcrumbOrigin?.activeLayer ?? null} + originSubGraph={breadcrumbOriginSubGraph} + originSubGraphDisplayName={breadcrumbOriginSubGraphName} onOverview={handleBreadcrumbOverview} onRestoreOrigin={handleDetailClose} /> @@ -864,8 +917,14 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { {selectedAssertion && !selectedEntity && ( handleNavigate(uri, undefined, layer)} onComplete={rawMemory.refresh} onOpenAgent={openAgent} /> diff --git a/packages/node-ui/src/ui/views/project/components.tsx b/packages/node-ui/src/ui/views/project/components.tsx index 495a2ed96..1a68cf022 100644 --- a/packages/node-ui/src/ui/views/project/components.tsx +++ b/packages/node-ui/src/ui/views/project/components.tsx @@ -67,7 +67,7 @@ import { filterTriplesToEntities, admitTripleForScope, entityTimestamp, formatRelativeTime, formatTimelineBucket, formatTrailTimestamp, canPromoteAssertion, assertionSubgraphLine, buildAssertionTrail, - buildBreadcrumbHops, + assertionEmptyStateCopy, buildBreadcrumbHops, type LayerView, type LayerContentTab, type KAPane, type SubGraphTab, type SubGraphEntitySort, } from './helpers.js'; @@ -521,6 +521,9 @@ export function ProjectHeaderStrip({ activeLayer, activeSubGraph, detailLabel, + originLayer, + originSubGraph, + originSubGraphDisplayName, onOverview, onRestoreOrigin, }: { @@ -531,6 +534,13 @@ export function ProjectHeaderStrip({ activeSubGraph: ReturnType['forSubGraph'] extends (s: string) => infer R ? R | null : null; /** Open detail's name (entity / assertion), or null when none is open. */ detailLabel?: string | null; + /** Codex round-8 (8-2) — the M2 ORIGIN's layer/subgraph (from the + * detail-origin snapshot). When a detail is open the middle hop labels + * the origin, not the current location (after an M2(b) follow they + * diverge). Null/undefined when no detail is open. */ + originLayer?: LayerView | null; + originSubGraph?: string | null; + originSubGraphDisplayName?: string | null; /** Navigate to the Context Graph overview (first-hop click). */ onOverview: () => void; /** Restore the M2 origin — close the open detail back to where it was @@ -547,14 +557,38 @@ export function ProjectHeaderStrip({ activeSubGraph: activeSubGraph?.slug ?? null, subGraphDisplayName: activeSubGraph?.displayName ?? activeSubGraph?.slug ?? null, detailLabel, + originLayer, + originSubGraph, + originSubGraphDisplayName, }); - // Subgraph context still tints the strip + surfaces the description. - const description = activeSubGraph?.description ?? cg.description; + // Codex round-9 (9-2) — the strip chrome (tint + description) must track + // the SAME origin the breadcrumb label does while a detail is open. The + // 8-2 fix made the middle hop label the origin; pre-9-2 the chrome still + // read CURRENT activeSubGraph, so after an M2(b) follow the tint / + // description named the followed-into subgraph while the breadcrumb said + // origin. Drive both from ONE predicate (`detailOpen`, the same gate the + // breadcrumb label uses) so they can't re-diverge. Resolve the origin + // subgraph OBJECT from its slug via `profile.forSubGraph` (don't thread + // more scalar props). Degradation (ux-confirmed): origin had no subgraph + // (overview / non-subgraph layer) → fall back to profile.primaryColor + + // cg.description (today's behaviour); no detail open → current + // activeSubGraph; no-cross-follow → origin == current → no-op. + const detailOpen = !!detailLabel; + // When a detail is open, chrome tracks the ORIGIN: the origin subgraph + // binding if the origin had one, otherwise NO subgraph (→ the fallback + // below uses profile.primaryColor + cg.description). Critically, an + // overview / non-subgraph origin must NOT fall back to the CURRENT + // (followed-into) subgraph — that's the very divergence 9-2 fixes. When + // no detail is open, chrome derives from the current activeSubGraph. + const chromeSubGraph = detailOpen + ? (originSubGraph ? profile.forSubGraph(originSubGraph) ?? null : null) + : activeSubGraph; + const description = chromeSubGraph?.description ?? cg.description; return (
void; onSelectEntity: (uri: string) => void; - /** S4 — open an assertion's detail view from the Assertions subtab. */ - onSelectAssertion?: (assertion: AssertionInfo) => void; + /** S4 — open an assertion's detail view from the Assertions subtab. + * Codex round-11 (11-1) — carries the source layer through to the + * detail view (seeds badge/tone without inventing 'wm'). */ + onSelectAssertion?: (assertion: AssertionInfo, sourceLayer: 'wm' | 'swm') => void; onNodeClick?: (node: any) => void; footer?: React.ReactNode; /** Codex Code6 (PR #656) — optional shared SWM attribution result @@ -3080,8 +3116,12 @@ export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey, o scrollKey?: string; /** S4 — open the assertion detail view for a clicked row. Optional so * call sites that don't wire detail navigation (none today) degrade - * to a non-clickable list. */ - onSelectAssertion?: (assertion: AssertionInfo) => void; + * to a non-clickable list. + * Codex round-11 (11-1) — forwards THIS list's `layer` as the source + * layer so the detail view can seed its badge + graph tone immediately + * (no `?? 'wm'` invention during hydrate). The list is the authoritative + * source of the layer: an assertion only ever opens from a WM/SWM list. */ + onSelectAssertion?: (assertion: AssertionInfo, sourceLayer: 'wm' | 'swm') => void; }) { const { data: assertions, loading, refresh } = useFetch( () => listAssertions(contextGraphId, layer), @@ -3153,7 +3193,7 @@ export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey, o // propagation so promoting doesn't also navigate. role={onSelectAssertion ? 'button' : undefined} tabIndex={onSelectAssertion ? 0 : undefined} - onClick={onSelectAssertion ? () => onSelectAssertion(a) : undefined} + onClick={onSelectAssertion ? () => onSelectAssertion(a, layer) : undefined} onKeyDown={onSelectAssertion ? ev => { // Codex round-1 — only the ROW itself activates on @@ -3163,7 +3203,7 @@ export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey, o if (ev.target !== ev.currentTarget) return; if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); - onSelectAssertion(a); + onSelectAssertion(a, layer); } } : undefined} @@ -3214,8 +3254,10 @@ export function LayerDetailView({ memory: ReturnType; onNodeClick: (node: any) => void; onSelectEntity: (uri: string) => void; - /** S4 — open an assertion's detail view from the Assertions subtab. */ - onSelectAssertion?: (assertion: AssertionInfo) => void; + /** S4 — open an assertion's detail view from the Assertions subtab. + * Codex round-11 (11-1) — carries the source layer (the list's layer) + * so the detail view seeds its badge/tone without inventing 'wm'. */ + onSelectAssertion?: (assertion: AssertionInfo, sourceLayer: 'wm' | 'swm') => void; contextGraphId: string; activeTab: LayerContentTab; onTabChange: (tab: LayerContentTab) => void; @@ -4075,15 +4117,28 @@ function AssertionLifecycleTrail({ export function AssertionDetailView({ assertion, + sourceLayer, contextGraphId, onNavigate, onComplete, onOpenAgent, }: { assertion: AssertionInfo; + /** Codex round-11 (11-1) — the layer the assertion's list row came from + * ('wm' | 'swm'). Seeds the badge + graph tone immediately so the header + * shows the KNOWN-true layer during the state hydrate, instead of the old + * `?? 'wm'` invention that mis-tinted SWM assertions and contradicted the + * "state unavailable" error. `fetchAssertionState.layer` refines it once + * resolved (e.g. created→promoted). */ + sourceLayer: 'wm' | 'swm'; contextGraphId: string; - /** Open an entity from this assertion in the entity-detail view. */ - onNavigate: (uri: string) => void; + /** Open an entity from this assertion in the entity-detail view. + * Codex round-5 — forwards the assertion's resolved `layer` so the + * follow-on entity detail stays scoped to the assertion's layer + * (a WM-assertion entity that ALSO exists in SWM/VM would otherwise + * open the global/canonical detail). Feeds M2's existing + * `handleNavigate(uri, _, layerContext)` channel; no contract reshape. */ + onNavigate: (uri: string, layer?: 'wm' | 'swm' | 'vm') => void; /** Refresh the underlying memory after a successful promote. */ onComplete: () => void; onOpenAgent?: (uri: string) => void; @@ -4108,11 +4163,14 @@ export function AssertionDetailView({ // assertion's data-graph URI + author DID alongside the state. const { data: stateInfo, loading: stateLoading, error: stateError } = useAssertionState(contextGraphId, assertion.graphUri, refreshNonce); - // While hydrating, the layer is unknown; once resolved we trust the - // `_meta` layer. A `created` WM assertion is the shippable scope; a - // promoted one resolves to `swm` (its data graph is empty — see - // `fetchAssertionTriples`). - const layer = stateInfo?.layer ?? 'wm'; + // Codex round-11 (11-1) — seed the layer from the KNOWN-true `sourceLayer` + // (the list row's layer) during the hydrate, and refine to the resolved + // `_meta` layer once it arrives (e.g. a `created` WM assertion the user + // just promoted resolves to `swm`). The old `?? 'wm'` invention is DELETED: + // it mis-tinted SWM assertions as Working during load and contradicted the + // "state unavailable" error. `stateInfo?.layer` wins when present; else the + // real source layer — never an invented default. + const layer = stateInfo?.layer ?? sourceLayer; const layerConfig = LAYER_CONFIG[layer]; const trustLevel: TrustLevel = LAYER_CONFIG[layer].trustLevel; @@ -4138,6 +4196,12 @@ export function AssertionDetailView({ const assertionGraph = stateInfo?.assertionGraph; const [triples, setTriples] = useState([]); const [triplesLoading, setTriplesLoading] = useState(false); + // Codex round-6 — a triple-FETCH failure must be DISTINCT from a + // genuinely-empty assertion (loading / error / empty are three states, + // not two — mirrors the lifecycle state's hydrating/error edge). + // Previously the `.catch` collapsed to `[]`, so a backend/query error + // showed the same empty-state copy as valid-but-empty data. + const [triplesError, setTriplesError] = useState(false); useEffect(() => { if (!assertionGraph) { // No data graph to read (promoted assertion — its triples moved to @@ -4150,13 +4214,15 @@ export function AssertionDetailView({ // renders. setTriples([]); setTriplesLoading(false); + setTriplesError(false); return; } let cancelled = false; setTriplesLoading(true); + setTriplesError(false); fetchAssertionTriples(contextGraphId, assertionGraph) - .then(rows => { if (!cancelled) setTriples(rows); }) - .catch(() => { if (!cancelled) setTriples([]); }) + .then(rows => { if (!cancelled) { setTriples(rows); setTriplesError(false); } }) + .catch(() => { if (!cancelled) { setTriples([]); setTriplesError(true); } }) .finally(() => { if (!cancelled) setTriplesLoading(false); }); return () => { cancelled = true; }; // `refreshNonce` re-runs the triples fetch after a promote (the data @@ -4177,6 +4243,26 @@ export function AssertionDetailView({ const entityCount = rootEntities.length; const tripleCount = triples.length; + // Codex round-11 (11-2) — header counts follow the hydrating contract: + // show KNOWN-true values immediately, `…` for the genuinely-unknown, NEVER + // a 0-until-resolved that reads as a false "this assertion is empty" claim. + // Counts are TRUSTWORTHY only once the triples fetch has settled cleanly + // (state resolved, not loading, not errored). `triplesError` keeps the + // placeholder — an operational failure must not surface as `0`. + const countsResolved = !stateLoading && !triplesLoading && !triplesError; + // entityCount is NOT carried on the row (genuinely unknown until the + // fetch) → placeholder during load/error; the real (possibly-zero) count + // only once resolved. + const entityCountLabel = countsResolved ? `${entityCount}` : '…'; + // tripleCount MAY be seeded from the row (`AssertionInfo.tripleCount`): + // prefer the resolved fetch count; else the row hint if present; else `…`. + // Never `0` before the fetch settles. + const tripleCountLabel = countsResolved + ? `${tripleCount}` + : assertion.tripleCount != null + ? `${assertion.tripleCount}` + : '…'; + const author = stateInfo?.createdBy ? agents?.get(stateInfo.createdBy) ?? null : null; const authorUri = stateInfo?.createdBy ?? null; const subgraphLine = assertionSubgraphLine(assertion.subGraph); @@ -4220,7 +4306,7 @@ export function AssertionDetailView({ ▤ {assertion.name}
- assertion · {entityCount} entities · {tripleCount} triples + assertion · {entityCountLabel} entities · {tripleCountLabel} triples
{subgraphLine &&
{subgraphLine}
}
@@ -4261,99 +4347,141 @@ export function AssertionDetailView({
- {pane === 'content' && ( + {/* Codex round-9 (9-1) — the triples loading / error treatment is + SHARED across all three tabs. Pre-fix only the Entities pane + gated on triplesLoading/triplesError; the Triples tab rendered a + "0 of 0" table and the Graph tab fell through to "No assertion + graph data" while the fetch was pending OR after it errored, + misreporting unknown/error as real empty content. Hoist the gate: + while loading (state still hydrating OR triples in-flight with + nothing yet to show) render the loading treatment in whichever + tab is active; on a fetch error render the error state; only once + resolved does each tab render its real content. Keyed on + `triples.length === 0` (not just the loading flag) so a + post-promote REFETCH that still holds the prior rows doesn't + blank existing content across the tabs. The Entities pane keeps + its own state-keyed EMPTY-state (8-1) inside the resolved branch + — empty is distinct from loading/error. */} + {(stateLoading || triplesLoading) && triples.length === 0 ? (
- {triplesLoading && rootEntities.length === 0 ? ( -
Loading assertion entities...
- ) : rootEntities.length === 0 ? ( - - ) : ( - rootEntities.map(e => { - const { icon, type } = entityMeta(e, profile); - return ( - - ); - }) - )} +
Loading assertion entities...
- )} - - {pane === 'triples' && ( - // Plain s/p/o table, assertion-scoped — NO filter pills - // (an assertion has singular scope, so cross-layer / - // cross-subgraph pills would have nothing to filter). -
- - - - - - {triples.slice(0, 200).map((t, i) => ( - - - - {/* Codex round-2: literal objects from - `fetchAssertionTriples`/`rawBindingValue` carry the - FULL N-Triples form (`"v"^^` / `"v"@lang`, - escaped) — required by the Graph tab's RdfGraph + - buildMemoryEntities. For DISPLAY, decode through the - shared `decodeRdfStringLiteral` (the same helper - `humanizeLabel` uses) so the datatype/lang suffix is - dropped and the body is unescaped — NOT a naive - outer-quote strip, which left `"42"^^` → - `42"^^`. IRIs render via `shortPred`. */} - - - ))} - -
SubjectPredicateObject
{shortPred(t.subject)}{shortPred(t.predicate)}{t.object.startsWith('"') ? decodeRdfStringLiteral(t.object).slice(0, 60) : shortPred(t.object)}
-
- {Math.min(triples.length, 200)} of {triples.length} triples shown -
+ ) : triplesError ? ( + // A fetch ERROR is DISTINCT from an empty assertion: an + // operational failure must not masquerade as valid-but-empty + // data. Mirrors the lifecycle state's error edge (quiet, + // explicit "couldn't load") — now consistent across all 3 tabs. +
+
- )} + ) : ( + <> + {pane === 'content' && ( +
+ {rootEntities.length === 0 ? ( + (() => { + // ux §4.7.1 locked copy — keyed off the lifecycle state + // so the noun + forward path match the destination layer + // (created / promoted→SWM / published|finalized→VM / + // discarded). See `assertionEmptyStateCopy`. + const copy = assertionEmptyStateCopy(stateInfo?.state); + return ( + + ); + })() + ) : ( + rootEntities.map(e => { + const { icon, type } = entityMeta(e, profile); + return ( + + ); + }) + )} +
+ )} - {pane === 'graph' && ( - // S4 ships with current viewport treatment. S7 (Track B, pending) - // will land the full-width / tall / expandable viewport upgrade - // for KADetailView's Graph tab AND AssertionDetailView's Graph - // tab simultaneously — same component family. - ( - layeredTriples.length > 0 ? ( - Loading graph...}> - n?.id && onNavigate(n.id)} - initialFit - /> - - ) : ( -
No assertion graph data
- ) + {pane === 'triples' && ( + // Plain s/p/o table, assertion-scoped — NO filter pills + // (an assertion has singular scope, so cross-layer / + // cross-subgraph pills would have nothing to filter). +
+ + + + + + {triples.slice(0, 200).map((t, i) => ( + + + + {/* Codex round-2: literal objects from + `fetchAssertionTriples`/`rawBindingValue` carry the + FULL N-Triples form (`"v"^^` / `"v"@lang`, + escaped) — required by the Graph tab's RdfGraph + + buildMemoryEntities. For DISPLAY, decode through the + shared `decodeRdfStringLiteral` (the same helper + `humanizeLabel` uses) so the datatype/lang suffix is + dropped and the body is unescaped — NOT a naive + outer-quote strip, which left `"42"^^` → + `42"^^`. IRIs render via `shortPred`. */} + + + ))} + +
SubjectPredicateObject
{shortPred(t.subject)}{shortPred(t.predicate)}{t.object.startsWith('"') ? decodeRdfStringLiteral(t.object).slice(0, 60) : shortPred(t.object)}
+
+ {Math.min(triples.length, 200)} of {triples.length} triples shown +
+
)} - /> + + {pane === 'graph' && ( + // S4 ships with current viewport treatment. S7 (Track B, pending) + // will land the full-width / tall / expandable viewport upgrade + // for KADetailView's Graph tab AND AssertionDetailView's Graph + // tab simultaneously — same component family. + ( + layeredTriples.length > 0 ? ( + Loading graph...}> + n?.id && onNavigate(n.id, layer)} + initialFit + /> + + ) : ( +
No assertion graph data
+ ) + )} + /> + )} + )}
diff --git a/packages/node-ui/src/ui/views/project/helpers.ts b/packages/node-ui/src/ui/views/project/helpers.ts index 2154839c5..1a54c7f09 100644 --- a/packages/node-ui/src/ui/views/project/helpers.ts +++ b/packages/node-ui/src/ui/views/project/helpers.ts @@ -1012,6 +1012,50 @@ export function buildAssertionTrail( })); } +/** + * Empty-state copy for the assertion detail Entities pane when the + * assertion has zero entities, keyed off `dkg:state` (ux-lead's locked + * §4.7.1 copy — Codex round-3 finding 3). A single generic template was + * rejected because the noun changes at the VM boundary (entities → + * Knowledge Assets, §4.8) and because the forward path differs per state: + * - created → genuinely empty draft. + * - promoted → its entities moved to Shared Working Memory. + * - published / finalized → its entities are now Knowledge Assets in + * Verifiable Memory (post-publish the data graph is empty). + * - discarded → terminal; no forward path (forward-safety — discarded + * isn't list-reachable today, see the badge guard). + * Plain text, no links (S4 lock). Title is the constant "No entities in + * this assertion." for the live states; discarded gets a terminal title. + */ +export function assertionEmptyStateCopy( + state: AssertionState | null | undefined, +): { title: string; description: string } { + switch (state) { + case 'promoted': + return { + title: 'No entities in this assertion.', + description: 'This assertion was promoted — its entities now live in Shared Working Memory. Open the Shared Working Memory tab to view them.', + }; + case 'published': + case 'finalized': + return { + title: 'No entities in this assertion.', + description: 'This assertion was published — its entities are now Knowledge Assets in Verifiable Memory. Open the Verifiable Memory tab to view them.', + }; + case 'discarded': + return { + title: 'This assertion was discarded.', + description: 'This assertion was discarded.', + }; + case 'created': + default: + return { + title: 'No entities in this assertion.', + description: 'This assertion has no extracted entities.', + }; + } +} + /** * The primary (first non-`meta`) sub-graph slug an entity has triples * in, or null when it lives only in the root bucket / meta. Mirrors the @@ -1089,36 +1133,91 @@ export function buildBreadcrumbHops(input: { subGraphDisplayName?: string | null; /** Open detail's name, or null when no detail is open. */ detailLabel?: string | null; + /** + * Codex round-8 (8-2) — the M2 ORIGIN's layer/subgraph. When a detail + * is open the middle hop's `target` is `'origin'` (clicking closes the + * detail back to where it was opened), so its LABEL must name the + * origin, not the current location. After an M2(b) cross-subgraph + * follow the current subgraph diverges from the origin, and labelling + * the middle hop with the CURRENT subgraph misnames the destination the + * click returns you to. Supplied only when a detail is open; when + * absent (or no detail), the middle hop falls back to current-scope + * labelling (then current == location, which is correct). If the origin + * had no middle (opened from overview — `originLayer === 'overview'` and + * `originSubGraph === null`), NO middle is synthesised (the 2-hop rule + * still holds). + */ + originLayer?: LayerView | null; + originSubGraph?: string | null; + originSubGraphDisplayName?: string | null; }): BreadcrumbHop[] { - const { contextGraphName, activeLayer, activeSubGraph, subGraphDisplayName, detailLabel } = input; + const { + contextGraphName, + activeLayer, + activeSubGraph, + subGraphDisplayName, + detailLabel, + originLayer, + originSubGraph, + originSubGraphDisplayName, + } = input; const hops: BreadcrumbHop[] = []; - // First hop — Context Graph. It is the CURRENT page only when we are on - // the overview with no subgraph and no open detail. + // First hop is the CURRENT page only when we are on the overview with no + // subgraph and no open detail. const onOverview = !activeSubGraph && activeLayer === 'overview' && !detailLabel; - hops.push({ - key: 'cg', - label: contextGraphName, - title: contextGraphName, - target: onOverview ? 'current' : 'overview', - }); - if (onOverview) return hops; + if (onOverview) { + hops.push({ + key: 'cg', + label: contextGraphName, + title: contextGraphName, + target: 'current', + }); + return hops; + } // Middle hop — subgraph displayName OR layer full-name (never both). const detailOpen = !!detailLabel; + // 8-2 — when a detail is open the middle hop labels the ORIGIN (where + // clicking returns you). The origin layer/subgraph is supplied by the + // caller from the M2 snapshot. When it isn't (e.g. no detail open, or a + // caller that doesn't thread it), fall back to the current location. + const haveOrigin = detailOpen && originLayer !== undefined; + const middleLayer = haveOrigin ? (originLayer ?? 'overview') : activeLayer; + const middleSubGraph = haveOrigin ? (originSubGraph ?? null) : activeSubGraph; + const middleSubGraphName = haveOrigin ? originSubGraphDisplayName : subGraphDisplayName; let middle: { label: string; title: string } | null = null; - if (activeSubGraph) { - const name = subGraphDisplayName?.trim() || activeSubGraph; + if (middleSubGraph) { + const name = middleSubGraphName?.trim() || middleSubGraph; middle = { label: name, title: name }; - } else if (activeLayer === 'wm' || activeLayer === 'swm' || activeLayer === 'vm') { - const name = LAYER_FULL_NAME[activeLayer]; + } else if (middleLayer === 'wm' || middleLayer === 'swm' || middleLayer === 'vm') { + const name = LAYER_FULL_NAME[middleLayer]; middle = { label: name, title: name }; - } else if (activeLayer === 'graph-overview') { + } else if (middleLayer === 'graph-overview') { middle = { label: 'Subgraphs', title: 'Subgraphs' }; - } else if (activeLayer === 'query') { + } else if (middleLayer === 'query') { middle = { label: 'Query Catalogue', title: 'Query Catalogue' }; } + // First hop — Context Graph. + // Codex round-10 (10-2) — when a detail was opened from the OVERVIEW the + // breadcrumb is only 2 hops `[Context Graph › Detail]` (no middle), so the + // first hop is the SOLE back-affordance. Targeting 'overview' there would + // be a fresh top-of-overview nav that drops the captured scroll/tab; + // re-target it to 'origin' (→ onRestoreOrigin / handleDetailClose, which + // restores the overview origin correctly). The carve-out: in the 3-hop + // case (detail from a layer/subgraph) the first hop stays 'overview' — a + // genuine "go UP to the CG root", a DIFFERENT destination than the middle + // hop's 'origin' restore. The "no middle" check is the same condition that + // decides whether a middle hop exists, so one shared check drives both. + const firstHopRestoresOrigin = detailOpen && !middle; + hops.push({ + key: 'cg', + label: contextGraphName, + title: contextGraphName, + target: firstHopRestoresOrigin ? 'origin' : 'overview', + }); + if (middle) { hops.push({ key: 'middle', diff --git a/packages/node-ui/test/assertion-detail-helpers.test.ts b/packages/node-ui/test/assertion-detail-helpers.test.ts index 4d0b43a70..a5182be44 100644 --- a/packages/node-ui/test/assertion-detail-helpers.test.ts +++ b/packages/node-ui/test/assertion-detail-helpers.test.ts @@ -4,6 +4,7 @@ import { canPromoteAssertion, assertionSubgraphLine, buildAssertionTrail, + assertionEmptyStateCopy, buildBreadcrumbHops, primarySubGraphOf, } from '../src/ui/views/project/helpers.js'; @@ -107,6 +108,43 @@ describe('buildAssertionTrail — lifecycle trail stages + is-current marker', ( }); }); +describe('assertionEmptyStateCopy — ux §4.7.1 state-keyed empty copy (Codex round-3 #3)', () => { + it('created → "no extracted entities" (the generalization must NOT loosen this)', () => { + const c = assertionEmptyStateCopy('created'); + expect(c.title).toBe('No entities in this assertion.'); + expect(c.description).toBe('This assertion has no extracted entities.'); + }); + + it('promoted → SWM forward-path line (unchanged)', () => { + const c = assertionEmptyStateCopy('promoted'); + expect(c.title).toBe('No entities in this assertion.'); + expect(c.description).toContain('now live in Shared Working Memory'); + expect(c.description).toContain('Open the Shared Working Memory tab'); + expect(c.description).not.toContain('no extracted entities'); + }); + + it('published AND finalized → VM / Knowledge-Assets line, NOT "no extracted entities"', () => { + for (const s of ['published', 'finalized'] as const) { + const c = assertionEmptyStateCopy(s); + expect(c.title).toBe('No entities in this assertion.'); + expect(c.description).toBe('This assertion was published — its entities are now Knowledge Assets in Verifiable Memory. Open the Verifiable Memory tab to view them.'); + expect(c.description).not.toContain('no extracted entities'); + expect(c.description).not.toContain('Shared Working Memory'); + } + }); + + it('discarded → terminal copy, no "open X tab" forward path', () => { + const c = assertionEmptyStateCopy('discarded'); + expect(c.title).toBe('This assertion was discarded.'); + expect(c.description).toBe('This assertion was discarded.'); + expect(c.description).not.toContain('Open the'); + }); + + it('hydrating (null) falls back to the created copy', () => { + expect(assertionEmptyStateCopy(null).description).toBe('This assertion has no extracted entities.'); + }); +}); + describe('buildBreadcrumbHops — S5 breadcrumb hop construction (T04)', () => { const CG = 'Hello World'; @@ -161,11 +199,11 @@ describe('buildBreadcrumbHops — S5 breadcrumb hop construction (T04)', () => { }); describe('buildBreadcrumbHops — cross-subgraph update (T05)', () => { - // M2(b) makes a cross-subgraph entity jump switch activeSubGraph; S5's - // breadcrumb must reflect the NEW subgraph on the middle hop. This pins - // that the hop model is a pure function of the current subgraph — so - // when activeSubGraph changes, the rendered middle hop changes. - it('reflects the active subgraph on the middle hop', () => { + // When NO origin snapshot is threaded, the middle hop falls back to the + // current subgraph (pre-8-2 behaviour — and still correct for callers + // that don't supply the origin). Codex round-8 (8-2) layers the + // origin-aware labelling ON TOP of this fallback (see the next describe). + it('reflects the active subgraph on the middle hop (no origin threaded → current fallback)', () => { const before = buildBreadcrumbHops({ contextGraphName: 'CG', activeLayer: 'wm', activeSubGraph: 'demo', subGraphDisplayName: 'Demo', @@ -173,8 +211,6 @@ describe('buildBreadcrumbHops — cross-subgraph update (T05)', () => { }); expect(before[1].label).toBe('Demo'); - // Entity A linked to an entity in subgraph "other" → activeSubGraph - // follows (M2 option b); the breadcrumb middle hop updates. const after = buildBreadcrumbHops({ contextGraphName: 'CG', activeLayer: 'wm', activeSubGraph: 'other', subGraphDisplayName: 'Other', @@ -185,6 +221,114 @@ describe('buildBreadcrumbHops — cross-subgraph update (T05)', () => { }); }); +describe('buildBreadcrumbHops — origin-derived middle hop (Codex round-8 / 8-2)', () => { + // When a detail is open the middle hop's target is `'origin'` (clicking + // closes the detail back to where it was opened). After an M2(b) + // cross-subgraph follow the CURRENT subgraph diverges from the origin, so + // the middle hop's LABEL must name the ORIGIN — where the click returns + // you — not the followed-into subgraph. + it('labels the middle hop with the ORIGIN subgraph, not the current one (M2(b) divergence)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: 'CG', activeLayer: 'wm', + // Current page is subgraph "other" (followed into via M2(b)) … + activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + // … but the detail was OPENED from subgraph "demo". + originLayer: 'wm', + originSubGraph: 'demo', originSubGraphDisplayName: 'Demo', + }); + // Middle hop names the origin (Demo) — clicking it returns there. + expect(hops[1].label).toBe('Demo'); + expect(hops[1].target).toBe('origin'); + // ONLY the middle hop changes — trailing stays the entity name. + expect(hops[2].label).toBe('Entity B'); + expect(hops[2].target).toBe('current'); + // The followed-into subgraph name must NOT appear anywhere. + expect(hops.some(h => h.label === 'Other')).toBe(false); + }); + + it('labels the middle hop with the ORIGIN layer when the origin was a layer page', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: 'CG', + // Current location followed into a subgraph … + activeLayer: 'wm', activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + // … but the detail was opened from the SWM layer list (no subgraph). + originLayer: 'swm', originSubGraph: null, originSubGraphDisplayName: null, + }); + expect(hops[1].label).toBe('Shared Working Memory'); + expect(hops[1].target).toBe('origin'); + }); + + it('synthesises NO middle hop when the origin was the overview (2-hop rule holds)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: 'CG', + activeLayer: 'wm', activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + // Origin was the overview — no middle to return to. + originLayer: 'overview', originSubGraph: null, originSubGraphDisplayName: null, + }); + expect(hops.map(h => h.label)).toEqual(['CG', 'Entity B']); + expect(hops[1].target).toBe('current'); + }); + + it('falls back to current-scope labelling when no origin is threaded (back-compat)', () => { + // Detail open but origin props omitted (undefined) → current label. + const hops = buildBreadcrumbHops({ + contextGraphName: 'CG', activeLayer: 'wm', + activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + }); + expect(hops[1].label).toBe('Other'); + }); +}); + +describe('buildBreadcrumbHops — first-hop restores origin for an overview-opened detail (Codex round-10 / 10-2)', () => { + const CG = 'Hello World'; + + // Detail opened from the OVERVIEW → 2-hop `[Context Graph › Detail]`, no + // middle. The first hop is the SOLE back-affordance, so it must restore + // the M2 origin (target 'origin' → onRestoreOrigin), NOT do a fresh + // top-of-overview nav that drops the captured scroll/tab. + it('detail-from-overview (2-hop): the first hop target is "origin", not "overview"', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, + activeLayer: 'wm', activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + // Origin was the overview — no middle hop. + originLayer: 'overview', originSubGraph: null, originSubGraphDisplayName: null, + }); + expect(hops.map(h => h.label)).toEqual([CG, 'Entity B']); // 2 hops, no middle + expect(hops[0].target).toBe('origin'); // first hop restores origin + expect(hops[1].target).toBe('current'); // trailing = you are here + }); + + // Detail opened from a LAYER (3-hop) → the first hop stays 'overview' (a + // genuine go-UP-to-CG-root, distinct from the middle hop's 'origin' + // restore). The carve-out must hold. + it('detail-from-layer (3-hop): the first hop stays "overview" (carve-out)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, + activeLayer: 'wm', activeSubGraph: null, + detailLabel: 'Entity B', + originLayer: 'swm', originSubGraph: null, originSubGraphDisplayName: null, + }); + expect(hops.map(h => h.label)).toEqual([CG, 'Shared Working Memory', 'Entity B']); // 3 hops + expect(hops[0].target).toBe('overview'); // go up to CG root — unchanged + expect(hops[1].target).toBe('origin'); // middle restores origin + expect(hops[2].target).toBe('current'); + }); + + // No detail open: the first hop is 'overview' (unchanged baseline). + it('no detail open: the first hop is "overview" (unchanged)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, activeLayer: 'wm', activeSubGraph: null, + }); + expect(hops[0].target).toBe('overview'); + expect(hops[1].target).toBe('current'); + }); +}); + describe('primarySubGraphOf — M2(b) cross-subgraph follow decision (T14)', () => { it('returns the first non-meta subgraph slug', () => { expect(primarySubGraphOf(entity(['demo']))).toBe('demo'); diff --git a/packages/node-ui/test/assertion-detail-view.dom.test.ts b/packages/node-ui/test/assertion-detail-view.dom.test.ts index e0597e4c0..c22320796 100644 --- a/packages/node-ui/test/assertion-detail-view.dom.test.ts +++ b/packages/node-ui/test/assertion-detail-view.dom.test.ts @@ -86,13 +86,14 @@ async function flush(): Promise { }); } -function render(root: Root, assertion: AssertionInfo) { +function render(root: Root, assertion: AssertionInfo, sourceLayer: 'wm' | 'swm' = 'wm') { return act(async () => { root.render( React.createElement(ProjectProfileContext.Provider, { value: profile }, React.createElement(AgentsContext.Provider, { value: agents }, React.createElement(AssertionDetailView, { assertion, + sourceLayer, contextGraphId: 'cg-test', onNavigate: vi.fn(), onComplete: vi.fn(), @@ -101,10 +102,10 @@ function render(root: Root, assertion: AssertionInfo) { }); } -async function mount(assertion: AssertionInfo): Promise { +async function mount(assertion: AssertionInfo, sourceLayer: 'wm' | 'swm' = 'wm'): Promise { document.body.innerHTML = '
'; const root = createRoot(query('#root')); - await render(root, assertion); + await render(root, assertion, sourceLayer); await flush(); return root; } @@ -210,6 +211,45 @@ describe('AssertionDetailView', () => { expect(document.body.textContent).not.toContain('discarded ('); }); + // Codex round-6 finding 2 — an entity label that is a lang/typed RDF + // literal must render DECODED in the Entities pane (`Hola`), not as the + // raw `Hola"@es` (the round-2 consumer audit missed buildMemoryEntities' + // label path; round-6 decodes at the shared label chokepoint). + it('Entities pane decodes a lang-tagged entity label (Hola, not Hola"@es)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockResolvedValue([ + { subject: 'urn:e:greet', predicate: TYPE, object: 'http://schema.org/Thing' }, + { subject: 'urn:e:greet', predicate: 'http://schema.org/name', object: '"Hola"@es' }, + ]); + root = await mount(wmAssertion); + const body = document.body.textContent ?? ''; + expect(body).toContain('Hola'); + expect(body).not.toContain('Hola"@es'); + expect(body).not.toContain('"@es'); + }); + + // Codex round-6 finding 3 — a triple-FETCH error must render a DISTINCT + // "couldn't load" state, NOT the empty-state copy (an operational + // failure must not look like valid-but-empty data). + it('triple-fetch error shows the error state, not the empty-state copy', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockRejectedValue(new Error('HTTP 500')); + root = await mount(wmAssertion); + const body = document.body.textContent ?? ''; + // Error state, distinct from the genuinely-empty copy. + expect(body).toContain("Couldn't load this assertion's contents."); + expect(body).not.toContain('This assertion has no extracted entities.'); + expect(body).not.toContain('No entities in this assertion.'); + }); + it('Triples tab renders a plain s/p/o table with NO filter pills (T10)', async () => { stateMock.fetchAssertionState.mockResolvedValue({ state: 'created', layer: 'wm', @@ -228,6 +268,67 @@ describe('AssertionDetailView', () => { expect(query('.v10-ka-triples-table').textContent).toContain('Battery cell 003'); }); + // Codex round-9 (9-1) — the triples loading treatment is SHARED across + // all three tabs. Pre-fix only the Entities pane gated on triplesLoading; + // the Triples tab rendered a "0 of 0" table and the Graph tab fell through + // to "No assertion graph data" while the fetch was still pending. + it('triples loading: the Triples and Graph tabs show the loading treatment, not 0-of-0 / no-data', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + // Triples fetch never settles → triplesLoading stays true, triples empty. + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockReturnValue(new Promise(() => {})); + root = await mount(wmAssertion); + + // Entities tab (default) shows loading. + expect(document.body.textContent).toContain('Loading assertion entities'); + + // Triples tab — must NOT render the empty "0 of 0" table. + const tabs = () => [...document.querySelectorAll('.v10-content-tab')]; + await act(async () => { tabs().find(t => t.textContent === 'Triples')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + expect(document.querySelector('.v10-ka-triples-table')).toBeNull(); + expect(document.body.textContent).not.toContain('0 of 0 triples shown'); + expect(document.body.textContent).toContain('Loading assertion entities'); + + // Graph tab — must NOT render "No assertion graph data". + await act(async () => { tabs().find(t => t.textContent === 'Graph')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + expect(document.body.textContent).not.toContain('No assertion graph data'); + expect(document.body.textContent).toContain('Loading assertion entities'); + }); + + it('triples error: ALL three tabs show the error state, not empty content', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockRejectedValue(new Error('HTTP 500')); + root = await mount(wmAssertion); + + const ERR = "Couldn't load this assertion's contents."; + const tabs = () => [...document.querySelectorAll('.v10-content-tab')]; + + // Entities tab. + expect(document.body.textContent).toContain(ERR); + + // Triples tab — error, NOT the empty table. + await act(async () => { tabs().find(t => t.textContent === 'Triples')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + expect(document.body.textContent).toContain(ERR); + expect(document.querySelector('.v10-ka-triples-table')).toBeNull(); + expect(document.body.textContent).not.toContain('0 of 0 triples shown'); + + // Graph tab — error, NOT "No assertion graph data". + await act(async () => { tabs().find(t => t.textContent === 'Graph')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + expect(document.body.textContent).toContain(ERR); + expect(document.body.textContent).not.toContain('No assertion graph data'); + }); + // Codex round-2 finding 3 — the Triples table must DISPLAY literals // decoded (datatype/lang suffix dropped, body unescaped), not the raw // N-Triples form. The producer (rawBindingValue) keeps the full form for @@ -268,6 +369,116 @@ describe('AssertionDetailView', () => { expect(query('.v10-ka-header-actions').textContent).not.toContain('Promote to SWM'); }); + // Codex round-11 (11-1) — during the state hydrate the badge + tone must + // reflect the KNOWN-true source layer (the list the detail opened from), + // NOT the old `?? 'wm'` invention. An SWM assertion opened while its state + // is still loading shows "◈ Shared", never "◇ Working". + it('hydrating: badge reflects the SWM source layer, not an invented wm (11-1)', async () => { + stateMock.fetchAssertionState.mockReturnValue(new Promise(() => {})); + root = await mount(wmAssertion, 'swm'); // opened from the SWM list + const badge = query('.v10-trust-badge'); + expect(badge.textContent).toContain('Shared'); // SWM trust label + expect(badge.textContent).not.toContain('Working'); // no wm-flash + expect(badge.textContent).toContain('◈'); // SWM glyph + expect(badge.textContent).not.toContain('◇'); // not the WM glyph + }); + + // 11-1 — on a state-fetch error the badge must still show the source layer + // (the value is real + known), NOT 'wm', so it doesn't contradict the + // "state unavailable" message in the right rail. + it('state-error: badge shows the SWM source layer alongside "state unavailable" (no wm contradiction)', async () => { + stateMock.fetchAssertionState.mockResolvedValue(null); + root = await mount(wmAssertion, 'swm'); + const badge = query('.v10-trust-badge'); + expect(badge.textContent).toContain('Shared'); + expect(badge.textContent).not.toContain('Working'); + expect(document.body.textContent).toContain('state unavailable'); + }); + + // Codex round-11 (11-2) — header counts during load must NOT read as + // `0 entities · 0 triples` (a false "empty" claim). entityCount is unknown + // until the fetch → `…`; tripleCount seeds from the row when present, else + // `…` — never 0 before the fetch settles. + it('hydrating: header counts show placeholders, never 0 entities / 0 triples (11-2)', async () => { + stateMock.fetchAssertionState.mockReturnValue(new Promise(() => {})); + // Row carries NO tripleCount → both counts are placeholders. + root = await mount({ name: 'x', graphUri: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/x', subGraph: 'demo' }); + const line = query('.v10-ka-ual').textContent ?? ''; + expect(line).not.toContain('0 entities'); + expect(line).not.toContain('0 triples'); + expect(line).toContain('… entities'); + expect(line).toContain('… triples'); + }); + + // 11-2 — when the row carries a tripleCount it seeds immediately (no + // 0-flash, no placeholder for that field); entities stay `…` until the + // fetch resolves. + it('hydrating: a row-seeded tripleCount shows immediately; entities stay a placeholder (11-2)', async () => { + stateMock.fetchAssertionState.mockReturnValue(new Promise(() => {})); + root = await mount({ name: 'x', graphUri: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/x', subGraph: 'demo', tripleCount: 142 }); + const line = query('.v10-ka-ual').textContent ?? ''; + expect(line).toContain('142 triples'); // row-seeded, no 0-flash + expect(line).toContain('… entities'); // still unknown + expect(line).not.toContain('0 entities'); + expect(line).not.toContain('0 triples'); + }); + + // 11-2 — once resolved, the real counts replace the placeholders (incl. a + // genuine zero, which is now a TRUE statement, not a hydrate artefact). + it('resolved: header shows the real fetched counts (placeholders replaced)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + // sampleTriples = 2 triples, 1 root entity (urn:e:battery). + root = await mount(wmAssertion); + const line = query('.v10-ka-ual').textContent ?? ''; + expect(line).toContain('2 triples'); + expect(line).not.toContain('… triples'); + expect(line).not.toContain('… entities'); + // Real entity count is present (not a placeholder). + expect(line).toMatch(/\d+ entities/); + }); + + // 11-2 — a triples-FETCH error must keep the count placeholders, NEVER + // surface `0 entities · 0 triples` (an operational failure must not read as + // valid-but-empty). + it('triples error: header counts stay placeholders, never 0 (11-2)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockRejectedValue(new Error('HTTP 500')); + root = await mount(wmAssertion); // wmAssertion carries no tripleCount + const line = query('.v10-ka-ual').textContent ?? ''; + expect(line).not.toContain('0 entities'); + expect(line).not.toContain('0 triples'); + expect(line).toContain('… entities'); + expect(line).toContain('… triples'); + }); + + // Codex round-8 (8-1) — the empty-state must NOT FLASH during hydration. + // On first mount `stateInfo` is null, so `assertionGraph` is undefined + // and the triples effect sets `triplesLoading=false` immediately — + // leaving the empty-state branch reachable BEFORE the lifecycle lookup + // resolves (with `assertionEmptyStateCopy(undefined)`). The hydrating + // treatment must win while `stateLoading` is true. + it('hydrating (state fetch unresolved): Entities pane shows the loading treatment, NOT the empty-state flash', async () => { + // State never resolves → stateLoading stays true → assertionGraph + // undefined → triples effect early-returns with triplesLoading=false. + stateMock.fetchAssertionState.mockReturnValue(new Promise(() => {})); + root = await mount(wmAssertion); + const body = document.body.textContent ?? ''; + // Hydrating treatment is shown … + expect(body).toContain('Loading assertion entities'); + // … and NONE of the empty-state copies flash through. + expect(body).not.toContain('This assertion has no extracted entities.'); + expect(body).not.toContain('No entities in this assertion'); + expect(body).not.toContain('now live in Shared Working Memory'); + expect(body).not.toContain('Knowledge Assets in Verifiable Memory'); + }); + it('state-fetch error: panel shows "state unavailable" + quiet trail hint, CTA hidden', async () => { stateMock.fetchAssertionState.mockResolvedValue(null); root = await mount(wmAssertion); @@ -339,6 +550,30 @@ describe('AssertionDetailView', () => { ); }); + // Codex round-3 finding 3 — a published/finalized assertion (empty data + // graph: entities moved to VM) must show the VM / Knowledge-Assets + // empty-state line, NOT "no extracted entities" and NOT the SWM line. + it('published assertion empty-state shows the VM / Knowledge-Assets line (not "no extracted entities")', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'published', layer: 'vm', + // post-publish the data graph is empty + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockResolvedValue([]); + root = await mount(wmAssertion); + const body = document.body.textContent ?? ''; + expect(body).toContain('No entities in this assertion'); + expect(body).toContain('its entities are now Knowledge Assets in Verifiable Memory. Open the Verifiable Memory tab to view them.'); + expect(body).not.toContain('no extracted entities'); + // Scope the SWM-line check to the empty-state element — the lifecycle + // trail in the right rail always renders the static stage title + // "Promoted to Shared Working Memory" (the pipeline legend), so + // checking the whole body would false-positive. + const emptyStateText = document.querySelector('.v10-empty-state')?.textContent ?? body; + expect(emptyStateText).not.toContain('now live in Shared Working Memory'); + }); + // Codex round-1 finding 1 — on assertion switch the hook must CLEAR // `data` so the previous assertion's state isn't briefly visible while // the new fetch is in flight. A → B where B's state never resolves: the diff --git a/packages/node-ui/test/breadcrumb.dom.test.ts b/packages/node-ui/test/breadcrumb.dom.test.ts index 03e73a5b7..3bbd25aec 100644 --- a/packages/node-ui/test/breadcrumb.dom.test.ts +++ b/packages/node-ui/test/breadcrumb.dom.test.ts @@ -117,3 +117,160 @@ describe('ProjectHeaderStrip breadcrumb', () => { expect(all('.v10-project-strip-sep')).toHaveLength(2); }); }); + +describe('ProjectHeaderStrip strip chrome from origin (Codex round-9 / 9-2)', () => { + let root: Root; + + // A profile that resolves subgraph bindings by slug — so 9-2 can read the + // ORIGIN subgraph's color + description from `forSubGraph(originSubGraph)`. + const bindings: Record = { + demo: { slug: 'demo', displayName: 'Demo', color: '#aa0000', description: 'Demo description.' }, + other: { slug: 'other', displayName: 'Other', color: '#00bb00', description: 'Other description.' }, + }; + const chromeProfile = { + displayName: 'Hello World', + primaryColor: '#64748b', + forSubGraph: (slug: string) => bindings[slug], + } as any; + + beforeEach(() => { + document.body.innerHTML = '
'; + root = createRoot(query('#root')); + }); + afterEach(async () => { + await act(async () => { root.unmount(); }); + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + async function render(props: Partial>) { + await act(async () => { + root.render(React.createElement(ProjectHeaderStrip, { + cg, profile: chromeProfile, + activeLayer: 'wm', + activeSubGraph: null, + detailLabel: null, + onOverview: vi.fn(), + onRestoreOrigin: vi.fn(), + ...props, + } as any)); + }); + } + + // After an M2(b) cross-subgraph follow (current = 'other', origin = + // 'demo'), the strip tint + description + breadcrumb middle hop must all + // name the ORIGIN ('demo'), not the followed-into 'other'. + it('cross-subgraph follow: tint + description + breadcrumb all come from the ORIGIN', async () => { + await render({ + activeLayer: 'wm', + activeSubGraph: bindings.other, // current page = followed-into 'other' + detailLabel: 'Entity B', + originLayer: 'wm', + originSubGraph: 'demo', // origin = where the detail opened + originSubGraphDisplayName: 'Demo', + }); + // Tint = origin color, NOT the followed-into one. + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).toBe('#aa0000'); + // Description = origin description. + expect(query('.v10-project-strip-desc').textContent).toBe('Demo description.'); + // Breadcrumb middle hop also names the origin (8-2 + 9-2 share the gate). + const hops = all('.v10-breadcrumb-hop'); + expect(hops[1].textContent).toBe('Demo'); + // The followed-into subgraph's chrome must NOT leak through. + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).not.toBe('#00bb00'); + expect(query('.v10-project-strip-desc').textContent).not.toBe('Other description.'); + }); + + // No cross-follow: origin == current → no-op (chrome stays on current). + it('no cross-follow (origin == current): chrome is a no-op (stays on current)', async () => { + await render({ + activeLayer: 'wm', + activeSubGraph: bindings.demo, + detailLabel: 'Entity A', + originLayer: 'wm', + originSubGraph: 'demo', + originSubGraphDisplayName: 'Demo', + }); + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).toBe('#aa0000'); + expect(query('.v10-project-strip-desc').textContent).toBe('Demo description.'); + }); + + // Origin had NO subgraph (opened from overview / a non-subgraph layer): + // chrome degrades to profile.primaryColor + cg.description. + it('overview-origin: chrome falls back to primaryColor + cg.description', async () => { + await render({ + activeLayer: 'wm', + activeSubGraph: bindings.other, // followed into 'other' + detailLabel: 'Entity B', + originLayer: 'overview', + originSubGraph: null, // origin had no subgraph + originSubGraphDisplayName: null, + }); + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).toBe('#64748b'); // primaryColor + expect(query('.v10-project-strip-desc').textContent).toBe('A demo graph.'); // cg.description + // The followed-into 'other' chrome must NOT be used. + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).not.toBe('#00bb00'); + }); + + // No detail open: chrome derives from the CURRENT activeSubGraph (the + // pre-9-2 behaviour — unchanged when there's no detail / no origin). + it('no detail open: chrome derives from the current activeSubGraph', async () => { + await render({ + activeLayer: 'wm', + activeSubGraph: bindings.other, + detailLabel: null, + }); + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).toBe('#00bb00'); + expect(query('.v10-project-strip-desc').textContent).toBe('Other description.'); + }); + + // Codex round-10 (10-1) — an UNPROFILED origin subgraph (forSubGraph + // returns undefined) must NOT throw: the chrome degrades to + // profile.primaryColor + cg.description and the breadcrumb still renders. + // (ProjectView resolves the origin label with the same optional-chain + + // slug fallback; here we exercise the ProjectHeaderStrip chrome path.) + it('unprofiled origin subgraph: no throw, breadcrumb renders, chrome falls back', async () => { + await expect(render({ + activeLayer: 'wm', + activeSubGraph: bindings.demo, + detailLabel: 'Entity X', + originLayer: 'wm', + originSubGraph: 'ghost', // no binding → forSubGraph returns undefined + originSubGraphDisplayName: 'ghost', // caller's slug-fallback name + })).resolves.not.toThrow(); + // Breadcrumb rendered (didn't crash) — middle hop shows the slug. + const hops = all('.v10-breadcrumb-hop'); + expect(hops.map(h => h.textContent)).toEqual(['Hello World', 'ghost', 'Entity X']); + // Chrome degraded to profile/CG defaults (no color/description binding). + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).toBe('#64748b'); + expect(query('.v10-project-strip-desc').textContent).toBe('A demo graph.'); + }); + + // Codex round-10 (10-2) — a detail opened from the OVERVIEW renders a + // 2-hop breadcrumb whose FIRST hop is the sole back-affordance. It must be + // a clickable button wired to onRestoreOrigin (origin restore), NOT + // onOverview (fresh nav that drops scroll/tab). + it('overview-opened detail: first hop is a button that restores the origin (not overview nav)', async () => { + const onOverview = vi.fn(); + const onRestoreOrigin = vi.fn(); + await render({ + activeLayer: 'wm', + activeSubGraph: null, + detailLabel: 'Entity B', + originLayer: 'overview', + originSubGraph: null, + originSubGraphDisplayName: null, + onOverview, + onRestoreOrigin, + }); + const hops = all('.v10-breadcrumb-hop'); + // 2 hops, no middle. + expect(hops.map(h => h.textContent)).toEqual(['Hello World', 'Entity B']); + // First hop is a clickable BUTTON (not a current span). + expect(hops[0].tagName).toBe('BUTTON'); + await act(async () => { hops[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + // Restores the origin; does NOT fire a fresh overview nav. + expect(onRestoreOrigin).toHaveBeenCalledTimes(1); + expect(onOverview).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/node-ui/test/context-graph-empty-stat-components.test.ts b/packages/node-ui/test/context-graph-empty-stat-components.test.ts index 5becba741..7a2f68881 100644 --- a/packages/node-ui/test/context-graph-empty-stat-components.test.ts +++ b/packages/node-ui/test/context-graph-empty-stat-components.test.ts @@ -621,7 +621,9 @@ describe('Context Graph shared empty/stat patterns', () => { row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); }); expect(onSelectAssertion).toHaveBeenCalledTimes(1); - expect(onSelectAssertion).toHaveBeenCalledWith(expect.objectContaining({ name: 'kbd-doc' })); + // Codex round-11 (11-1) — the list forwards its own `layer` as the + // source layer (2nd arg) so the detail view seeds its badge/tone. + expect(onSelectAssertion).toHaveBeenCalledWith(expect.objectContaining({ name: 'kbd-doc' }), 'wm'); await unmount(); }); diff --git a/packages/node-ui/test/project-view-navigation.test.ts b/packages/node-ui/test/project-view-navigation.test.ts index cecd08f5d..e05074a0e 100644 --- a/packages/node-ui/test/project-view-navigation.test.ts +++ b/packages/node-ui/test/project-view-navigation.test.ts @@ -39,6 +39,20 @@ function createEntities() { properties: new Map(), connections: [], }], + // Codex round-8 (8-3) — a MULTI-HOMED entity: lives in BOTH 'demo' + // and 'other'. When followed from a page already in one of its + // subgraphs, handleNavigate must NOT jump to the arbitrary + // `primarySubGraphOf` sibling — it should stay on the current one. + ['urn:entity:multihomed', { + uri: 'urn:entity:multihomed', + label: 'Multi-homed entity', + types: [], + trustLevel: 'working', + layers: new Set(['working']), + subGraphs: new Set(['demo', 'other']), + properties: new Map(), + connections: [], + }], ['urn:entity:overlap', { uri: 'urn:entity:overlap', label: 'Shared overlap', @@ -309,15 +323,23 @@ vi.mock('../src/ui/views/project/components.js', () => ({ KADetailView: ({ entity, onNavigate }: { entity: any; onNavigate: (uri: string) => void }) => React.createElement('section', { 'data-testid': 'entity-detail', 'data-entity': entity.uri, 'data-trust': entity.trustLevel, 'data-connections': String(entity.connections.length), 'data-subgraphs': [...entity.subGraphs].sort().join(',') }, React.createElement('div', {}, entity.label), - React.createElement('button', { 'data-testid': 'open-related-entity', onClick: () => onNavigate('urn:entity:other') }, 'Open related')), + React.createElement('button', { 'data-testid': 'open-related-entity', onClick: () => onNavigate('urn:entity:other') }, 'Open related'), + // Codex round-8 (8-3) — follow a link to the MULTI-HOMED entity + // (subGraphs: {demo, other}). The prefer-current rule should keep + // activeSubGraph on whichever of those the page is already in. + React.createElement('button', { 'data-testid': 'open-multihomed-entity', onClick: () => onNavigate('urn:entity:multihomed') }, 'Open multi-homed')), // S4 — assertion detail overlay. The mock surfaces the assertion // identity + an "open entity from assertion" button so the // mutually-exclusive overlay + close-to-origin behaviour can be // asserted without the real fetch/render path. - AssertionDetailView: ({ assertion, onNavigate }: { assertion: any; onNavigate: (uri: string) => void }) => + AssertionDetailView: ({ assertion, onNavigate }: { assertion: any; onNavigate: (uri: string, layer?: 'wm' | 'swm' | 'vm') => void }) => React.createElement('section', { 'data-testid': 'assertion-detail', 'data-assertion': assertion.graphUri, 'data-name': assertion.name, 'data-subgraph': assertion.subGraph ?? '' }, React.createElement('div', {}, assertion.name), - React.createElement('button', { 'data-testid': 'assertion-open-entity', onClick: () => onNavigate('urn:entity:demo') }, 'Open entity from assertion')), + React.createElement('button', { 'data-testid': 'assertion-open-entity', onClick: () => onNavigate('urn:entity:demo') }, 'Open entity from assertion'), + // Codex round-5 — open an entity that exists in MULTIPLE layers, + // forwarding the assertion's layer (wm) so the follow-on detail is + // WM-scoped, not the canonical (shared) version. + React.createElement('button', { 'data-testid': 'assertion-open-overlap-wm', onClick: () => onNavigate('urn:entity:overlap', 'wm') }, 'Open overlap entity (WM-scoped)')), SubGraphDetailView: ({ slug, activeTab = 'items', onTabChange, onSelectEntity, initialLayer, initialEnabledLayers, onEnabledLayersChange }: { slug: string; activeTab?: string; @@ -403,7 +425,11 @@ vi.mock('../src/ui/views/project/components.js', () => ({ onClick: () => onEnabledLayersChange?.(new Set(['working', 'shared', 'verified'])), }, 'all three'), React.createElement('div', { 'data-testid': 'subgraph-scroll', 'data-cg-scroll-key': `subgraph:${slug}:${activeTab}` }, - React.createElement('button', { 'data-testid': 'open-subgraph-entity', onClick: () => onSelectEntity('urn:entity:demo') }, 'Open demo entity'))); + React.createElement('button', { 'data-testid': 'open-subgraph-entity', onClick: () => onSelectEntity('urn:entity:demo') }, 'Open demo entity'), + // Codex round-8 (8-3) — open the multi-homed entity (subGraphs: + // {demo, other}) from whichever subgraph page we're on, exercising + // handleNavigate's prefer-current branch. + React.createElement('button', { 'data-testid': 'open-subgraph-multihomed', onClick: () => onSelectEntity('urn:entity:multihomed') }, 'Open multi-homed entity'))); }, ProjectOverviewCard: ({ onOpenPrimer, participants, participantsStatus, subGraphCount, subGraphFetchFailed }: { onOpenPrimer: () => void; @@ -439,7 +465,9 @@ vi.mock('../src/ui/views/project/components.js', () => ({ activeTab: string; onTabChange: (tab: string) => void; onSelectEntity: (uri: string) => void; - onSelectAssertion?: (assertion: any) => void; + // Codex round-11 (11-1) — mirrors the real signature: the list forwards + // its own layer as the source layer. + onSelectAssertion?: (assertion: any, sourceLayer: 'wm' | 'swm') => void; onNodeClick: (node: any) => void; }) => React.createElement('section', { 'data-testid': 'layer-detail', 'data-layer': layer, 'data-tab': activeTab }, @@ -448,8 +476,9 @@ vi.mock('../src/ui/views/project/components.js', () => ({ React.createElement('div', { 'data-testid': 'layer-scroll', 'data-cg-scroll-key': `layer:${layer}:${activeTab}` }, React.createElement('button', { 'data-testid': 'open-layer-entity', onClick: () => onSelectEntity('urn:entity:working') }, 'Open layer entity'), React.createElement('button', { 'data-testid': 'open-layer-overlap-entity', onClick: () => onSelectEntity('urn:entity:overlap') }, 'Open overlap entity'), - // S4 — open an assertion detail from the Assertions subtab. - React.createElement('button', { 'data-testid': 'open-layer-assertion', onClick: () => onSelectAssertion?.({ name: 'demo-assertion', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/demo-assertion', subGraph: undefined }) }, 'Open assertion'), + // S4 — open an assertion detail from the Assertions subtab. Forwards + // the mock's `layer` as the source layer (real lists do the same). + React.createElement('button', { 'data-testid': 'open-layer-assertion', onClick: () => onSelectAssertion?.({ name: 'demo-assertion', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/demo-assertion', subGraph: undefined }, layer === 'swm' ? 'swm' : 'wm') }, 'Open assertion'), React.createElement('button', { 'data-testid': 'open-layer-graph-node', onClick: () => onNodeClick({ id: 'urn:entity:overlap', trustLayer: layer }) }, 'Open graph node'))), })); @@ -717,6 +746,89 @@ describe('ProjectView entity detail navigation', () => { expect(query('subgraph-detail').dataset.tab).toBe('graph'); }); + // Codex round-8 (8-3) — prefer-current for a MULTI-HOMED entity. M2(b)'s + // switch target is `primarySubGraphOf` (first non-meta), which is + // arbitrary when the entity lives in several subgraphs. When the + // followed entity is ALREADY in the current subgraph, do NOT switch — + // staying put is least-surprising and avoids an arbitrary sibling jump. + // + // The discriminating setup: be on subgraph 'other' (the NON-primary of + // the multi-homed set {demo, other} — primarySubGraphOf returns 'demo', + // the insertion-first slug). Pre-8-3 the follow would switch to 'demo' + // (primary ≠ current); post-8-3 it stays on 'other' because the entity + // is already there. (If we tested from 'demo' the bug would hide, since + // primary == current → no switch even without the fix.) + it('following a multi-homed entity that is in the CURRENT (non-primary) subgraph does NOT switch (8-3 prefer-current)', async () => { + await click('switch-subgraphs'); + await click('select-subgraph-other'); + await flush(); + expect(query('active-subgraph').textContent).toBe('other'); + + await click('open-subgraph-multihomed'); + await flush(); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:multihomed'); + // Stayed on 'other' — NOT switched to the arbitrary primary ('demo'). + expect(query('active-subgraph').textContent).toBe('other'); + }); + + // 8-3 symmetric guard — when the followed entity is NOT in the current + // subgraph at all, M2(b) MUST still switch (primarySubGraphOf is the + // must-move fallback that un-strands the entity). Already covered by the + // T14/T15 cross-subgraph test (urn:entity:other in {'other'} only); + // this pins the intent explicitly alongside the prefer-current case. + it('following an entity that is NOT in the current subgraph still switches (8-3 must-move fallback)', async () => { + await click('switch-subgraphs'); + await click('select-subgraph-demo'); + await flush(); + expect(query('active-subgraph').textContent).toBe('demo'); + + // urn:entity:other lives ONLY in {'other'} — not in 'demo'. The follow + // must switch to 'other' (the entity isn't on the current page). + await click('open-subgraph-entity'); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:demo'); + await click('open-related-entity'); + await flush(); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:other'); + expect(query('active-subgraph').textContent).toBe('other'); + }); + + // Codex round-10 (10-1) — opening a detail from an UNPROFILED subgraph + // (profile.forSubGraph returns undefined for its slug) must NOT crash. The + // breadcrumb-origin name resolution in ProjectView calls + // `forSubGraph(originSubGraph)`; pre-fix it dereferenced `.displayName` on + // the undefined result, throwing during render. The fix optional-chains + // and degrades to the slug. Here we make 'demo' unprofiled, enter it, and + // open an entity (→ 'demo' becomes the detail origin) — the render must + // succeed and the detail must appear. + it('opening a detail from an unprofiled origin subgraph does not crash (10-1 null-guard)', async () => { + const originalForSubGraph = profile.forSubGraph; + // 'demo' has no profile binding now → forSubGraph('demo') === undefined. + // (The active-subgraph slug stays 'demo' internally; only the resolved + // binding is undefined, so the strip chrome degrades.) + profile.forSubGraph = (slug: string) => + slug === 'demo' ? undefined : originalForSubGraph(slug); + try { + await click('switch-subgraphs'); + await click('select-subgraph-demo'); + await flush(); + // The subgraph DETAIL view is on 'demo' (driven by the internal slug, + // not the missing binding) — proves we entered it without crashing. + expect(query('subgraph-detail').dataset.slug).toBe('demo'); + + // Open an entity in 'demo' → 'demo' becomes the detail origin. Pre-fix + // the render throws resolving the origin subgraph name + // (`forSubGraph('demo').displayName` on undefined). + await click('open-subgraph-entity'); + await flush(); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:demo'); + // Breadcrumb still renders (didn't crash); origin name degrades to the + // slug. The strip is present. + expect(query('project-strip')).toBeTruthy(); + } finally { + profile.forSubGraph = originalForSubGraph; + } + }); + // T15 — the M2 ORIGINAL behavior (no cross-subgraph involved) is // untouched: open from a layer list → close → same layer / subtab / // scroll, and opening from a plain layer does NOT spuriously jump into @@ -1389,6 +1501,34 @@ describe('ProjectView entity detail navigation', () => { expect(query('layer-detail').dataset.tab).toBe('assertions'); }); + // Codex round-7 (7-1) — the scroll-restore effect must fire for the + // assertion-detail close, not just the entity close. Assertions now + // participate in the M2 origin snapshot (`handleDetailClose` queues + // `origin.scroll` for either overlay), but pre-fix the effect's guard + // only watched `selectedUri`; closing an assertion back to the SAME + // layer it opened from changed none of the listed deps, so the queued + // scroll was never consumed. Mirrors the entity scroll-restore test + // (T07 / T16 covered layer + subtab; this closes the scroll gap). + it('restores scroll position when the assertion detail closes (T07 / T16 scroll parity)', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + + const scroller = query('layer-scroll'); + scroller.scrollTop = 73; + + await click('open-layer-assertion'); + await flush(); + expect(query('assertion-detail').dataset.name).toBe('demo-assertion'); + + await click('detail-back'); + await flush(); + + expect(query('layer-detail').dataset.layer).toBe('wm'); + expect(query('layer-detail').dataset.tab).toBe('assertions'); + expect(query('layer-scroll').scrollTop).toBe(73); + }); + it('opening an entity from the assertion detail replaces it (mutually exclusive overlays)', async () => { await click('switch-wm'); await click('layer-tab-assertions'); @@ -1404,6 +1544,28 @@ describe('ProjectView entity detail navigation', () => { expect(document.querySelector('[data-testid="assertion-detail"]')).toBeNull(); }); + // Codex round-5 finding 2 — navigating from a WM-assertion entity that + // ALSO exists in SWM must open the WM-SCOPED detail (the assertion's + // layer), not the canonical/global version. urn:entity:overlap lives in + // {working, shared} with canonical trustLevel 'shared'; opening it with + // layer='wm' forwarded must resolve the WM slice (trust 'working'). + it('navigating from a WM-assertion entity (also in SWM) opens the WM-scoped detail, not canonical', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + await click('open-layer-assertion'); + await flush(); + expect(query('assertion-detail').dataset.name).toBe('demo-assertion'); + + await click('assertion-open-overlap-wm'); + await flush(); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:overlap'); + // WM-scoped (working), NOT the canonical 'shared' — proves the + // assertion's layer flowed through handleNavigate's layerContext. + expect(query('entity-detail').dataset.trust).toBe('working'); + expect(query('entity-detail').textContent).toContain('Working overlap'); + }); + it('switching layers from the assertion detail exits the overlay (no stranded detail)', async () => { await click('switch-wm'); await click('layer-tab-assertions'); diff --git a/packages/node-ui/test/use-assertion-state.test.ts b/packages/node-ui/test/use-assertion-state.test.ts index 6f038e01b..6d10e9e85 100644 --- a/packages/node-ui/test/use-assertion-state.test.ts +++ b/packages/node-ui/test/use-assertion-state.test.ts @@ -171,6 +171,42 @@ describe('fetchAssertionTriples — reads the assertion data graph', () => { expect(out[0].object).toBe('"say \\"hi\\"\\\\done"'); expect(out[0].object.startsWith('"')).toBe(true); }); + + // Codex round-6 — control chars (raw newline/tab/CR + other C0) in a + // multiline extracted literal must be N-Triples-escaped, else the value + // is invalid N-Triples and breaks the graph/triple parsers. + it('escapes control chars (newline / tab / CR / other C0) in a literal', async () => { + // Build the control chars + backslash at RUNTIME (fromCharCode) so the + // test SOURCE holds no raw control bytes / ambiguous escapes. + const NL = String.fromCharCode(10), TAB = String.fromCharCode(9), CR = String.fromCharCode(13), BELL = String.fromCharCode(7); + const BS = String.fromCharCode(92); // backslash + const raw = 'line1' + NL + 'line2' + TAB + 'col' + CR + 'end' + BELL + 'bell'; + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'p' }, o: { type: 'literal', value: raw } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + // Expected: two-char escapes BS+n / BS+t / BS+r and the BS+u0007 form. + const expected = '"' + 'line1' + BS + 'n' + 'line2' + BS + 't' + 'col' + BS + 'r' + 'end' + BS + 'u0007' + 'bell' + '"'; + expect(out[0].object).toBe(expected); + // No RAW control bytes survive in the output. + // eslint-disable-next-line no-control-regex + expect(out[0].object).not.toMatch(new RegExp('[\\u0000-\\u001F]')); + expect(out[0].object.startsWith('"')).toBe(true); + }); + + // Codex round-5 — a SPARQL-JSON blank node must come back as `_:` + // (the form `useMemoryEntities.isUri` recognises as a resource), NOT the + // bare identifier (which is neither a leading-`"` literal nor an + // IRI-with-scheme → misclassified, bnode RDF structure lost). + it('renders a blank-node object as _: (resource, not literal/bare)', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'http://example/part' }, o: { type: 'bnode', value: 'b0' } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + expect(out[0].object).toBe('_:b0'); + // Sanity: it does NOT look like a literal (no leading quote). + expect(out[0].object.startsWith('"')).toBe(false); + }); }); // Make-or-break (#864 rebase): the REAL list→detail data flow, NOT a @@ -260,4 +296,55 @@ describe('listAssertions(wm) partition graphUri → fetchAssertionState resolves expect(body.sparql).toContain('UNION'); expect(body.sparql).toContain('assertionGraph'); }); + + // Codex round-3 finding 2 — an SWM input (lifecycle URN) whose + // dkg:assertionGraph did NOT resolve (legacy/partial _meta row) must + // NOT echo the URN as assertionGraph (that would make + // fetchAssertionTriples query `GRAPH ` — a bogus + // empty render). Return undefined → the panes show their empty-state. + it('SWM lifecycle-URN with UNRESOLVED dkg:assertionGraph → assertionGraph undefined (not the URN)', async () => { + const LIFECYCLE = 'urn:dkg:assertion:cg-A:0xabc:legacy-no-graph'; + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ state: { value: 'promoted' }, layer: { value: 'SWM' } }] }, // no assertionGraph + })); + const stateInfo = await fetchAssertionState('cg-A', LIFECYCLE); + expect(stateInfo).not.toBeNull(); + expect(stateInfo!.state).toBe('promoted'); + // Must NOT be the lifecycle URN; undefined so the triples pane is empty. + expect(stateInfo!.assertionGraph).toBeUndefined(); + }); + + // Counterpart — a WM data-graph-URI input with no resolved + // dkg:assertionGraph (it IS the data graph) keeps echoing itself. + it('WM data-graph-URI input with no resolved dkg:assertionGraph echoes the input (it IS the data graph)', async () => { + const PARTITION = 'did:dkg:context-graph:cg-A/assertion/0xabc/notes'; + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ state: { value: 'created' }, layer: { value: 'WM' } }] }, // no assertionGraph binding + })); + const stateInfo = await fetchAssertionState('cg-A', PARTITION); + expect(stateInfo!.assertionGraph).toBe(PARTITION); + }); + + // Codex round-4 — branch A must bind ?lifecycle UNCONDITIONALLY with the + // dkg:assertionGraph match OPTIONAL, so an SWM lifecycle-URN row whose + // _meta carries dkg:state but NOT dkg:assertionGraph (legacy/partial) + // still resolves its state. Pin the SPARQL shape so it can't regress to + // requiring the assertionGraph triple to bind the lifecycle subject. + it('SPARQL: branch A binds the input AS ?lifecycle unconditionally + OPTIONAL assertionGraph (round-4)', async () => { + const LIFECYCLE = 'urn:dkg:assertion:cg-A:0xabc:legacy-no-graph'; + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ state: { value: 'promoted' }, layer: { value: 'SWM' } }] }, + })); + const stateInfo = await fetchAssertionState('cg-A', LIFECYCLE); + // State still resolves (the round-4 outcome) and assertionGraph stays + // undefined (round-3 guard composes). + expect(stateInfo!.state).toBe('promoted'); + expect(stateInfo!.assertionGraph).toBeUndefined(); + const sparql = JSON.parse(fetchMock.mock.calls[0][1].body).sparql as string; + // Unconditional BIND of the input as the lifecycle subject… + expect(sparql).toContain(`BIND(<${LIFECYCLE}> AS ?lifecycle)`); + // …with the assertionGraph match OPTIONAL (branch A no longer REQUIRES + // ` dkg:assertionGraph ?assertionGraph` to bind ?lifecycle). + expect(sparql).toMatch(/OPTIONAL\s*\{\s*\s*\s*\?assertionGraph\s*\}/); + }); });