@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.
@@ -477,15 +477,23 @@ export function deriveGraphEdges(doc: {
477
477
  }
478
478
 
479
479
  /**
480
- * True when the model has components but no walkthrough or graphify edges.
481
- * Those snapshots are a catalog of declarations, not a graph.
480
+ * True when the model has components but no graph structure at all: no
481
+ * walkthrough/graphify edges AND no boundary containment (`module` / `process`)
482
+ * to frame. Those snapshots are a catalog of declarations, not a graph.
483
+ *
484
+ * A model that carries `module` / `process` membership still draws frames even
485
+ * with zero edges — that is static topology, so it is not constructs-only.
482
486
  */
483
487
  export function isConstructsOnlyModel(doc: {
484
- components: readonly { alias: string }[];
488
+ components: readonly { alias: string; module?: string; process?: string }[];
485
489
  walkthroughs?: readonly SubsystemWalkthrough[];
486
490
  graphifyRelations?: readonly SubsystemGraphifyRelation[];
487
491
  }): boolean {
488
492
  if (doc.components.length === 0) return false;
493
+ const hasBoundary = doc.components.some(
494
+ (c) => (c.module?.trim() ?? '') !== '' || (c.process?.trim() ?? '') !== '',
495
+ );
496
+ if (hasBoundary) return false;
489
497
  return deriveGraphEdges({
490
498
  walkthroughs: doc.walkthroughs,
491
499
  graphifyRelations: doc.graphifyRelations,
@@ -608,7 +616,7 @@ export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
608
616
  */
609
617
  export interface SubsystemProcessRegion {
610
618
  /** Discriminator — which field / identity produced this region. */
611
- kind: 'process' | 'module' | 'package';
619
+ kind: 'process' | 'module' | 'directory' | 'package';
612
620
  /** The `process` / `module` value, or purl repo key for packages. */
613
621
  key: string;
614
622
  /** Display label for the boundary frame. */
@@ -631,6 +639,15 @@ export interface BoundaryFrameOptions {
631
639
  * - `never`: no package frames
632
640
  */
633
641
  packageFrames?: 'multi-repo' | 'always' | 'never';
642
+ /**
643
+ * How module frames group.
644
+ * - `exact` (default): one frame per distinct `module` string.
645
+ * - `path`: derive directory frames from the path segments of each
646
+ * component's `module`, and nest module frames under the directories that
647
+ * contain them. Directory frames are interposed between process/package
648
+ * and module frames.
649
+ */
650
+ moduleNesting?: 'exact' | 'path';
634
651
  }
635
652
 
636
653
  /** React Flow id for a process boundary group node. */
@@ -643,6 +660,23 @@ export function moduleGroupNodeId(moduleKey: string): string {
643
660
  return `module:${moduleKey}`;
644
661
  }
645
662
 
663
+ /** React Flow id for a directory boundary group node (path-derived nesting). */
664
+ export function directoryGroupNodeId(dirKey: string): string {
665
+ return `directory:${dirKey}`;
666
+ }
667
+
668
+ /** Normalize a module path to forward slashes, no trailing slash or `./`. */
669
+ function normalizeModulePath(p: string): string {
670
+ return p.trim().replace(/\\/g, '/').replace(/\/+$/, '').replace(/^\.\//, '');
671
+ }
672
+
673
+ /** Parent directory of a path-like key, or `''` when it has no slash. */
674
+ function parentDirectory(path: string): string {
675
+ const norm = normalizeModulePath(path);
676
+ const idx = norm.lastIndexOf('/');
677
+ return idx <= 0 ? '' : norm.slice(0, idx);
678
+ }
679
+
646
680
  export const MODULE_BADGE_INSET = 12;
647
681
  const MODULE_BADGE_CHAR_WIDTH = 12.5;
648
682
  const MODULE_BADGE_CHROME = 16;
@@ -718,6 +752,7 @@ export function packageGroupNodeId(packageKey: string): string {
718
752
  /** React Flow id for a boundary group of any kind. */
719
753
  export function boundaryGroupNodeId(region: Pick<SubsystemProcessRegion, 'kind' | 'key'>): string {
720
754
  if (region.kind === 'module') return moduleGroupNodeId(region.key);
755
+ if (region.kind === 'directory') return directoryGroupNodeId(region.key);
721
756
  if (region.kind === 'package') return packageGroupNodeId(region.key);
722
757
  return processGroupNodeId(region.key);
723
758
  }
@@ -840,6 +875,9 @@ export function buildBoundaryLayoutGroups(
840
875
  doc: Pick<SubsystemModelDocument, 'components'>,
841
876
  opts: BoundaryFrameOptions = {},
842
877
  ): BoundaryLayoutGroup[] {
878
+ if (opts.moduleNesting === 'path') {
879
+ return buildBoundaryLayoutGroupsByPath(doc, opts);
880
+ }
843
881
  const showSingletons = opts.showSingletonFrames === true;
844
882
  const packageMode = opts.packageFrames ?? 'multi-repo';
845
883
  const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
@@ -983,6 +1021,288 @@ export function buildBoundaryLayoutGroups(
983
1021
  return [...moduleGroups, ...processGroups, ...packageGroups];
984
1022
  }
985
1023
 
1024
+ /**
1025
+ * Path-nesting variant of {@link buildBoundaryLayoutGroups}.
1026
+ *
1027
+ * Adds directory frames derived from each component's `module` path segments
1028
+ * (`src/session/transcript.ts` → `src` → `src/session` → module frame) and
1029
+ * nests module frames under the deepest directory that contains them. Directory
1030
+ * frames are kept when they hold 2+ leaves (or `showSingletonFrames`). Process
1031
+ * and package frames nest above directories exactly as before.
1032
+ *
1033
+ * Module frames are unchanged (one per exact `module` string); leaves still
1034
+ * parent to their module frame in `convertSubsystemToNodes`.
1035
+ */
1036
+ export function buildBoundaryLayoutGroupsByPath(
1037
+ doc: Pick<SubsystemModelDocument, 'components'>,
1038
+ opts: BoundaryFrameOptions = {},
1039
+ ): BoundaryLayoutGroup[] {
1040
+ const showSingletons = opts.showSingletonFrames === true;
1041
+ const packageMode = opts.packageFrames ?? 'multi-repo';
1042
+ const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
1043
+
1044
+ const allPackages = getSubsystemPackageRegions(doc);
1045
+ const packageEligible =
1046
+ packageMode === 'always'
1047
+ ? true
1048
+ : packageMode === 'never'
1049
+ ? false
1050
+ : allPackages.length >= 2;
1051
+ const packages = packageEligible
1052
+ ? allPackages.filter((r) => keepRegion(r, showSingletons))
1053
+ : [];
1054
+ const keptPackageKeys = new Set(packages.map((r) => r.key));
1055
+
1056
+ const processes = getSubsystemRegions(doc).filter((r) =>
1057
+ keepRegion(r, showSingletons),
1058
+ );
1059
+ const keptProcessKeys = new Set(processes.map((r) => r.key));
1060
+
1061
+ const modules = getSubsystemModuleRegions(doc).filter((r) =>
1062
+ keepRegion(r, showSingletons),
1063
+ );
1064
+
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
+
1087
+ const dirMembers = new Map<string, Set<string>>();
1088
+ const dirPathOf = new Map<string, string>();
1089
+ const dirScopeOf = new Map<string, string>();
1090
+ for (const c of doc.components) {
1091
+ const m = c.module?.trim();
1092
+ if (!m) continue;
1093
+ const scope = scopeKeyFor(c);
1094
+ let d = parentDirectory(m);
1095
+ while (d) {
1096
+ const ck = `${scope}\0${d}`;
1097
+ const set = dirMembers.get(ck) ?? new Set<string>();
1098
+ set.add(c.alias);
1099
+ dirMembers.set(ck, set);
1100
+ dirPathOf.set(ck, d);
1101
+ dirScopeOf.set(ck, scope);
1102
+ d = parentDirectory(d);
1103
+ }
1104
+ }
1105
+ const keptDirs = new Set<string>();
1106
+ for (const [ck, members] of dirMembers) {
1107
+ if (members.size >= 2 || showSingletons) keptDirs.add(ck);
1108
+ }
1109
+
1110
+ // Files sitting directly in each directory (drives chain compaction below).
1111
+ const directFileCount = new Map<string, number>();
1112
+ for (const c of doc.components) {
1113
+ const m = c.module?.trim();
1114
+ if (!m) continue;
1115
+ const d = parentDirectory(m);
1116
+ if (!d) continue;
1117
+ const ck = `${scopeKeyFor(c)}\0${d}`;
1118
+ directFileCount.set(ck, (directFileCount.get(ck) ?? 0) + 1);
1119
+ }
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`.
1133
+ let compacting = true;
1134
+ while (compacting) {
1135
+ compacting = false;
1136
+ const childDirsOf = new Map<string, string[]>();
1137
+ for (const ck of keptDirs) {
1138
+ const a = nearestKeptDirCk(ck);
1139
+ if (!a) continue;
1140
+ const list = childDirsOf.get(a) ?? [];
1141
+ list.push(ck);
1142
+ childDirsOf.set(a, list);
1143
+ }
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);
1150
+ compacting = true;
1151
+ }
1152
+ }
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
+ };
1159
+
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));
1169
+ }
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;
1175
+ let d = parentDirectory(moduleKey);
1176
+ while (d) {
1177
+ const cand = `${scope}\0${d}`;
1178
+ if (keptDirs.has(cand)) return cand;
1179
+ d = parentDirectory(d);
1180
+ }
1181
+ return undefined;
1182
+ };
1183
+
1184
+ const sharedPackageParent = (
1185
+ memberAliases: readonly string[],
1186
+ ): string | undefined => {
1187
+ const seen = new Set<string>();
1188
+ for (const alias of memberAliases) {
1189
+ const c = byAlias.get(alias);
1190
+ const pkg = c ? componentPackageKey(c) : undefined;
1191
+ if (pkg) seen.add(pkg);
1192
+ }
1193
+ if (seen.size !== 1) return undefined;
1194
+ const pkg = [...seen][0]!;
1195
+ return keptPackageKeys.has(pkg) ? packageGroupNodeId(pkg) : undefined;
1196
+ };
1197
+
1198
+ const moduleGroups: BoundaryLayoutGroup[] = modules.map((r) => {
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');
1203
+ // Relative label when nested: `app/book/actions.ts` under `app/book`
1204
+ // reads as `actions.ts`. At the root the full module path stays.
1205
+ const label = ck ? r.key.slice(dirPathOf.get(ck)!.length + 1) : r.label;
1206
+ return {
1207
+ id: moduleGroupNodeId(r.key),
1208
+ memberAliases: [...r.memberAliases],
1209
+ parentId,
1210
+ region: { ...r, label },
1211
+ };
1212
+ });
1213
+
1214
+ const dirGroups: BoundaryLayoutGroup[] = [...keptDirs].map((ck) => {
1215
+ const scope = dirScopeOf.get(ck)!;
1216
+ const path = dirPathOf.get(ck)!;
1217
+ const ancestor = nearestKeptDirCk(ck);
1218
+ const parentId = ancestor
1219
+ ? dirGroupId(dirScopeOf.get(ancestor)!, dirPathOf.get(ancestor)!)
1220
+ : scopeParentId(scope);
1221
+ // Immediate children: subdirectories and module frames parented here.
1222
+ // Only child *group ids* go in the layout member list — a group's
1223
+ // descendant leaves belong to its child groups alone, or ELK sees the
1224
+ // same leaf in two compound parents and rejects the layout.
1225
+ const childDirCks = [...keptDirs].filter((d) => nearestKeptDirCk(d) === ck);
1226
+ const childModuleKeys = modules
1227
+ .filter((m) => moduleDirCk(m.key) === ck)
1228
+ .map((m) => m.key);
1229
+ const claimed = new Set<string>();
1230
+ for (const d of childDirCks) {
1231
+ for (const a of dirMembers.get(d) ?? []) claimed.add(a);
1232
+ }
1233
+ for (const mk of childModuleKeys) {
1234
+ for (const a of moduleRegionByKey.get(mk)?.memberAliases ?? []) claimed.add(a);
1235
+ }
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
+ };
1245
+ return {
1246
+ id: dirGroupId(scope, path),
1247
+ memberAliases: [
1248
+ ...childDirCks.map((d) => dirGroupId(dirScopeOf.get(d)!, dirPathOf.get(d)!)),
1249
+ ...childModuleKeys.map(moduleGroupNodeId),
1250
+ ...directLeaves,
1251
+ ],
1252
+ parentId,
1253
+ region,
1254
+ };
1255
+ });
1256
+
1257
+ const frameGroups = [...dirGroups, ...moduleGroups];
1258
+
1259
+ // Aliases owned by a module frame (singleton or not) must not become direct
1260
+ // leaves of an enclosing process/package frame.
1261
+ const moduleMemberAliases = new Set<string>();
1262
+ for (const r of modules) for (const a of r.memberAliases) moduleMemberAliases.add(a);
1263
+
1264
+ const processGroups: BoundaryLayoutGroup[] = processes.map((r) => {
1265
+ const processId = processGroupNodeId(r.key);
1266
+ const nested = frameGroups.filter((g) => g.parentId === processId);
1267
+ const claimed = new Set<string>();
1268
+ for (const g of nested) for (const a of g.region.memberAliases) claimed.add(a);
1269
+ const directLeaves = r.memberAliases.filter(
1270
+ (alias) => !claimed.has(alias) && !moduleMemberAliases.has(alias),
1271
+ );
1272
+ return {
1273
+ id: processId,
1274
+ memberAliases: [...nested.map((g) => g.id), ...directLeaves],
1275
+ parentId: sharedPackageParent(r.memberAliases),
1276
+ region: r,
1277
+ };
1278
+ });
1279
+
1280
+ const processMemberAliases = new Set<string>();
1281
+ for (const r of processes) for (const a of r.memberAliases) processMemberAliases.add(a);
1282
+
1283
+ const packageGroups: BoundaryLayoutGroup[] = packages.map((r) => {
1284
+ const packageId = packageGroupNodeId(r.key);
1285
+ const nested = [...processGroups, ...frameGroups].filter(
1286
+ (g) => g.parentId === packageId,
1287
+ );
1288
+ const claimed = new Set<string>();
1289
+ for (const g of nested) for (const a of g.region.memberAliases) claimed.add(a);
1290
+ const directLeaves = r.memberAliases.filter(
1291
+ (alias) =>
1292
+ !claimed.has(alias) &&
1293
+ !moduleMemberAliases.has(alias) &&
1294
+ !processMemberAliases.has(alias),
1295
+ );
1296
+ return {
1297
+ id: packageId,
1298
+ memberAliases: [...nested.map((g) => g.id), ...directLeaves],
1299
+ region: r,
1300
+ };
1301
+ });
1302
+
1303
+ return [...frameGroups, ...processGroups, ...packageGroups];
1304
+ }
1305
+
986
1306
  export interface SubsystemGraphNodeData extends Record<string, unknown> {
987
1307
  component: SubsystemComponent;
988
1308
  /** Set while a file is open in the drawer: true if this node's component
@@ -1294,6 +1614,30 @@ export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][]
1294
1614
  ['registers-into', 'registration pattern', false],
1295
1615
  ];
1296
1616
 
1617
+ /**
1618
+ * Single border/badge color for directory (folder) frames. Folders read as one
1619
+ * kind of container, so they share a neutral hue rather than hashing per-path
1620
+ * like modules / processes / packages. Host `boundaryColors` still overrides.
1621
+ */
1622
+ export const FOLDER_FRAME_COLOR = '#7aa2d4';
1623
+
1624
+ /**
1625
+ * True when a boundary frame names a folder. `directory` frames always are.
1626
+ * A `module` frame is a folder when its key looks like a directory path — the
1627
+ * last segment has no file extension (the model has no explicit file/folder
1628
+ * flag, so this is a heuristic; `Dockerfile`-style extensionless files read as
1629
+ * folders).
1630
+ */
1631
+ export function isFolderFrame(
1632
+ region: { kind: string; key: string } | undefined,
1633
+ ): boolean {
1634
+ if (!region) return false;
1635
+ if (region.kind === 'directory') return true;
1636
+ if (region.kind !== 'module') return false;
1637
+ const last = region.key.split('/').pop() ?? '';
1638
+ return last !== '' && !last.includes('.');
1639
+ }
1640
+
1297
1641
  /** Package color palette (derived deterministically from the package name). */
1298
1642
  export function packageColor(name: string): string {
1299
1643
  const palette = [
@@ -1709,10 +2053,15 @@ export async function buildSubsystemGraph(
1709
2053
  measuredHeights,
1710
2054
  showSingletonFrames,
1711
2055
  packageFrames,
2056
+ moduleNesting,
1712
2057
  graphifyRelations,
1713
2058
  orderByLine = false,
1714
2059
  } = opts;
1715
- const frameOpts: BoundaryFrameOptions = { showSingletonFrames, packageFrames };
2060
+ const frameOpts: BoundaryFrameOptions = {
2061
+ showSingletonFrames,
2062
+ packageFrames,
2063
+ moduleNesting,
2064
+ };
1716
2065
  const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
1717
2066
  const edges = convertSubsystemToEdges(doc, graphifyRelations);
1718
2067
  // Nested boundary tree: package → process → module → leaves.
@@ -1804,7 +2153,12 @@ export async function buildSubsystemGraph(
1804
2153
  try {
1805
2154
  const result = await computeElkLayout(nodes, edges, {
1806
2155
  routingStyle: 'orthogonal',
1807
- direction: 'RIGHT',
2156
+ // Edge-driven graphs flow left-to-right. With no edges (pure
2157
+ // containment, e.g. the static-topology layer) a RIGHT layout stacks
2158
+ // disconnected top-level frames in one vertical column; DOWN makes the
2159
+ // layered pass place those sibling frames along the horizontal axis
2160
+ // while each frame's members stack vertically.
2161
+ direction: edges.length > 0 ? 'RIGHT' : 'DOWN',
1808
2162
  nodeSpacing: 60,
1809
2163
  edgeSpacing: 30,
1810
2164
  edgeNodeSpacing: 60,
@@ -1825,7 +2179,7 @@ export async function buildSubsystemGraph(
1825
2179
  // room for the collapsed badge. Processes/packages never collapse:
1826
2180
  // size the frame to hold the full centered label plus padding.
1827
2181
  minWidth:
1828
- g.region.kind === 'module'
2182
+ g.region.kind === 'module' || g.region.kind === 'directory'
1829
2183
  ? moduleMinWidthForBadge(g.region.label)
1830
2184
  : boundaryMinWidthForBadge(g.region.label),
1831
2185
  })),
@@ -1890,7 +2244,7 @@ export async function buildSubsystemGraph(
1890
2244
  position: { x: 0, y: 0 },
1891
2245
  width: Math.max(
1892
2246
  400,
1893
- g.region.kind === 'module'
2247
+ g.region.kind === 'module' || g.region.kind === 'directory'
1894
2248
  ? moduleMinWidthForBadge(g.region.label)
1895
2249
  : boundaryMinWidthForBadge(g.region.label),
1896
2250
  ),
@@ -33,6 +33,8 @@ import {
33
33
  moduleBadgeHoverLabel,
34
34
  moduleBadgeWidth,
35
35
  packageColor,
36
+ FOLDER_FRAME_COLOR,
37
+ isFolderFrame,
36
38
  type SubsystemGraphNodeData,
37
39
  type SubsystemGroupNodeData,
38
40
  type SubsystemGraphEdge,
@@ -445,8 +447,14 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
445
447
  selected?: boolean;
446
448
  };
447
449
  const region = data.region;
448
- // Explicit host override wins over the derived (hash-based) frame color.
449
- const color = data.color ?? packageColor(region?.key ?? 'process');
450
+ // Explicit host override wins over the derived frame color. Folder frames
451
+ // (directory, or a module whose key names a folder) share one neutral hue;
452
+ // everything else hashes per key.
453
+ const color =
454
+ data.color ??
455
+ (isFolderFrame(region ?? undefined)
456
+ ? FOLDER_FRAME_COLOR
457
+ : packageColor(region?.key ?? 'process'));
450
458
  const dimmed = data.dimmed === true;
451
459
  const hidden = (data as { hidden?: boolean }).hidden === true;
452
460
  // Label is the region identity alone (path / process key / owner/name).
@@ -454,7 +462,9 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
454
462
  // — don't prefix `module ·` / `package ·` or the path reads twice.
455
463
  const label = region?.label ?? '';
456
464
  const [expandedLabel, setExpandedLabel] = useState<string | null>(null);
457
- const isModule = region?.kind === 'module';
465
+ // Directory frames (path-derived nesting) render like modules — dashed,
466
+ // collapsible path badge.
467
+ const isModule = region?.kind === 'module' || region?.kind === 'directory';
458
468
  const isProcess = region?.kind === 'process';
459
469
  const expanded = isModule && expandedLabel === label;
460
470
  const availableBadgeWidth = Math.max(0, (width ?? 400) - MODULE_BADGE_INSET * 2 - 4);
@@ -759,6 +759,16 @@ export async function computeElkLayout(
759
759
  if (parentId && builtGroups.has(parentId)) continue; // nested inside parent
760
760
  elkParents.push(node);
761
761
  }
762
+ // Order root-level compounds by model order (the caller's `groups` array), so
763
+ // disconnected sibling frames lay out deterministically — e.g. `app/book`
764
+ // before `lib`. Without this, planCompoundGroups' bottom-up build order
765
+ // decides placement.
766
+ const groupOrder = new Map(groupDefs.map((g, i) => [g.id, i]));
767
+ elkParents.sort(
768
+ (a, b) =>
769
+ (groupOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
770
+ (groupOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER),
771
+ );
762
772
 
763
773
  // Create ELK graph
764
774
  const rootOptions = getElkOptions(options);