@principal-ai/subsystems-react 0.43.0 → 0.43.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,19 +18,19 @@ export interface SubsystemFileTreeProps {
18
18
  /** File to transiently highlight while its node is hovered on the graph;
19
19
  * falls back to `selectedFile` when null. */
20
20
  hoveredFile?: string | null;
21
+ /** Boundary path (folder or file) to transiently highlight — e.g. hovering a
22
+ * module/directory frame on the canvas. Matched with or without a trailing
23
+ * slash. Takes a back seat to `hoveredFile`. */
24
+ hoveredFolder?: string | null;
21
25
  /** Called when a file row is clicked; upstream toggles the drawer. */
22
26
  onSelectFile?: (file: string) => void;
23
- /** Called on hover in/out of a file row — directories and non-file rows
24
- * emit null. Pierre exposes no hover API, so this rides a shadow-DOM
25
- * `mouseover` listener (see below). */
26
- onHoverFile?: (file: string | null) => void;
27
27
  /** Hide the built-in "Files N" header row — used when an upstream repo
28
28
  * header already labels the tree. */
29
29
  headerless?: boolean;
30
30
  }
31
31
 
32
32
  /** Fills its parent height by design — pin it with a sized flex container. */
33
- export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFile, onHoverFile, headerless }: SubsystemFileTreeProps) {
33
+ export function SubsystemFileTree({ files, selectedFile, hoveredFile, hoveredFolder, onSelectFile, headerless }: SubsystemFileTreeProps) {
34
34
  const { theme } = useTheme();
35
35
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
36
36
  const paths = useMemo(() => Array.from(new Set(files)).sort(), [files]);
@@ -42,8 +42,6 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFi
42
42
  pathSetRef.current = pathSet;
43
43
  const onSelectFileRef = useRef(onSelectFile);
44
44
  onSelectFileRef.current = onSelectFile;
45
- const onHoverFileRef = useRef(onHoverFile);
46
- onHoverFileRef.current = onHoverFile;
47
45
  const selectedFileRef = useRef<string | null>(selectedFile ?? null);
48
46
  selectedFileRef.current = selectedFile ?? null;
49
47
  // Suppresses onSelectFile while selection is driven programmatically from
@@ -96,59 +94,28 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFi
96
94
  useEffect(() => {
97
95
  hoveredRowRef.current?.style.removeProperty('background');
98
96
  hoveredRowRef.current = null;
99
- const target = hoveredFile ?? null;
100
- if (!target || !pathSetRef.current.has(target)) return;
97
+ const target = hoveredFile ?? hoveredFolder ?? null;
98
+ if (!target) return;
99
+ // Files are keyed exact; folder rows may carry a trailing slash. Try both.
100
+ const candidates = hoveredFile ? [target] : [target, `${target}/`];
101
101
 
102
102
  // Depth-first walk across shadow roots — handles nested custom elements.
103
103
  const roots: Array<Document | ShadowRoot> = [document];
104
104
  while (roots.length > 0) {
105
105
  const root = roots.shift()!;
106
- const row = root.querySelector(`[data-item-path="${CSS.escape(target)}"]`);
107
- if (row instanceof HTMLElement) {
108
- row.style.background = 'var(--trees-theme-list-hover-bg, rgba(128, 128, 128, 0.25))';
109
- hoveredRowRef.current = row;
110
- return;
106
+ for (const candidate of candidates) {
107
+ const row = root.querySelector(`[data-item-path="${CSS.escape(candidate)}"]`);
108
+ if (row instanceof HTMLElement) {
109
+ row.style.background = 'var(--trees-theme-list-hover-bg, rgba(128, 128, 128, 0.25))';
110
+ hoveredRowRef.current = row;
111
+ return;
112
+ }
111
113
  }
112
114
  for (const el of Array.from(root.querySelectorAll('*'))) {
113
115
  if (el.shadowRoot) roots.push(el.shadowRoot);
114
116
  }
115
117
  }
116
- }, [hoveredFile]);
117
-
118
- // Hover notifications. Pierre exposes no hover callback hook, but its rows
119
- // carry `data-item-path` / `data-item-type` inside an OPEN shadow root and
120
- // it binds no `mouseover`/`mouseleave` handlers, so native mouse events
121
- // bubble out to this wrapper. Resolve the row via `composedPath()` (which
122
- // crosses the shadow boundary even though `event.target` is retargeted to
123
- // the host), then emit the repo-root-relative path. Directories emit null.
124
- const lastHoveredRef = useRef<string | null>(null);
125
- useEffect(() => {
126
- const host = treeRef.current;
127
- if (!host) return;
128
- const resolveFile = (e: MouseEvent): string | null => {
129
- for (const el of e.composedPath()) {
130
- if (!(el instanceof HTMLElement) || el.dataset.type !== 'item') continue;
131
- if (el.dataset.itemType === 'folder') return null;
132
- const raw = el.dataset.itemPath ?? '';
133
- const path = raw.endsWith('/') ? raw.slice(0, -1) : raw;
134
- return pathSetRef.current.has(path) ? path : null;
135
- }
136
- return null;
137
- };
138
- const emit = (file: string | null) => {
139
- if (lastHoveredRef.current === file) return;
140
- lastHoveredRef.current = file;
141
- onHoverFileRef.current?.(file);
142
- };
143
- const handleOver = (e: MouseEvent) => emit(resolveFile(e));
144
- const handleLeave = () => emit(null);
145
- host.addEventListener('mouseover', handleOver);
146
- host.addEventListener('mouseleave', handleLeave);
147
- return () => {
148
- host.removeEventListener('mouseover', handleOver);
149
- host.removeEventListener('mouseleave', handleLeave);
150
- };
151
- }, []);
118
+ }, [hoveredFile, hoveredFolder]);
152
119
 
153
120
  // Re-scope the tree in place when the subsystem's file set changes.
154
121
  useEffect(() => {
@@ -22,7 +22,9 @@ import {
22
22
  boundaryMinWidthForBadge,
23
23
  MODULE_BADGE_INSET,
24
24
  packageGroupNodeId,
25
+ directoryGroupNodeId,
25
26
  buildBoundaryLayoutGroups,
27
+ buildBoundaryLayoutGroupsByPath,
26
28
  buildSubsystemGraph,
27
29
  componentPackageKey,
28
30
  deriveNameFromSymbol,
@@ -851,6 +853,152 @@ describe('isConstructsOnlyModel', () => {
851
853
  }),
852
854
  ).toBe(false);
853
855
  });
856
+
857
+ test('false when module/process containment frames exist, even with no edges', () => {
858
+ expect(
859
+ isConstructsOnlyModel({
860
+ components: [
861
+ { alias: 'a', module: 'src/a.ts' },
862
+ { alias: 'b', module: 'src/b.ts' },
863
+ ],
864
+ }),
865
+ ).toBe(false);
866
+ expect(
867
+ isConstructsOnlyModel({
868
+ components: [{ alias: 'a', process: 'app/server' }],
869
+ }),
870
+ ).toBe(false);
871
+ // Blank/whitespace membership is not a frame.
872
+ expect(
873
+ isConstructsOnlyModel({ components: [{ alias: 'a', module: ' ' }] }),
874
+ ).toBe(true);
875
+ });
876
+ });
877
+
878
+ describe('buildBoundaryLayoutGroupsByPath', () => {
879
+ const pathComps: SubsystemComponent[] = [
880
+ { alias: 'record', name: 'record', construct: 'type_alias', file: 'src/session/transcript.ts', module: 'src/session/transcript.ts', purl: 'pkg:github/p/app' },
881
+ { alias: 'parse', name: 'parse', construct: 'function', file: 'src/session/transcript.ts', module: 'src/session/transcript.ts', purl: 'pkg:github/p/app' },
882
+ { alias: 'tool-name', name: 'toolName', construct: 'function', file: 'src/session/paths.ts', module: 'src/session/paths.ts', purl: 'pkg:github/p/app' },
883
+ { alias: 'boot', name: 'boot', construct: 'function', file: 'src/host/main.ts', module: 'src/host/main.ts', purl: 'pkg:github/p/app' },
884
+ { alias: 'store', name: 'store', construct: 'store', file: 'src/host/store.ts', module: 'src/host/store.ts', purl: 'pkg:github/p/app' },
885
+ ];
886
+
887
+ test('derives directory frames and nests modules under them', () => {
888
+ const groups = buildBoundaryLayoutGroupsByPath(
889
+ { components: pathComps },
890
+ { showSingletonFrames: true },
891
+ );
892
+ const byId = new Map(groups.map((g) => [g.id, g]));
893
+
894
+ expect(byId.has(directoryGroupNodeId('src'))).toBe(true);
895
+ expect(byId.has(directoryGroupNodeId('src/session'))).toBe(true);
896
+ expect(byId.has(directoryGroupNodeId('src/host'))).toBe(true);
897
+
898
+ // directory → directory nesting
899
+ expect(byId.get(directoryGroupNodeId('src/session'))?.parentId).toBe(
900
+ directoryGroupNodeId('src'),
901
+ );
902
+ // module → directory nesting
903
+ expect(byId.get(moduleGroupNodeId('src/session/transcript.ts'))?.parentId).toBe(
904
+ directoryGroupNodeId('src/session'),
905
+ );
906
+ // top directory has no frame above it
907
+ expect(byId.get(directoryGroupNodeId('src'))?.parentId).toBeUndefined();
908
+
909
+ // Labels are relative to the parent frame, not the full path.
910
+ expect(byId.get(directoryGroupNodeId('src'))?.region.label).toBe('src');
911
+ expect(byId.get(directoryGroupNodeId('src/session'))?.region.label).toBe('session');
912
+ expect(byId.get(moduleGroupNodeId('src/session/transcript.ts'))?.region.label).toBe(
913
+ 'transcript.ts',
914
+ );
915
+ });
916
+
917
+ test('each leaf is claimed by exactly one group (no compound duplicates)', () => {
918
+ const groups = buildBoundaryLayoutGroupsByPath(
919
+ { components: pathComps },
920
+ { showSingletonFrames: true },
921
+ );
922
+ const groupIds = new Set(groups.map((g) => g.id));
923
+ const leafCount = new Map<string, number>();
924
+ for (const g of groups) {
925
+ for (const m of g.memberAliases) {
926
+ // group ids are allowed to repeat (as child references); leaves must not
927
+ if (groupIds.has(m)) continue;
928
+ leafCount.set(m, (leafCount.get(m) ?? 0) + 1);
929
+ }
930
+ }
931
+ for (const [alias, n] of leafCount) {
932
+ expect(n, `${alias} claimed ${n} times`).toBe(1);
933
+ }
934
+ });
935
+
936
+ test('compacts single-child directory chains (app → app/book)', () => {
937
+ const comps: SubsystemComponent[] = [
938
+ { alias: 'page', name: 'page', construct: 'function', file: 'app/book/page.tsx', module: 'app/book/page.tsx', purl: 'pkg:github/p/app' },
939
+ { alias: 'actions', name: 'actions', construct: 'function', file: 'app/book/actions.ts', module: 'app/book/actions.ts', purl: 'pkg:github/p/app' },
940
+ { alias: 'lib-a', name: 'a', construct: 'function', file: 'lib/a.ts', module: 'lib/a.ts', purl: 'pkg:github/p/app' },
941
+ { alias: 'lib-b', name: 'b', construct: 'function', file: 'lib/b.ts', module: 'lib/b.ts', purl: 'pkg:github/p/app' },
942
+ ];
943
+ const groups = buildBoundaryLayoutGroupsByPath(
944
+ { components: comps },
945
+ { showSingletonFrames: true },
946
+ );
947
+ const ids = new Set(groups.map((g) => g.id));
948
+ // `app` has a single child dir and no direct files → merged into `app/book`.
949
+ expect(ids.has(directoryGroupNodeId('app'))).toBe(false);
950
+ expect(ids.has(directoryGroupNodeId('app/book'))).toBe(true);
951
+ const appBook = groups.find((g) => g.id === directoryGroupNodeId('app/book'))!;
952
+ expect(appBook.parentId).toBeUndefined();
953
+ expect(appBook.region.label).toBe('app/book');
954
+ expect(ids.has(directoryGroupNodeId('lib'))).toBe(true);
955
+ });
956
+
957
+ test('module directories are scoped to their process (process stays primary)', () => {
958
+ const comps: SubsystemComponent[] = [
959
+ { alias: 'page', name: 'page', construct: 'function', file: 'app/book/page.tsx', module: 'app/book/page.tsx', process: 'web/client', purl: 'pkg:github/p/app' },
960
+ { alias: 'a1', name: 'a1', construct: 'function', file: 'app/book/actions.ts', module: 'app/book/actions.ts', process: 'web/server', purl: 'pkg:github/p/app' },
961
+ { alias: 'a2', name: 'a2', construct: 'function', file: 'app/book/actions.ts', module: 'app/book/actions.ts', process: 'web/server', purl: 'pkg:github/p/app' },
962
+ ];
963
+ const groups = buildBoundaryLayoutGroupsByPath(
964
+ { components: comps },
965
+ { showSingletonFrames: true },
966
+ );
967
+ const client = groups.find((g) => g.id === processGroupNodeId('web/client'))!;
968
+ const server = groups.find((g) => g.id === processGroupNodeId('web/server'))!;
969
+ expect(client).toBeDefined();
970
+ expect(server).toBeDefined();
971
+
972
+ const clientDirs = groups.filter(
973
+ (g) => g.region.kind === 'directory' && g.parentId === processGroupNodeId('web/client'),
974
+ );
975
+ const serverDirs = groups.filter(
976
+ (g) => g.region.kind === 'directory' && g.parentId === processGroupNodeId('web/server'),
977
+ );
978
+ // Same folder path is split per process — separate, scoped frames.
979
+ expect(clientDirs.map((g) => g.region.key)).toEqual(['app/book']);
980
+ expect(serverDirs.map((g) => g.region.key)).toEqual(['app/book']);
981
+ expect(clientDirs[0]!.id).not.toBe(serverDirs[0]!.id);
982
+ // Process frames own those directories as children (not dropped/empty).
983
+ expect(client.memberAliases).toContain(clientDirs[0]!.id);
984
+ expect(server.memberAliases).toContain(serverDirs[0]!.id);
985
+ });
986
+
987
+ test('exact mode adds no directory frames', () => {
988
+ const groups = buildBoundaryLayoutGroups(
989
+ { components: pathComps },
990
+ { showSingletonFrames: true, moduleNesting: 'exact' },
991
+ );
992
+ expect(groups.some((g) => g.region.kind === 'directory')).toBe(false);
993
+ });
994
+
995
+ test('path mode is selected by the moduleNesting option', () => {
996
+ const groups = buildBoundaryLayoutGroups(
997
+ { components: pathComps },
998
+ { showSingletonFrames: true, moduleNesting: 'path' },
999
+ );
1000
+ expect(groups.some((g) => g.region.kind === 'directory')).toBe(true);
1001
+ });
854
1002
  });
855
1003
 
856
1004
  describe('describeConstructBreakdown', () => {