Skip to content

Commit 0818d95

Browse files
Jurij Skornikclaude
andcommitted
feat(node-ui): M2 option (b) — silent cross-subgraph switch on link follow
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 › <new subgraph> › 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) <noreply@anthropic.com>
1 parent b174efe commit 0818d95

4 files changed

Lines changed: 112 additions & 9 deletions

File tree

packages/node-ui/src/ui/views/ProjectView.tsx

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
useMemoryEntities,
1212
type LayeredTriple,
1313
type TrustLevel,
14+
type MemoryEntity,
1415
} from '../hooks/useMemoryEntities.js';
1516
import { useProjectProfile, ProjectProfileContext } from '../hooks/useProjectProfile.js';
1617
import { useAgents, AgentsContext } from '../hooks/useAgents.js';
@@ -21,7 +22,7 @@ import { ActivityFeed } from '../components/ActivityFeed.js';
2122
import { SubGraphBar } from '../components/SubGraphBar.js';
2223
import { CONTEXT_GRAPH_PRIMER_TAB } from '../lib/contextGraphPrimer.js';
2324
import { useTabsStore } from '../stores/tabs.js';
24-
import { shouldFetchSwmAttribution, type LayerView, type LayerContentTab, type SubGraphTab } from './project/helpers.js';
25+
import { shouldFetchSwmAttribution, primarySubGraphOf, type LayerView, type LayerContentTab, type SubGraphTab } from './project/helpers.js';
2526
import {
2627
ProjectHeaderStrip,
2728
LayerSwitcher,
@@ -224,6 +225,11 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) {
224225
// open without listing the state in its deps.
225226
const selectedAssertionRef = useRef<AssertionInfo | null>(null);
226227
useEffect(() => { selectedAssertionRef.current = selectedAssertion; }, [selectedAssertion]);
228+
// M2 option (b) — mirror the full entity map so `handleNavigate` can
229+
// resolve a navigated entity's primary sub-graph synchronously
230+
// (to decide whether to follow a cross-subgraph link) WITHOUT listing
231+
// the map in its deps and churning the callback identity.
232+
const entitiesRef = useRef<ReadonlyMap<string, MemoryEntity>>(new Map());
227233
const [layerContentTabs, setLayerContentTabs] = useState<Record<MemoryLayerView, LayerContentTab>>(
228234
DEFAULT_LAYER_TABS,
229235
);
@@ -391,6 +397,9 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) {
391397
// DashboardView caller, which already opts in for the same
392398
// failed-vs-empty-distinct reason.
393399
const rawMemory = useMemoryEntities(contextGraphId, { signalErrors: true });
400+
// M2 option (b) — keep the entity-map ref in sync so `handleNavigate`
401+
// can resolve a navigated entity's sub-graph synchronously.
402+
useEffect(() => { entitiesRef.current = rawMemory.entities; }, [rawMemory.entities]);
394403
// SWM attribution drives the SWM graph's agent-tint legend (its
395404
// sole remaining consumer). PR #694 review — the Overview no
396405
// longer reads this stream (lifecycle source replaced it), so the
@@ -658,22 +667,43 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) {
658667
setSubGraphTabs(prev => prev[slug] === tab ? prev : { ...prev, [slug]: tab });
659668
}, []);
660669

