@principal-ai/subsystems-react 0.43.1 → 0.44.0

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.
@@ -1062,23 +1062,49 @@ export function buildBoundaryLayoutGroupsByPath(
1062
1062
  keepRegion(r, showSingletons),
1063
1063
  );
1064
1064
 
1065
- // Directory regions: every ancestor directory of a component's module path,
1066
- // with the union of all aliases beneath it.
1065
+ // Directory nesting is SCOPED to the enclosing process (else package, else
1066
+ // root), so module grouping stays subordinate to process boundaries. A
1067
+ // folder that spans two processes is split per process rather than becoming
1068
+ // a frame that belongs to neither — which would leave the process frames
1069
+ // childless and drop them. Composite key = `${scopeKey}\0${path}`.
1070
+ const scopeKeyFor = (c: SubsystemComponent): string => {
1071
+ const p = c.process?.trim();
1072
+ if (p && keptProcessKeys.has(p)) return `p:${p}`;
1073
+ const pkg = componentPackageKey(c);
1074
+ if (pkg && keptPackageKeys.has(pkg)) return `k:${pkg}`;
1075
+ return 'root';
1076
+ };
1077
+ const scopeParentId = (scopeKey: string): string | undefined => {
1078
+ if (scopeKey.startsWith('p:')) return processGroupNodeId(scopeKey.slice(2));
1079
+ if (scopeKey.startsWith('k:')) return packageGroupNodeId(scopeKey.slice(2));
1080
+ return undefined;
1081
+ };
1082
+ const dirGroupId = (scopeKey: string, path: string): string =>
1083
+ scopeKey === 'root'
1084
+ ? directoryGroupNodeId(path)
1085
+ : `directory:${scopeKey}::${path}`;
1086
+
1067
1087
  const dirMembers = new Map<string, Set<string>>();
1088
+ const dirPathOf = new Map<string, string>();
1089
+ const dirScopeOf = new Map<string, string>();
1068
1090
  for (const c of doc.components) {
1069
1091
  const m = c.module?.trim();
1070
1092
  if (!m) continue;
1093
+ const scope = scopeKeyFor(c);
1071
1094
  let d = parentDirectory(m);
1072
1095
  while (d) {
1073
- const set = dirMembers.get(d) ?? new Set<string>();
1096
+ const ck = `${scope}\0${d}`;
1097
+ const set = dirMembers.get(ck) ?? new Set<string>();
1074
1098
  set.add(c.alias);
1075
- dirMembers.set(d, set);
1099
+ dirMembers.set(ck, set);
1100
+ dirPathOf.set(ck, d);
1101
+ dirScopeOf.set(ck, scope);
1076
1102
  d = parentDirectory(d);
1077
1103
  }
1078
1104
  }
