Skip to content

Commit b174efe

Browse files
Jurij Skornikclaude
andcommitted
feat(node-ui): S5 — breadcrumb navigation; drop in-detail back button
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) <noreply@anthropic.com>
1 parent 05f3156 commit b174efe

9 files changed

Lines changed: 506 additions & 77 deletions

packages/node-ui/src/ui/styles.css

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7970,31 +7970,47 @@ body.light .v10-ka-graph-shell .v10-graph-canvas {
79707970
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-color, #a855f7) 18%, transparent);
79717971
flex-shrink: 0;
79727972
}
7973-
.v10-project-strip-name {
7973+
/* S5 — breadcrumb hops live inline in the project strip. The
7974+
separator is the existing `.v10-project-strip-sep`, now with 6px of
7975+
each-side padding per the §4.7.1 visual spec. The prior
7976+
`.v10-project-strip-name` / `.v10-project-strip-sg` rules are gone —
7977+
the breadcrumb hops replace the project-name button + subgraph chip. */
7978+
.v10-breadcrumb { display: flex; align-items: center; min-width: 0; }
7979+
.v10-breadcrumb-hop {
79747980
font-size: 13px; font-weight: 600;
7975-
color: var(--text-primary);
7976-
background: none; border: none; padding: 0;
7977-
cursor: pointer;
79787981
font-family: var(--font-sans);
7982+
/* Shrink toward the middle hops; first hop keeps its natural width,
7983+
trailing hop is preserved most. */
7984+
flex: 0 1 auto; min-width: 0; max-width: 200px;
7985+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
7986+
}
7987+
/* First hop ("Context Graph") stays intact — it's the orientation
7988+
anchor; only the middle/trailing hops character-truncate. */
7989+
.v10-breadcrumb-hop:first-child { flex: 0 0 auto; max-width: none; }
7990+
.v10-breadcrumb-hop.link {
7991+
background: none; border: none; padding: 0;
7992+
color: var(--text-link); cursor: pointer;
79797993
transition: color 0.15s;
79807994
}
7981-
.v10-project-strip-name:disabled {
7982-
cursor: default; opacity: 1;
7995+
.v10-breadcrumb-hop.link:hover {
7996+
text-decoration: underline; text-decoration-color: currentColor; text-underline-offset: 2px;
79837997
}
7984-
.v10-project-strip-name:not(:disabled):hover {
7985-
color: var(--sg-color, #a855f7);
7998+
.v10-breadcrumb-hop.link:focus-visible {
7999+
outline: 2px solid var(--text-link); outline-offset: 2px; border-radius: 2px;
8000+
}
8001+
/* Trailing "you are here" hop — three intentional differences from
8002+
clickable hops (secondary color, weight 500, no underline, default
8003+
cursor) communicate the current location without a label. */
8004+
.v10-breadcrumb-hop.current {
8005+
color: var(--text-secondary); font-weight: 500; cursor: default;
79868006
}
79878007
.v10-project-strip-sep {
79888008
color: var(--text-ghost);
79898009
font-size: 14px;
79908010
font-weight: 400;
8011+
padding: 0 6px;
8012+
flex-shrink: 0;
79918013
}
7992-
.v10-project-strip-sg {
7993-
display: inline-flex; align-items: center; gap: 5px;
7994-
font-size: 12px; font-weight: 600;
7995-
color: var(--text-secondary);
7996-
}
7997-
.v10-project-strip-sg-icon { font-size: 13px; line-height: 1; }
79988014
.v10-project-strip-desc {
79998015
font-size: 12px; color: var(--text-secondary);
80008016
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,14 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) {
729729
openTab(CONTEXT_GRAPH_PRIMER_TAB);
730730
}, [openTab]);
731731

732+
// S5 — first breadcrumb hop ("Context Graph") navigates to the
733+
// overview, clearing any open detail / subgraph page. Reuses the
734+
// layer-switch reset (clears detail origin + refs) so it can't strand
735+
// a stale subgraph/detail context.
736+
const handleBreadcrumbOverview = useCallback(() => {
737+
handleLayerSwitch('overview');
738+
}, [handleLayerSwitch]);
739+
732740
if (!cg) {
733741
return (
734742
<div className="v10-view-placeholder">
@@ -741,6 +749,15 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) {
741749
// across sub-graph / layer / overview routes.
742750
const activeSubGraphBinding = activeSubGraph ? profile.forSubGraph(activeSubGraph) : null;
743751

752+
// S5 — the breadcrumb's trailing hop is the open detail's name (entity
753+
// label or assertion name); null when no detail is open (the middle hop
754+
// is then the current location).
755+
const breadcrumbDetailLabel = selectedEntity
756+
? selectedEntity.label
757+
: selectedAssertion
758+
? selectedAssertion.name
759+
: null;
760+
744761
// Codex Bug C — gate the `entities`-driven chip-count path on a
745762
// fully-loaded memory snapshot. While `useMemoryEntities` is mid-
746763
// hydration or a layer query is in-flight, `entityList` is partial
@@ -778,8 +795,11 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) {
778795
<ProjectHeaderStrip
779796
cg={cg}
780797
profile={profile}
798+
activeLayer={activeLayer}
781799
activeSubGraph={activeSubGraphBinding}
782-
onClearSubGraph={() => handleSelectSubGraph(null)}
800+
detailLabel={breadcrumbDetailLabel}
801+
onOverview={handleBreadcrumbOverview}
802+
onRestoreOrigin={handleDetailClose}
783803
/>
784804

785805
{/* Layer Switcher — always visible now. Clicking a layer from within
@@ -802,9 +822,9 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) {
802822
allEntities={detailEntities}
803823
allTriples={detailTriples}
804824
onNavigate={handleNavigate}
805-
onClose={handleDetailClose}
806825
contextGraphId={contextGraphId}
807826
onRefresh={rawMemory.refresh}
827+
onOpenAgent={openAgent}
808828
/>
809829
)}
810830

packages/node-ui/src/ui/views/project/components.tsx

Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
filterTriplesToEntities, admitTripleForScope,
6868
entityTimestamp, formatRelativeTime, formatTimelineBucket, formatTrailTimestamp,
6969
canPromoteAssertion, assertionSubgraphLine, buildAssertionTrail,
70+
buildBreadcrumbHops,
7071
type LayerView, type LayerContentTab, type KAPane,
7172
type SubGraphTab, type SubGraphEntitySort,
7273
} from './helpers.js';
@@ -517,15 +518,38 @@ export function LayerSwitcher({ active, counts, onSwitch, onShare, onImport, onR
517518
export function ProjectHeaderStrip({
518519
cg,
519520
profile,
521+
activeLayer,
520522
activeSubGraph,
521-
onClearSubGraph,
523+
detailLabel,
524+
onOverview,
525+
onRestoreOrigin,
522526
}: {
523527
cg: { id: string; name?: string; description?: string };
524528
profile: ReturnType<typeof useProjectProfile>;
529+
/** Current layer route (drives the middle hop when no subgraph). */
530+
activeLayer: LayerView;
525531
activeSubGraph: ReturnType<typeof useProjectProfile>['forSubGraph'] extends (s: string) => infer R ? R | null : null;
526-
onClearSubGraph: () => void;
532+
/** Open detail's name (entity / assertion), or null when none is open. */
533+
detailLabel?: string | null;
534+
/** Navigate to the Context Graph overview (first-hop click). */
535+
onOverview: () => void;
536+
/** Restore the M2 origin — close the open detail back to where it was
537+
* opened (middle-hop click when a detail is open). */
538+
onRestoreOrigin: () => void;
527539
}) {
528540
const name = cg.name || profile.displayName || cg.id;
541+
// S5 — the breadcrumb is the persistent navigation + back-affordance.
542+
// It replaces the prior project-name + subgraph-chip rendering AND the
543+
// old `.v10-ka-back` button (deleted on the detail views).
544+
const hops = buildBreadcrumbHops({
545+
contextGraphName: name,
546+
activeLayer,
547+
activeSubGraph: activeSubGraph?.slug ?? null,
548+
subGraphDisplayName: activeSubGraph?.displayName ?? activeSubGraph?.slug ?? null,
549+
detailLabel,
550+
});
551+
// Subgraph context still tints the strip + surfaces the description.
552+
const description = activeSubGraph?.description ?? cg.description;
529553
return (
530554
<div
531555
className="v10-project-strip"
@@ -537,39 +561,31 @@ export function ProjectHeaderStrip({
537561
className="v10-project-strip-dot"
538562
style={{ background: profile.primaryColor }}
539563
/>
540-
<button
541-
type="button"
542-
className="v10-project-strip-name"
543-
onClick={activeSubGraph ? onClearSubGraph : undefined}
544-
disabled={!activeSubGraph}
545-
title={activeSubGraph ? 'Back to context graph overview' : cg.id}
546-
>
547-
{name}
548-
</button>
549-
{activeSubGraph ? (
550-
<>
551-
<span className="v10-project-strip-sep"></span>
552-
<span className="v10-project-strip-sg">
553-
<span
554-
className="v10-project-strip-sg-icon"
555-
style={{ color: activeSubGraph.color }}
556-
>
557-
{activeSubGraph.icon ?? '•'}
558-
</span>
559-
{activeSubGraph.displayName ?? activeSubGraph.slug}
560-
</span>
561-
{activeSubGraph.description && (
562-
<span className="v10-project-strip-desc" title={activeSubGraph.description}>
563-
{activeSubGraph.description}
564-
</span>
565-
)}
566-
</>
567-
) : (
568-
cg.description && (
569-
<span className="v10-project-strip-desc" title={cg.description}>
570-
{cg.description}
571-
</span>
572-
)
564+
<nav className="v10-breadcrumb" aria-label="Breadcrumb">
565+
{hops.map((hop, i) => (
566+
<React.Fragment key={hop.key}>
567+
{i > 0 && <span className="v10-project-strip-sep" aria-hidden="true"></span>}
568+
{hop.target === 'current' ? (
569+
<span className="v10-breadcrumb-hop current" title={hop.title} aria-current="page">
570+
{hop.label}
571+
</span>
572+
) : (
573+
<button
574+
type="button"
575+
className="v10-breadcrumb-hop link"
576+
title={hop.title}
577+
onClick={hop.target === 'overview' ? onOverview : onRestoreOrigin}
578+
>
579+
{hop.label}
580+
</button>
581+
)}
582+
</React.Fragment>
583+
))}
584+
</nav>
585+
{description && (
586+
<span className="v10-project-strip-desc" title={description}>
587+
{description}
588+
</span>
573589
)}
574590
</div>
575591
);
@@ -3587,12 +3603,11 @@ export function VerifyOnDkgButton({
35873603
);
35883604
}
35893605

3590-
export function KADetailView({ entity, allEntities, allTriples, onNavigate, onClose, contextGraphId, onRefresh, onOpenAgent }: {
3606+
export function KADetailView({ entity, allEntities, allTriples, onNavigate, contextGraphId, onRefresh, onOpenAgent }: {
35913607
entity: MemoryEntity;
35923608
allEntities: Map<string, MemoryEntity>;
35933609
allTriples: Triple[];
35943610
onNavigate: (uri: string) => void;
3595-
onClose: () => void;
35963611
contextGraphId: string;
35973612
onRefresh: () => void;
35983613
onOpenAgent?: (uri: string) => void;
@@ -3716,7 +3731,8 @@ export function KADetailView({ entity, allEntities, allTriples, onNavigate, onCl
37163731
return (
37173732
<div className="v10-ka-detail">
37183733
<div className="v10-ka-header">
3719-
<button className="v10-ka-back" onClick={onClose}>← Back to Context Graph</button>
3734+
{/* S5 — no back button here; the persistent breadcrumb in
3735+
ProjectHeaderStrip is the sole back-affordance. */}
37203736
<div className="v10-ka-header-left">
37213737
<div className="v10-ka-label">{detailNoun}</div>
37223738
<div className="v10-ka-name">

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

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,3 +1005,120 @@ export function buildAssertionTrail(
10051005
isCurrent: state != null && stage.state === state,
10061006
}));
10071007
}
1008+
1009+
// ─── S5 Breadcrumb navigation ───────────────────────────────
1010+
// `Context Graph › {Layer | Subgraph} › {Entity | Assertion}`. Lives
1011+
// inline in the persistent ProjectHeaderStrip. Hop content rules
1012+
// (§4.7.1): the middle hop is EITHER the layer full-name OR the subgraph
1013+
// displayName, never both (layer scope inside a subgraph is signalled by
1014+
// the in-page active-layer chip, not the breadcrumb). The trailing hop
1015+
// is the current location — a non-interactive span, three intentional
1016+
// styling differences from clickable hops ("you are here" without a
1017+
// label).
1018+
1019+
/** Where a clicked breadcrumb hop navigates. */
1020+
export type BreadcrumbTarget =
1021+
| 'overview' // the Context Graph overview (first hop)
1022+
| 'origin' // restore the M2 origin snapshot (close the open detail)
1023+
| 'current'; // the trailing hop — not interactive
1024+
1025+
export interface BreadcrumbHop {
1026+
/** Stable React key. */
1027+
key: string;
1028+
/** Truncatable display label. */
1029+
label: string;
1030+
/** Full text for the unconditional `title=` tooltip. */
1031+
title: string;
1032+
/** Navigation target; `'current'` hops render as a non-interactive span. */
1033+
target: BreadcrumbTarget;
1034+
}
1035+
1036+
const LAYER_FULL_NAME: Record<MemoryLayerKey, string> = {
1037+
wm: 'Working Memory',
1038+
swm: 'Shared Working Memory',
1039+
vm: 'Verifiable Memory',
1040+
};
1041+
1042+
type MemoryLayerKey = 'wm' | 'swm' | 'vm';
1043+
1044+
/**
1045+
* Build the breadcrumb hops for the current ProjectView location.
1046+
*
1047+
* - `Context Graph` is always the first hop, clickable to overview
1048+
* (except when it is itself the current page — then it's the trailing
1049+
* `current` hop with no further hops).
1050+
* - The middle hop is the subgraph displayName when a subgraph page is
1051+
* active, otherwise the active layer's full name. Only one, never both.
1052+
* - The trailing hop (`current`) is the open detail's name (entity or
1053+
* assertion) when a detail is open; otherwise the middle hop itself is
1054+
* the current location.
1055+
*
1056+
* Navigation semantics are attached by the renderer via `target`:
1057+
* non-trailing hops are clickable; clicking the first hop goes to
1058+
* overview, clicking the middle hop (when a detail is open) restores the
1059+
* M2 origin (closes the detail back to where it was opened).
1060+
*/
1061+
export function buildBreadcrumbHops(input: {
1062+
/** The CG display name (first hop label). */
1063+
contextGraphName: string;
1064+
activeLayer: LayerView;
1065+
/** Active subgraph slug, or null when not on a subgraph page. */
1066+
activeSubGraph: string | null;
1067+
/** Active subgraph display name (falls back to slug). */
1068+
subGraphDisplayName?: string | null;
1069+
/** Open detail's name, or null when no detail is open. */
1070+
detailLabel?: string | null;
1071+
}): BreadcrumbHop[] {
1072+
const { contextGraphName, activeLayer, activeSubGraph, subGraphDisplayName, detailLabel } = input;
1073+
const hops: BreadcrumbHop[] = [];
1074+
1075+
// First hop — Context Graph. It is the CURRENT page only when we are on
1076+
// the overview with no subgraph and no open detail.
1077+
const onOverview = !activeSubGraph && activeLayer === 'overview' && !detailLabel;
1078+
hops.push({
1079+
key: 'cg',
1080+
label: contextGraphName,
1081+
title: contextGraphName,
1082+
target: onOverview ? 'current' : 'overview',
1083+
});
1084+
if (onOverview) return hops;
1085+
1086+
// Middle hop — subgraph displayName OR layer full-name (never both).
1087+
const detailOpen = !!detailLabel;
1088+
let middle: { label: string; title: string } | null = null;
1089+
if (activeSubGraph) {
1090+
const name = subGraphDisplayName?.trim() || activeSubGraph;
1091+
middle = { label: name, title: name };
1092+
} else if (activeLayer === 'wm' || activeLayer === 'swm' || activeLayer === 'vm') {
1093+
const name = LAYER_FULL_NAME[activeLayer];
1094+
middle = { label: name, title: name };
1095+
} else if (activeLayer === 'graph-overview') {
1096+
middle = { label: 'Subgraphs', title: 'Subgraphs' };
1097+
} else if (activeLayer === 'query') {
1098+
middle = { label: 'Query Catalogue', title: 'Query Catalogue' };
1099+
}
1100+
1101+
if (middle) {
1102+
hops.push({
1103+
key: 'middle',
1104+
label: middle.label,
1105+
title: middle.title,
1106+
// When a detail is open the middle hop is clickable (restores the
1107+
// origin). When no detail is open the middle hop IS the current
1108+
// location.
1109+
target: detailOpen ? 'origin' : 'current',
1110+
});
1111+
}
1112+
1113+
// Trailing hop — the open detail's name (current location).
1114+
if (detailOpen) {
1115+
hops.push({
1116+
key: 'detail',
1117+
label: detailLabel!,
1118+
title: detailLabel!,
1119+
target: 'current',
1120+
});
1121+
}
1122+
1123+
return hops;
1124+
}

0 commit comments

Comments
 (0)