661-
// M2 keeps the user's origin stable: linked entities open in the detail
662-
// pane, but the underlying layer/sub-graph page does not silently change
663-
// until S5 adds breadcrumbs that can make that movement visible.
664-
//
665670
// Intent: a brand-new top-level open (no selected entity yet) resets
666671
// the layer context; in-detail navigation (a click inside an open
667672
// detail) keeps the prior layer context. We read both `selectedUri`
668673
// (via ref) and the prior `selectedLayerContext` (via the setter
669674
// `prev` argument) so the callback identity stays stable — listing
670675
// them in deps would re-create `handleNavigate` on every navigation
671676
// and rebuild every downstream memo / callback that consumes it.
677+
//
678+
// M2 option (b) — silent cross-subgraph switch, ENABLED alongside S5.
679+
// When following a link to an entity that lives in a DIFFERENT
680+
// sub-graph than the one currently in scope, switch `activeSubGraph`
681+
// to the target's sub-graph. Pre-S5 this was suppressed (option (a):
682+
// no switch) because the move was invisible; now the breadcrumb makes
683+
// it visible (`Context Graph › <new subgraph> › Entity`), so the
684+
// switch is acceptable. Routed through `setActiveSubGraphSync` (PR
685+
// #793 Bug N) so the breadcrumb's React tree — and the ref-keyed
686+
// discriminators — see the new subgraph synchronously. The M2 origin
687+
// snapshot was captured at the FIRST open, so closing still returns to
688+
// the originating page, not the followed-into one.
689+
//
690+
// Only fires when ALREADY in a sub-graph page (`activeSubGraphRef`):
691+
// opening an entity from a plain layer list must NOT spuriously jump
692+
// into a sub-graph just because the entity happens to belong to one.
672693
const handleNavigate = useCallback((uri: string, originScrollKey?: string, layerContext?: MemoryLayerView) => {
673694
const hadSelection = selectedUriRef.current != null;
674695
openEntityDetail(uri, originScrollKey);
675696
setSelectedLayerContext(prev => layerContext ?? (hadSelection ? prev : null));
676-
}, [openEntityDetail]);
697+
const currentSubGraph = activeSubGraphRef.current;
698+
if (currentSubGraph) {
699+
const targetSubGraph = primarySubGraphOf(
700+
entitiesRef.current.get(uri) ?? entitiesRef.current.get(canonicalEntityUri(uri)),
701+
);
702+
if (targetSubGraph && targetSubGraph !== currentSubGraph) {
703+
setActiveSubGraphSync(targetSubGraph);
704+
}
705+
}
706+
}, [openEntityDetail, setActiveSubGraphSync]);
677707

678708
const handleDetailClose = useCallback(() => {
679709
const origin = detailOriginRef.current;

packages/node-ui/src/ui/views/project/helpers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,21 @@ export function buildAssertionTrail(
10061006
}));
10071007
}
10081008

1009+
/**
1010+
* The primary (first non-`meta`) sub-graph slug an entity has triples
1011+
* in, or null when it lives only in the root bucket / meta. Mirrors the
1012+
* `SubGraphBadge` rule (lowest-rank binding wins; most entities live in
1013+
* exactly one sub-graph). Used by M2 option (b) to decide whether a
1014+
* cross-subgraph entity jump should switch `activeSubGraph`.
1015+
*/
1016+
export function primarySubGraphOf(entity: MemoryEntity | undefined | null): string | null {
1017+
if (!entity) return null;
1018+
for (const s of entity.subGraphs) {
1019+
if (s !== 'meta') return s;
1020+
}
1021+
return null;
1022+
}
1023+
10091024
// ─── S5 Breadcrumb navigation ───────────────────────────────
10101025
// `Context Graph › {Layer | Subgraph} › {Entity | Assertion}`. Lives
10111026
// inline in the persistent ProjectHeaderStrip. Hop content rules

packages/node-ui/test/assertion-detail-helpers.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,17 @@ import {
55
assertionSubgraphLine,
66
buildAssertionTrail,
77
buildBreadcrumbHops,
8+
primarySubGraphOf,
89
} from '../src/ui/views/project/helpers.js';
10+
import type { MemoryEntity } from '../src/ui/hooks/useMemoryEntities.js';
11+
12+
function entity(subGraphs: string[]): MemoryEntity {
13+
return {
14+
uri: 'urn:e', label: 'E', types: [], trustLevel: 'working',
15+
layers: new Set(['working']), subGraphs: new Set(subGraphs),
16+
properties: new Map(), connections: [],
17+
};
18+
}
919