1079
- const keptDirKeys = new Set<string>();
1080
- for (const [d, members] of dirMembers) {
1081
- if (members.size >= 2 || showSingletons) keptDirKeys.add(d);
1105
+ const keptDirs = new Set<string>();
1106
+ for (const [ck, members] of dirMembers) {
1107
+ if (members.size >= 2 || showSingletons) keptDirs.add(ck);
1082
1108
  }
1083
1109
 
1084
1110
  // Files sitting directly in each directory (drives chain compaction below).
@@ -1087,76 +1113,74 @@ export function buildBoundaryLayoutGroupsByPath(
1087
1113
  const m = c.module?.trim();
1088
1114
  if (!m) continue;
1089
1115
  const d = parentDirectory(m);
1090
- if (d) directFileCount.set(d, (directFileCount.get(d) ?? 0) + 1);
1116
+ if (!d) continue;
1117
+ const ck = `${scopeKeyFor(c)}\0${d}`;
1118
+ directFileCount.set(ck, (directFileCount.get(ck) ?? 0) + 1);
1091
1119
  }
1092
- // Compact single-child directory chains, mirroring the file tree: a directory
1093
- // whose only child is a single subdirectory (and which holds no files
1094
- // directly) is merged into it. `app` → `app/book` renders one `app/book`
1095
- // frame, not `app` wrapping `book`.
1120
+ const nearestKeptDirCk = (ck: string): string | undefined => {
1121
+ const scope = dirScopeOf.get(ck)!;
1122
+ let a = parentDirectory(dirPathOf.get(ck)!);
1123
+ while (a) {
1124
+ const cand = `${scope}\0${a}`;
1125
+ if (keptDirs.has(cand)) return cand;
1126
+ a = parentDirectory(a);
1127
+ }
1128
+ return undefined;
1129
+ };
1130
+ // Compact single-child directory chains within a scope, mirroring the file
1131
+ // tree: `app` → `app/book` renders one `app/book` frame, not `app` wrapping
1132
+ // `book`.
1096
1133
  let compacting = true;
1097
1134
  while (compacting) {
1098
1135
  compacting = false;
1099
1136
  const childDirsOf = new Map<string, string[]>();
1100
- for (const d of keptDirKeys) {
1101
- let a = parentDirectory(d);
1102
- while (a && !keptDirKeys.has(a)) a = parentDirectory(a);
1137
+ for (const ck of keptDirs) {
1138
+ const a = nearestKeptDirCk(ck);
1103
1139
  if (!a) continue;
1104
1140
  const list = childDirsOf.get(a) ?? [];
1105
- list.push(d);
1141
+ list.push(ck);
1106
1142
  childDirsOf.set(a, list);
1107
1143
  }
1108
- for (const d of [...keptDirKeys]) {
1109
- const childDirs = childDirsOf.get(d) ?? [];
1110
- if (childDirs.length === 1 && (directFileCount.get(d) ?? 0) === 0) {
1111
- keptDirKeys.delete(d);
1144
+ for (const ck of [...keptDirs]) {
1145
+ if (
1146
+ (childDirsOf.get(ck) ?? []).length === 1 &&
1147
+ (directFileCount.get(ck) ?? 0) === 0
1148
+ ) {
1149
+ keptDirs.delete(ck);
1112
1150
  compacting = true;
1113
1151
  }
1114
1152
  }
1115
1153
  }
1154
+ const dirLabel = (ck: string): string => {
1155
+ const path = dirPathOf.get(ck)!;
1156
+ const ancestor = nearestKeptDirCk(ck);
1157
+ return ancestor ? path.slice(dirPathOf.get(ancestor)!.length + 1) : path;
1158
+ };
1116
1159
 
1117
- const nearestKeptDirAncestor = (dirKey: string): string | undefined => {
1118
- let p = parentDirectory(dirKey);
1119
- while (p) {
1120
- if (keptDirKeys.has(p)) return p;
1121
- p = parentDirectory(p);
1160
+ const moduleRegionByKey = new Map(modules.map((r) => [r.key, r]));
1161
+ // A module's scope is uniform across its members (else undefined → root), so
1162
+ // a module straddling processes is never forced into one.
1163
+ const moduleScope = new Map<string, string | undefined>();
1164
+ for (const r of modules) {
1165
+ const scopes = new Set<string>();
1166
+ for (const a of r.memberAliases) {
1167
+ const c = byAlias.get(a);
1168
+ if (c) scopes.add(scopeKeyFor(c));
1122
1169
  }
1123
- return undefined;
1124
- };
1125
- const dirRegions: SubsystemProcessRegion[] = [...keptDirKeys]
1126
- .sort()
1127
- .map((key) => {
1128
- // Label is the path relative to the enclosing kept directory (which
1129
- // already carries the prefix): `book` under `app`, or the full collapsed
1130
- // path `app/book` when the chain was compacted.
1131
- const ancestor = nearestKeptDirAncestor(key);
1132
- return {
1133
- kind: 'directory' as const,
1134
- key,
1135
- label: ancestor ? key.slice(ancestor.length + 1) : key,
1136
- memberAliases: [...(dirMembers.get(key) ?? [])],
1137
- };
1138
- });
1139
- const moduleDirParent = (moduleKey: string): string | undefined => {
1170
+ moduleScope.set(r.key, scopes.size === 1 ? [...scopes][0] : undefined);
1171
+ }
1172
+ const moduleDirCk = (moduleKey: string): string | undefined => {
1173
+ const scope = moduleScope.get(moduleKey);
1174
+ if (scope === undefined) return undefined;
1140
1175
  let d = parentDirectory(moduleKey);
1141
1176
  while (d) {
1142
- if (keptDirKeys.has(d)) return d;
1177
+ const cand = `${scope}\0${d}`;
1178
+ if (keptDirs.has(cand)) return cand;
1143
1179
  d = parentDirectory(d);
1144
1180
  }
1145
1181
  return undefined;
1146
1182
  };
1147
1183
 
1148
- const sharedProcessParent = (
1149
- memberAliases: readonly string[],
1150
- ): string | undefined => {
1151
- const seen = new Set<string>();
1152
- for (const alias of memberAliases) {
1153
- const p = byAlias.get(alias)?.process?.trim();
1154
- if (p) seen.add(p);
1155
- }
1156
- if (seen.size !== 1) return undefined;
1157
- const p = [...seen][0]!;
1158
- return keptProcessKeys.has(p) ? processGroupNodeId(p) : undefined;
1159
- };
1160
1184
  const sharedPackageParent = (
1161
1185
  memberAliases: readonly string[],
1162
1186
  ): string | undefined => {
@@ -1170,17 +1194,15 @@ export function buildBoundaryLayoutGroupsByPath(
1170
1194
  const pkg = [...seen][0]!;
1171
1195
  return keptPackageKeys.has(pkg) ? packageGroupNodeId(pkg) : undefined;
1172
1196
  };
1173
- const frameParent = (memberAliases: readonly string[]): string | undefined =>
1174
- sharedProcessParent(memberAliases) ?? sharedPackageParent(memberAliases);
1175
1197
 
1176
1198
  const moduleGroups: BoundaryLayoutGroup[] = modules.map((r) => {
1177
- const dirParent = moduleDirParent(r.key);
1178
- const parentId = dirParent
1179
- ? directoryGroupNodeId(dirParent)
1180
- : frameParent(r.memberAliases);
1199
+ const ck = moduleDirCk(r.key);
1200
+ const parentId = ck
1201
+ ? dirGroupId(dirScopeOf.get(ck)!, dirPathOf.get(ck)!)
1202
+ : scopeParentId(moduleScope.get(r.key) ?? 'root');
1181
1203
  // Relative label when nested: `app/book/actions.ts` under `app/book`
1182
1204
  // reads as `actions.ts`. At the root the full module path stays.
1183
- const label = dirParent ? r.key.slice(dirParent.length + 1) : r.label;
1205
+ const label = ck ? r.key.slice(dirPathOf.get(ck)!.length + 1) : r.label;
1184
1206
  return {
1185
1207
  id: moduleGroupNodeId(r.key),
1186
1208
  memberAliases: [...r.memberAliases],
@@ -1189,41 +1211,46 @@ export function buildBoundaryLayoutGroupsByPath(
1189
1211
  };
1190
1212
  });
1191
1213
 
1192
- const dirGroupByKey = new Map(dirRegions.map((r) => [r.key, r]));
1193
- const moduleRegionByKey = new Map(modules.map((r) => [r.key, r]));
1194
- const dirGroups: BoundaryLayoutGroup[] = dirRegions.map((r) => {
1195
- const id = directoryGroupNodeId(r.key);
1196
- const ancestor = nearestKeptDirAncestor(r.key);
1214
+ const dirGroups: BoundaryLayoutGroup[] = [...keptDirs].map((ck) => {
1215
+ const scope = dirScopeOf.get(ck)!;
1216
+ const path = dirPathOf.get(ck)!;
1217
+ const ancestor = nearestKeptDirCk(ck);
1197
1218
  const parentId = ancestor
1198
- ? directoryGroupNodeId(ancestor)
1199
- : frameParent(r.memberAliases);
1219
+ ? dirGroupId(dirScopeOf.get(ancestor)!, dirPathOf.get(ancestor)!)
1220
+ : scopeParentId(scope);
1200
1221
  // Immediate children: subdirectories and module frames parented here.
1201
1222
  // Only child *group ids* go in the layout member list — a group's
1202
1223
  // descendant leaves belong to its child groups alone, or ELK sees the
1203
1224
  // same leaf in two compound parents and rejects the layout.
1204
- const childDirKeys = dirRegions
1205
- .filter((d) => nearestKeptDirAncestor(d.key) === r.key)
1206
- .map((d) => d.key);
1225
+ const childDirCks = [...keptDirs].filter((d) => nearestKeptDirCk(d) === ck);
1207
1226
  const childModuleKeys = modules
1208
- .filter((m) => moduleDirParent(m.key) === r.key)
1227
+ .filter((m) => moduleDirCk(m.key) === ck)
1209
1228
  .map((m) => m.key);
1210
1229
  const claimed = new Set<string>();
1211
- for (const dk of childDirKeys) {
1212
- for (const a of dirGroupByKey.get(dk)?.memberAliases ?? []) claimed.add(a);
1230
+ for (const d of childDirCks) {
1231
+ for (const a of dirMembers.get(d) ?? []) claimed.add(a);
1213
1232
  }
1214
1233
  for (const mk of childModuleKeys) {
1215
1234
  for (const a of moduleRegionByKey.get(mk)?.memberAliases ?? []) claimed.add(a);
1216
1235
  }
1217
- const directLeaves = r.memberAliases.filter((a) => !claimed.has(a));
1236
+ const directLeaves = [...(dirMembers.get(ck) ?? [])].filter(
1237
+ (a) => !claimed.has(a),
1238
+ );
1239
+ const region: SubsystemProcessRegion = {
1240
+ kind: 'directory',
1241
+ key: path,
1242
+ label: dirLabel(ck),
1243
+ memberAliases: [...(dirMembers.get(ck) ?? [])],
1244
+ };
1218
1245
  return {
1219
- id,
1246
+ id: dirGroupId(scope, path),
1220
1247
  memberAliases: [
1221
- ...childDirKeys.map(directoryGroupNodeId),
1248
+ ...childDirCks.map((d) => dirGroupId(dirScopeOf.get(d)!, dirPathOf.get(d)!)),
1222
1249
  ...childModuleKeys.map(moduleGroupNodeId),
1223
1250
  ...directLeaves,
1224
1251
  ],
1225
1252
  parentId,
1226
- region: r,
1253
+ region,
1227
1254
  };
1228
1255
  });
1229
1256
 
@@ -2132,6 +2159,9 @@ export async function buildSubsystemGraph(
2132
2159
  // layered pass place those sibling frames along the horizontal axis
2133
2160
  // while each frame's members stack vertically.
2134
2161
  direction: edges.length > 0 ? 'RIGHT' : 'DOWN',
2162
+ // Edge-less containment: fold the root frames into a column-major grid
2163
+ // so the shape reads closer to square than one long strip.
2164
+ reflowGrid: edges.length === 0,
2135
2165
  nodeSpacing: 60,
2136
2166
  edgeSpacing: 30,
2137
2167
  edgeNodeSpacing: 60,
@@ -94,6 +94,15 @@ export interface ElkLayoutOptions {
94
94
  */
95
95
  orderByLine?: boolean;
96
96
 
97
+ /**
98
+ * Reflow root-level compound frames into a column-major grid after ELK runs:
99
+ * fill each column top-to-bottom, then start the next column to the right.
100
+ * Gives an approximately square shape for edge-less containment graphs, which
101
+ * ELK otherwise packs into one long strip. No effect when edges exist (their
102
+ * routes would need re-routing).
103
+ */
104
+ reflowGrid?: boolean;
105
+
97
106
  /**
98
107
  * Compound groups — each becomes an ELK parent whose `memberIds` are laid
99
108
  * out inside it. `memberIds` may be leaf node ids or other group ids
@@ -529,6 +538,7 @@ export async function computeElkLayout(
529
538
  const edgeLabels = options.edgeLabels;
530
539
  const endpointInset = options.endpointInset ?? 0;
531
540
  const direction = options.direction ?? 'RIGHT';
541
+ const reflowGrid = options.reflowGrid === true;
532
542
  const orderByLine = options.orderByLine ?? false;
533
543
 
534
544
  // Build a map of original node positions BEFORE passing to ELK
@@ -1023,6 +1033,102 @@ export async function computeElkLayout(
1023
1033
  return node;
1024
1034
  });
1025
1035
 
1036
+ // Optional column-major grid reflow for edge-less containment graphs. ELK
1037
+ // packs disconnected root frames into one long strip; fold them into a grid
1038
+ // (fill a column top-to-bottom, then start the next column) so the shape
1039
+ // approaches a square. Root frames are moved; their descendants (relative
1040
+ // group bounds / leaf positions) ride along, and absoluteRects is rebased so
1041
+ // hit-testing stays correct.
1042
+ if (reflowGrid && edges.length === 0) {
1043
+ const rootGroupIds: string[] = [];
1044
+ for (const g of groupDefs) {
1045
+ if (!builtGroups.has(g.id)) continue;
1046
+ if (g.parentId && builtGroups.has(g.parentId)) continue;
1047
+ rootGroupIds.push(g.id);
1048
+ }
1049
+ type GridItem = {
1050
+ id: string;
1051
+ kind: 'group' | 'leaf';
1052
+ x: number;
1053
+ y: number;
1054
+ w: number;
1055
+ h: number;
1056
+ };
1057
+ const items: GridItem[] = [];
1058
+ for (const id of rootGroupIds) {
1059
+ const b = groupBounds.get(id);
1060
+ if (b) items.push({ id, kind: 'group', x: b.x, y: b.y, w: b.width, h: b.height });
1061
+ }
1062
+ for (const id of ungroupedElkNodes.map((nd) => nd.id)) {
1063
+ const rel = elkRelativePositions.get(id) ?? { x: 0, y: 0 };
1064
+ const r = absoluteRects.get(id);
1065
+ items.push({ id, kind: 'leaf', x: rel.x, y: rel.y, w: r?.width ?? 160, h: r?.height ?? 60 });
1066
+ }
1067
+ if (items.length > 1) {
1068
+ const count = items.length;
1069
+ const rows = Math.max(1, Math.ceil(Math.sqrt(count)));
1070
+ const cols = Math.ceil(count / rows);
1071
+ const GAP = 80;
1072
+ const PAD = 40;
1073
+ const colW: number[] = new Array(cols).fill(0);
1074
+ const rowH: number[] = new Array(rows).fill(0);
1075
+ items.forEach((it, i) => {
1076
+ const c = Math.floor(i / rows);
1077
+ const r = i % rows;
1078
+ colW[c] = Math.max(colW[c] ?? 0, it.w);
1079
+ rowH[r] = Math.max(rowH[r] ?? 0, it.h);
1080
+ });
1081
+ const colX: number[] = [];
1082
+ let acc = PAD;
1083
+ for (let c = 0; c < cols; c++) {
1084
+ colX[c] = acc;
1085
+ acc += (colW[c] ?? 0) + GAP;
1086
+ }
1087
+ const rowY: number[] = [];
1088
+ acc = PAD;
1089
+ for (let r = 0; r < rows; r++) {
1090
+ rowY[r] = acc;
1091
+ acc += (rowH[r] ?? 0) + GAP;
1092
+ }
1093
+ const shiftAbsSubtree = (id: string, dx: number, dy: number) => {
1094
+ const ar = absoluteRects.get(id);
1095
+ if (ar) absoluteRects.set(id, { ...ar, x: ar.x + dx, y: ar.y + dy });
1096
+ const g = groupById.get(id);
1097
+ if (!g) return;
1098
+ for (const mid of g.memberIds) {
1099
+ if (builtGroups.has(mid)) shiftAbsSubtree(mid, dx, dy);
1100
+ else {
1101
+ const mar = absoluteRects.get(mid);
1102
+ if (mar) absoluteRects.set(mid, { ...mar, x: mar.x + dx, y: mar.y + dy });
1103
+ }
1104
+ }
1105
+ };
1106
+ const movedLeaves = new Map<string, { x: number; y: number }>();
1107
+ items.forEach((it, i) => {
1108
+ const c = Math.floor(i / rows);
1109
+ const r = i % rows;
1110
+ const dx = (colX[c] ?? 0) - it.x;
1111
+ const dy = (rowY[r] ?? 0) - it.y;
1112
+ if (dx === 0 && dy === 0) return;
1113
+ if (it.kind === 'group') {
1114
+ const b = groupBounds.get(it.id);
1115
+ if (b) groupBounds.set(it.id, { ...b, x: b.x + dx, y: b.y + dy });
1116
+ shiftAbsSubtree(it.id, dx, dy);
1117
+ } else {
1118
+ shiftAbsSubtree(it.id, dx, dy);
1119
+ const rel = elkRelativePositions.get(it.id);
1120
+ if (rel) movedLeaves.set(it.id, { x: rel.x + dx, y: rel.y + dy });
1121
+ }
1122
+ });
1123
+ if (movedLeaves.size > 0) {
1124
+ for (const node of resultNodes) {
1125
+ const m = movedLeaves.get(node.id);
1126
+ if (m) (node as { position: { x: number; y: number } }).position = m;
1127
+ }
1128
+ }
1129
+ }
1130
+ }
1131
+
1026
1132
  return {
1027
1133
  nodes: resultNodes,
1028
1134
  edgePaths,