1020
// S4 — pure helpers behind the assertion detail view. These pin the
1121
// lifecycle-trail tone mapping (T01), the Promote CTA visibility
@@ -174,3 +184,20 @@ describe('buildBreadcrumbHops — cross-subgraph update (T05)', () => {
174184
expect(after[2].label).toBe('Entity B');
175185
});
176186
});
187+
188+
describe('primarySubGraphOf — M2(b) cross-subgraph follow decision (T14)', () => {
189+
it('returns the first non-meta subgraph slug', () => {
190+
expect(primarySubGraphOf(entity(['demo']))).toBe('demo');
191+
expect(primarySubGraphOf(entity(['meta', 'research']))).toBe('research');
192+
});
193+
194+
it('returns null for a root-only / meta-only entity', () => {
195+
expect(primarySubGraphOf(entity([]))).toBeNull();
196+
expect(primarySubGraphOf(entity(['meta']))).toBeNull();
197+
});
198+
199+
it('returns null for a missing entity', () => {
200+
expect(primarySubGraphOf(undefined)).toBeNull();
201+
expect(primarySubGraphOf(null)).toBeNull();
202+
});
203+
});

packages/node-ui/test/project-view-navigation.test.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -684,28 +684,59 @@ describe('ProjectView entity detail navigation', () => {
684684
expect(query('overview-card').dataset.participantsStatus).toBe('loading');
685685
});
686686

687-
it('keeps the originating subgraph stable while following cross-subgraph entity links', async () => {
687+
// M2 option (b) — ENABLED alongside S5. Following a link to an entity
688+
// in a DIFFERENT sub-graph switches activeSubGraph (the breadcrumb
689+
// makes the move visible); closing still returns to the ORIGINATING
690+
// sub-graph page (the M2 origin captured at first open). (T14 / T15)
691+
it('follows a cross-subgraph entity link → activeSubGraph switches; close returns to origin', async () => {
688692
await click('switch-subgraphs');
689693
await click('select-subgraph-demo');
690694
await click('subgraph-tab-graph');
691695
expect(query('active-subgraph').textContent).toBe('demo');
692696
expect(query('subgraph-detail').dataset.tab).toBe('graph');
693697

698+
// Open an entity that lives in the CURRENT subgraph — no switch.
694699
await click('open-subgraph-entity');
695700
expect(query('entity-detail').dataset.entity).toBe('urn:entity:demo');
696701
expect(query('active-subgraph').textContent).toBe('demo');
697702

703+
// Follow a link to urn:entity:other (subGraphs: {'other'}) — M2(b)
704+
// switches activeSubGraph to 'other'; the breadcrumb reflects it via
705+
// the active-subgraph mirror.
698706
await click('open-related-entity');
699707
expect(query('entity-detail').dataset.entity).toBe('urn:entity:other');
700-
expect(query('active-subgraph').textContent).toBe('demo');
708+
expect(query('active-subgraph').textContent).toBe('other');
709+
// The breadcrumb's trailing hop tracks the followed entity.
710+
expect(query('project-strip').dataset.detailLabel).toBe('Other entity');
701711

712+
// Close — origin restore returns to the ORIGINATING subgraph (demo),
713+
// not the followed-into one (other). (T15 — origin model intact.)
702714
await click('detail-back');
703715
await flush();
704-
705716
expect(query('subgraph-detail').dataset.slug).toBe('demo');
706717
expect(query('subgraph-detail').dataset.tab).toBe('graph');
707718
});
708719

720+
// T15 — the M2 ORIGINAL behavior (no cross-subgraph involved) is
721+
// untouched: open from a layer list → close → same layer / subtab /
722+
// scroll, and opening from a plain layer does NOT spuriously jump into
723+
// a subgraph just because the entity belongs to one.
724+
it('opening an entity from a plain layer list does NOT switch into a subgraph (M2(b) guard)', async () => {
725+
await click('switch-wm');
726+
// urn:entity:overlap has subGraphs {'demo'} but we open it from the
727+
// WM layer list (no active subgraph) — must stay layer-scoped.
728+
await click('open-layer-overlap-entity');
729+
await flush();
730+
expect(query('entity-detail').dataset.entity).toBe('urn:entity:overlap');
731+
// No subgraph page was entered.
732+
expect(query('active-subgraph').textContent).toBe('none');
733+
734+
await click('detail-back');
735+
await flush();
736+
expect(query('layer-detail').dataset.layer).toBe('wm');
737+
expect(document.querySelector('[data-testid="subgraph-detail"]')).toBeNull();
738+
});
739+
709740
it('clears stale detail origin when the selected entity disappears', async () => {
710741
await click('switch-swm');
711742
await click('open-layer-overlap-entity');

0 commit comments

Comments
 (0)