@jkwd/inbase 0.1.11 → 0.1.13

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.
@@ -0,0 +1,201 @@
1
+ import { CONFIG } from '../theme'
2
+ import type { PlacedBridge, PlacedFile, PlacedFolder } from '../types'
3
+
4
+ export const WALK_MESH_RADIUS = 120
5
+ export const WALK_MESH_KEEP = 148
6
+ export const WALK_LABEL_RADIUS = 28
7
+ export const WALK_LABEL_KEEP = 36
8
+ export const WALK_FOLDER_PAD = 160
9
+ export const WALK_FOLDER_LABEL = 18
10
+ export const WALK_AROUND = 14
11
+ export const WALK_BEHIND_DOT = -0.18
12
+
13
+ export type WalkLod = {
14
+ files: Set<string>
15
+ labels: Set<string>
16
+ folders: Set<string>
17
+ folderLabels: Set<string>
18
+ bridges: Set<string>
19
+ }
20
+
21
+ export function folderDistance(
22
+ px: number,
23
+ pz: number,
24
+ folder: PlacedFolder,
25
+ ): number {
26
+ const halfW = folder.width / 2
27
+ const minX = folder.x - halfW
28
+ const maxX = folder.x + halfW
29
+ const minZ = folder.z
30
+ const maxZ = folder.z + folder.depth
31
+ const cx = Math.min(maxX, Math.max(minX, px))
32
+ const cz = Math.min(maxZ, Math.max(minZ, pz))
33
+ return Math.hypot(px - cx, pz - cz)
34
+ }
35
+
36
+ function distanceToSegment(
37
+ px: number,
38
+ pz: number,
39
+ ax: number,
40
+ az: number,
41
+ bx: number,
42
+ bz: number,
43
+ ) {
44
+ const dx = bx - ax
45
+ const dz = bz - az
46
+ const lengthSq = dx * dx + dz * dz
47
+ if (lengthSq === 0) return Math.hypot(px - ax, pz - az)
48
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSq))
49
+ return Math.hypot(px - (ax + dx * t), pz - (az + dz * t))
50
+ }
51
+
52
+ function bridgeDistance(
53
+ px: number,
54
+ pz: number,
55
+ bridge: PlacedBridge,
56
+ ): number {
57
+ let nearest = Infinity
58
+ for (let i = 1; i < bridge.points.length; i += 1) {
59
+ const from = bridge.points[i - 1]
60
+ const to = bridge.points[i]
61
+ nearest = Math.min(
62
+ nearest,
63
+ distanceToSegment(px, pz, from[0], from[1], to[0], to[1]),
64
+ )
65
+ }
66
+ if (!Number.isFinite(nearest) && bridge.points[0]) {
67
+ return Math.hypot(px - bridge.points[0][0], pz - bridge.points[0][1])
68
+ }
69
+ return nearest
70
+ }
71
+
72
+ function inFrontOrNearby(
73
+ dx: number,
74
+ dz: number,
75
+ lookX: number,
76
+ lookZ: number,
77
+ ): boolean {
78
+ const distSq = dx * dx + dz * dz
79
+ if (distSq <= WALK_AROUND * WALK_AROUND) return true
80
+ const dist = Math.sqrt(distSq)
81
+ return (dx / dist) * lookX + (dz / dist) * lookZ >= WALK_BEHIND_DOT
82
+ }
83
+
84
+ export function walkLodCell(
85
+ x: number,
86
+ z: number,
87
+ y: number,
88
+ lookX: number,
89
+ lookZ: number,
90
+ ) {
91
+ const angle = Math.atan2(lookX, lookZ)
92
+ const bucket = Math.round(angle / (Math.PI / 10))
93
+ const heightBand = Math.round(Math.max(0, y - CONFIG.eyeHeight) / 6)
94
+ return `${Math.round(x / 4)}:${Math.round(z / 4)}:${heightBand}:${bucket}`
95
+ }
96
+
97
+ function sameSet(left: Set<string>, right: Set<string>) {
98
+ if (left.size !== right.size) return false
99
+ for (const id of left) {
100
+ if (!right.has(id)) return false
101
+ }
102
+ return true
103
+ }
104
+
105
+ export function sameWalkLod(left: WalkLod | null, right: WalkLod) {
106
+ if (!left) return false
107
+ return (
108
+ sameSet(left.files, right.files) &&
109
+ sameSet(left.labels, right.labels) &&
110
+ sameSet(left.folders, right.folders) &&
111
+ sameSet(left.folderLabels, right.folderLabels) &&
112
+ sameSet(left.bridges, right.bridges)
113
+ )
114
+ }
115
+
116
+ export function heightBoost(y: number) {
117
+ return Math.max(0, y - CONFIG.eyeHeight)
118
+ }
119
+
120
+ export function computeWalkLod({
121
+ x,
122
+ z,
123
+ y = CONFIG.eyeHeight,
124
+ lookX,
125
+ lookZ,
126
+ files,
127
+ folders,
128
+ bridges,
129
+ keepFileIds,
130
+ keepFolderPaths,
131
+ prev,
132
+ }: {
133
+ x: number
134
+ z: number
135
+ y?: number
136
+ lookX: number
137
+ lookZ: number
138
+ files: Record<string, PlacedFile>
139
+ folders: Record<string, PlacedFolder>
140
+ bridges: PlacedBridge[]
141
+ keepFileIds: Iterable<string>
142
+ keepFolderPaths: Iterable<string>
143
+ prev: WalkLod | null
144
+ }): WalkLod {
145
+ const boost = heightBoost(y)
146
+ const meshRadius = WALK_MESH_RADIUS + boost * 2.4
147
+ const meshKeep = WALK_MESH_KEEP + boost * 2.4
148
+ const labelRadius = WALK_LABEL_RADIUS + boost * 0.6
149
+ const labelKeep = WALK_LABEL_KEEP + boost * 0.6
150
+ const folderPad = WALK_FOLDER_PAD + boost * 3
151
+ const lookLen = Math.hypot(lookX, lookZ) || 1
152
+ const nx = lookX / lookLen
153
+ const nz = lookZ / lookLen
154
+
155
+ const nextFiles = new Set<string>()
156
+ const nextLabels = new Set<string>()
157
+ for (const id of keepFileIds) nextFiles.add(id)
158
+
159
+ for (const [id, placed] of Object.entries(files)) {
160
+ const dx = placed.position[0] - x
161
+ const dz = placed.position[2] - z
162
+ const dist = Math.hypot(dx, dz)
163
+ const kept = Boolean(prev?.files.has(id))
164
+ const labeled = Boolean(prev?.labels.has(id))
165
+ const meshLimit = kept ? meshKeep : meshRadius
166
+ const labelLimit = labeled ? labelKeep : labelRadius
167
+ if (dist > meshLimit) continue
168
+ if (!inFrontOrNearby(dx, dz, nx, nz) && dist > WALK_AROUND) continue
169
+ nextFiles.add(id)
170
+ if (dist <= labelLimit) nextLabels.add(id)
171
+ }
172
+
173
+ const nextFolders = new Set<string>()
174
+ const nextFolderLabels = new Set<string>()
175
+ for (const path of keepFolderPaths) nextFolders.add(path)
176
+
177
+ for (const folder of Object.values(folders)) {
178
+ const dist = folderDistance(x, z, folder)
179
+ const kept = Boolean(prev?.folders.has(folder.path))
180
+ const limit = kept ? folderPad + 18 : folderPad
181
+ if (dist > limit && !nextFolders.has(folder.path)) continue
182
+ nextFolders.add(folder.path)
183
+ if (dist <= WALK_FOLDER_LABEL) nextFolderLabels.add(folder.path)
184
+ }
185
+
186
+ const nextBridges = new Set<string>()
187
+ for (const bridge of bridges) {
188
+ const dist = bridgeDistance(x, z, bridge)
189
+ const kept = Boolean(prev?.bridges.has(bridge.id))
190
+ const limit = kept ? folderPad + 18 : folderPad
191
+ if (dist <= limit) nextBridges.add(bridge.id)
192
+ }
193
+
194
+ return {
195
+ files: nextFiles,
196
+ labels: nextLabels,
197
+ folders: nextFolders,
198
+ folderLabels: nextFolderLabels,
199
+ bridges: nextBridges,
200
+ }
201
+ }
@@ -255,6 +255,7 @@ export type WorkflowAction =
255
255
 
256
256
  export type AgentIntentBundle = {
257
257
  focusedSessionId: string | null
258
+ nextAttachSessionId: string | null
258
259
  intents: AgentIntent[]
259
260
  }
260
261
 
@@ -214,9 +214,23 @@ function AddIntentRow({
214
214
  )
215
215
  }
216
216
 
217
+ function AttachStateBadge({ attached }: { attached: boolean }) {
218
+ return (
219
+ <span
220
+ className="hud-attach-badge"
221
+ data-attached={attached}
222
+ aria-label={attached ? 'LLM attached' : 'Waiting for LLM'}
223
+ >
224
+ <span className="hud-attach-dot" aria-hidden="true" />
225
+ {attached ? 'Attached' : 'Waiting'}
226
+ </span>
227
+ )
228
+ }
229
+
217
230
  function PanelChrome({
218
231
  title,
219
232
  subtitle,
233
+ badge,
220
234
  minimized = false,
221
235
  onMinimize,
222
236
  onClose,
@@ -225,6 +239,7 @@ function PanelChrome({
225
239
  }: {
226
240
  title: ReactNode
227
241
  subtitle?: ReactNode
242
+ badge?: ReactNode
228
243
  minimized?: boolean
229
244
  onMinimize?: () => void
230
245
  onClose?: () => void
@@ -234,7 +249,10 @@ function PanelChrome({
234
249
  return (
235
250
  <div className="hud-panel-chrome">
236
251
  <div className="hud-panel-chrome-heading">
237
- <div className="hud-panel-chrome-title">{title}</div>
252
+ <div className="hud-panel-chrome-title-row">
253
+ <div className="hud-panel-chrome-title">{title}</div>
254
+ {badge}
255
+ </div>
238
256
  {subtitle ? (
239
257
  <div className="hud-panel-chrome-subtitle">{subtitle}</div>
240
258
  ) : null}
@@ -305,13 +323,13 @@ function HandshakeSetup({
305
323
  onInstructionChange,
306
324
  blueprintDefined,
307
325
  awaitingAttach,
308
- attachBlockedBy,
326
+ nextAttachLabel,
309
327
  }: {
310
328
  instruction: string
311
329
  onInstructionChange: (value: string) => void
312
330
  blueprintDefined: boolean
313
331
  awaitingAttach: boolean
314
- attachBlockedBy: string | null
332
+ nextAttachLabel: string | null
315
333
  }) {
316
334
  return (
317
335
  <div className="hud-setup">
@@ -334,15 +352,15 @@ function HandshakeSetup({
334
352
  </h2>
335
353
  <p>
336
354
  Press <kbd>Space</kbd> for a file and <kbd>B</kbd> for an island.
337
- Optional.
355
+ On the map, right-click to add a file or folder. Optional.
338
356
  </p>
339
357
  </section>
340
358
  <section className="hud-setup-section">
341
359
  <h2 className="hud-setup-heading">Start</h2>
342
- {attachBlockedBy ? (
360
+ {awaitingAttach && nextAttachLabel ? (
343
361
  <p>
344
- An LLM is already attached to {attachBlockedBy}. Stop that session
345
- before running <kbd>/inbase</kbd>.
362
+ <kbd>/inbase</kbd> attaches {nextAttachLabel} first. This session
363
+ stays in the queue.
346
364
  </p>
347
365
  ) : awaitingAttach ? (
348
366
  <p>
@@ -350,7 +368,7 @@ function HandshakeSetup({
350
368
  </p>
351
369
  ) : (
352
370
  <p>
353
- Starting from <kbd>/inbase</kbd>…
371
+ This window is attached. Starting from <kbd>/inbase</kbd>…
354
372
  </p>
355
373
  )}
356
374
  </section>
@@ -373,7 +391,7 @@ function sessionLiveStatus(intent: AgentIntent) {
373
391
  return { text: 'LLM wait timed out', busy: false }
374
392
  }
375
393
  if (intent.awaitingAttach) {
376
- return { text: 'Waiting for /inbase in Cursor', busy: true }
394
+ return { text: 'Waiting for /inbase in Cursor', busy: false }
377
395
  }
378
396
  if (kind === 'execute') {
379
397
  return { text: `LLM received ${detail}`, busy: true }
@@ -462,7 +480,7 @@ type SessionPanelProps = {
462
480
  intent: AgentIntent
463
481
  focused: boolean
464
482
  naming: boolean
465
- attachedSession?: AgentIntent | null
483
+ nextAttachSession?: AgentIntent | null
466
484
  onFocus: () => void
467
485
  onWorkflowAction: (
468
486
  sessionId: string,
@@ -476,7 +494,7 @@ function SessionPanel({
476
494
  intent,
477
495
  focused,
478
496
  naming,
479
- attachedSession = null,
497
+ nextAttachSession = null,
480
498
  onFocus,
481
499
  onWorkflowAction,
482
500
  onNavigateDiff,
@@ -563,11 +581,11 @@ function SessionPanel({
563
581
  const llmConnected = intent.awaitingAttach === false
564
582
  const showConnectedProgress =
565
583
  llmConnected && (askingBlueprint || sendingBlueprint || preparing)
566
- const attachBlockedBy =
584
+ const queuedBehind =
567
585
  intent.awaitingAttach &&
568
- attachedSession &&
569
- attachedSession.sessionId !== sessionId
570
- ? sessionLabel(attachedSession) || 'another session'
586
+ nextAttachSession &&
587
+ nextAttachSession.sessionId !== sessionId
588
+ ? sessionLabel(nextAttachSession) || 'a newer session'
571
589
  : null
572
590
 
573
591
  const act = (
@@ -584,6 +602,7 @@ function SessionPanel({
584
602
  }
585
603
  data-minimized={minimized}
586
604
  data-focused={focused}
605
+ data-attached={llmConnected}
587
606
  onPointerDown={onFocus}
588
607
  >
589
608
  <PanelChrome
@@ -598,6 +617,7 @@ function SessionPanel({
598
617
  : reviewTitle(intent.status)
599
618
  : undefined
600
619
  }
620
+ badge={<AttachStateBadge attached={llmConnected} />}
601
621
  minimized={minimized}
602
622
  onMinimize={() => setMinimized((current) => !current)}
603
623
  onClose={() => act('stop')}
@@ -606,11 +626,13 @@ function SessionPanel({
606
626
  />
607
627
  {!minimized && (
608
628
  <>
609
- <LiveStatus
610
- intent={intent}
611
- showStop={showConnectedProgress || working}
612
- onStop={() => act('stop')}
613
- />
629
+ {!intent.awaitingAttach && (
630
+ <LiveStatus
631
+ intent={intent}
632
+ showStop={showConnectedProgress || working}
633
+ onStop={() => act('stop')}
634
+ />
635
+ )}
614
636
  <label className="hud-mode-switch">
615
637
  <span>Step by step</span>
616
638
  <button
@@ -635,20 +657,18 @@ function SessionPanel({
635
657
  onInstructionChange={updateInitialInstruction}
636
658
  blueprintDefined={blueprintIsDefined(intent)}
637
659
  awaitingAttach={Boolean(intent.awaitingAttach)}
638
- attachBlockedBy={attachBlockedBy || null}
660
+ nextAttachLabel={queuedBehind}
639
661
  />
640
662
  ) : intent.awaitingAttach ? (
641
- attachedSession &&
642
- attachedSession.sessionId !== sessionId ? (
663
+ queuedBehind ? (
643
664
  <p className="hud-mode-hint">
644
- An LLM is already attached to{' '}
645
- {sessionLabel(attachedSession) || 'another session'}. Stop that
646
- session before running <kbd>/inbase</kbd>.
665
+ <kbd>/inbase</kbd> attaches {queuedBehind} first. This session
666
+ stays in the queue.
647
667
  </p>
648
668
  ) : (
649
669
  <p className="hud-mode-hint">
650
670
  No LLM is attached. Open a Cursor chat and run{' '}
651
- <kbd>/inbase</kbd>. It connects to this focused session.
671
+ <kbd>/inbase</kbd>. It connects to the next waiting session.
652
672
  </p>
653
673
  )
654
674
  ) : null}
@@ -700,8 +720,9 @@ function SessionPanel({
700
720
  <>
701
721
  {canPlace && !intent.working && (
702
722
  <p>
703
- <kbd>Space</kbd> places a file, <kbd>B</kbd> an island for
704
- this session.
723
+ On the map, right-click to add a file or folder.{' '}
724
+ <kbd>Space</kbd> places a file, <kbd>B</kbd> an island while
725
+ walking.
705
726
  </p>
706
727
  )}
707
728
  {intent.steps?.length > 0 && (
@@ -1204,7 +1225,7 @@ function explorerInstructions({
1204
1225
  {
1205
1226
  id: 'setup-session',
1206
1227
  keys: ['Setup LLM session'],
1207
- label: 'Open a session; /inbase connects the focused one',
1228
+ label: 'Open a session; /inbase attaches the newest waiting one',
1208
1229
  },
1209
1230
  { id: 'toggle-map', keys: ['M'], label: 'Toggle map' },
1210
1231
  {
@@ -1251,8 +1272,8 @@ function explorerInstructions({
1251
1272
  { id: 'select-island', keys: ['Click'], label: 'Select an island' },
1252
1273
  {
1253
1274
  id: 'add-file-folder',
1254
- keys: [],
1255
- label: 'Add file / Add folder in panel',
1275
+ keys: ['Right-click'],
1276
+ label: 'Add file or folder',
1256
1277
  },
1257
1278
  ]
1258
1279
  : []),
@@ -1291,7 +1312,7 @@ function explorerInstructions({
1291
1312
  {
1292
1313
  id: 'setup-session',
1293
1314
  keys: ['Setup LLM session'],
1294
- label: 'Open a session; /inbase connects the focused one',
1315
+ label: 'Open a session; /inbase attaches the newest waiting one',
1295
1316
  },
1296
1317
  { id: 'map-walk', keys: ['M'], label: 'Back to walk' },
1297
1318
  ...stop,
@@ -1315,6 +1336,7 @@ type HUDProps = {
1315
1336
  intent: AgentIntent
1316
1337
  intents?: AgentIntent[]
1317
1338
  focusedSessionId?: string | null
1339
+ nextAttachSessionId?: string | null
1318
1340
  onFocusSession?: (sessionId: string) => void
1319
1341
  onSetupSession?: () => Promise<unknown>
1320
1342
  onWorkflowAction: (
@@ -1380,6 +1402,7 @@ export function HUD({
1380
1402
  intent,
1381
1403
  intents,
1382
1404
  focusedSessionId = null,
1405
+ nextAttachSessionId = null,
1383
1406
  onFocusSession,
1384
1407
  onSetupSession,
1385
1408
  onWorkflowAction,
@@ -1441,8 +1464,10 @@ export function HUD({
1441
1464
  (item) => item.sessionId && isReviewingIntent(item.status),
1442
1465
  )
1443
1466
  const canStop = canStopSession(intent)
1444
- const attachedSession =
1445
- sessions.find((session) => session.awaitingAttach === false) ?? null
1467
+ const nextAttachSession =
1468
+ sessions.find((session) => session.sessionId === nextAttachSessionId) ??
1469
+ [...sessions].reverse().find((session) => session.awaitingAttach) ??
1470
+ null
1446
1471
  const [walkIntro, setWalkIntro] = useState(false)
1447
1472
  const walkIntroSeen = useRef(false)
1448
1473
  const [instructionsOpen, setInstructionsOpen] = useState(false)
@@ -1780,6 +1805,8 @@ export function HUD({
1780
1805
  {sessions.map((session) => {
1781
1806
  const active =
1782
1807
  session.sessionId === (focusedSessionId ?? intent.sessionId)
1808
+ const attached = session.awaitingAttach === false
1809
+ const label = sessionTabLabel(session, sessions)
1783
1810
  return (
1784
1811
  <button
1785
1812
  className="hud-button hud-session-tab"
@@ -1787,13 +1814,15 @@ export function HUD({
1787
1814
  role="tab"
1788
1815
  aria-selected={active}
1789
1816
  data-active={active}
1817
+ data-attached={attached}
1790
1818
  key={session.sessionId}
1791
- title={sessionTabLabel(session, sessions)}
1819
+ title={attached ? `${label} · Attached` : `${label} · Waiting`}
1792
1820
  onClick={() => {
1793
1821
  if (session.sessionId) onFocusSession?.(session.sessionId)
1794
1822
  }}
1795
1823
  >
1796
- {sessionTabLabel(session, sessions)}
1824
+ <span className="hud-attach-dot" aria-hidden="true" />
1825
+ <span className="hud-session-tab-label">{label}</span>
1797
1826
  </button>
1798
1827
  )
1799
1828
  })}
@@ -1804,7 +1833,7 @@ export function HUD({
1804
1833
  intent={intent}
1805
1834
  focused
1806
1835
  naming={naming}
1807
- attachedSession={attachedSession}
1836
+ nextAttachSession={nextAttachSession}
1808
1837
  onFocus={() => {
1809
1838
  if (intent.sessionId) onFocusSession?.(intent.sessionId)
1810
1839
  }}
@@ -0,0 +1,89 @@
1
+ import { useEffect } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+
4
+ export type MapContextMenuState = {
5
+ x: number
6
+ y: number
7
+ folder: string
8
+ }
9
+
10
+ type MapContextMenuProps = {
11
+ menu: MapContextMenuState | null
12
+ onAddFile: (folder: string) => void
13
+ onAddFolder: (folder: string) => void
14
+ onClose: () => void
15
+ }
16
+
17
+ export function MapContextMenu({
18
+ menu,
19
+ onAddFile,
20
+ onAddFolder,
21
+ onClose,
22
+ }: MapContextMenuProps) {
23
+ useEffect(() => {
24
+ if (!menu) return
25
+ const onKey = (event: KeyboardEvent) => {
26
+ if (event.code !== 'Escape') return
27
+ event.preventDefault()
28
+ onClose()
29
+ }
30
+ const onPointerDown = (event: PointerEvent) => {
31
+ const target = event.target
32
+ if (target instanceof Element && target.closest('.map-context-menu')) {
33
+ return
34
+ }
35
+ onClose()
36
+ }
37
+ window.addEventListener('keydown', onKey)
38
+ window.addEventListener('pointerdown', onPointerDown, true)
39
+ return () => {
40
+ window.removeEventListener('keydown', onKey)
41
+ window.removeEventListener('pointerdown', onPointerDown, true)
42
+ }
43
+ }, [menu, onClose])
44
+
45
+ if (!menu) return null
46
+
47
+ const pad = 8
48
+ const width = 176
49
+ const height = 84
50
+ const left = Math.min(
51
+ Math.max(pad, menu.x),
52
+ window.innerWidth - width - pad,
53
+ )
54
+ const top = Math.min(
55
+ Math.max(pad, menu.y),
56
+ window.innerHeight - height - pad,
57
+ )
58
+
59
+ return createPortal(
60
+ <div
61
+ className="map-context-menu"
62
+ role="menu"
63
+ style={{ left, top }}
64
+ onContextMenu={(event) => event.preventDefault()}
65
+ >
66
+ <button
67
+ type="button"
68
+ role="menuitem"
69
+ onClick={() => {
70
+ onAddFile(menu.folder)
71
+ onClose()
72
+ }}
73
+ >
74
+ Add file
75
+ </button>
76
+ <button
77
+ type="button"
78
+ role="menuitem"
79
+ onClick={() => {
80
+ onAddFolder(menu.folder)
81
+ onClose()
82
+ }}
83
+ >
84
+ Add folder
85
+ </button>
86
+ </div>,
87
+ document.body,
88
+ )
89
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  FileNode,
6
6
  PatchImportAddition,
7
7
  PatchSymbolAddition,
8
+ PlacedFolder,
8
9
  UserCreatedBlock,
9
10
  UserCreatedIsland,
10
11
  WorldLayout,
@@ -193,6 +194,54 @@ export function withUserCreatedGraph(
193
194
  }
194
195
  }
195
196
 
197
+ function placedFolderParent(
198
+ folder: PlacedFolder,
199
+ islands: UserCreatedIsland[],
200
+ ) {
201
+ const created = islands.find((item) => islandKey(item) === folder.path)
202
+ if (created) return created.parent
203
+ return folderParent(folder.path)
204
+ }
205
+
206
+ function expandParentsToChildren(
207
+ folders: Record<string, PlacedFolder>,
208
+ islands: UserCreatedIsland[],
209
+ ) {
210
+ const parentPaths = new Set<string>()
211
+ for (const island of islands) {
212
+ let current: string | null = island.parent
213
+ while (current) {
214
+ parentPaths.add(current)
215
+ current = folderParent(current)
216
+ }
217
+ }
218
+ const ordered = [...parentPaths].sort(
219
+ (left, right) =>
220
+ right.split('/').filter(Boolean).length -
221
+ left.split('/').filter(Boolean).length,
222
+ )
223
+ for (const parentPath of ordered) {
224
+ const parent = folders[parentPath]
225
+ if (!parent) continue
226
+ const children = Object.values(folders).filter(
227
+ (folder) =>
228
+ folder.path !== parentPath &&
229
+ placedFolderParent(folder, islands) === parentPath,
230
+ )
231
+ if (children.length === 0) continue
232
+ let extent = parent.width / 2
233
+ for (const child of children) {
234
+ extent = Math.max(
235
+ extent,
236
+ Math.abs(child.x - child.width / 2 - parent.x),
237
+ Math.abs(child.x + child.width / 2 - parent.x),
238
+ )
239
+ }
240
+ const width = extent * 2
241
+ if (width > parent.width) folders[parentPath] = { ...parent, width }
242
+ }
243
+ }
244
+
196
245
  function overlayIslands(
197
246
  layout: WorldLayout,
198
247
  islands: UserCreatedIsland[],
@@ -245,6 +294,7 @@ function overlayIslands(
245
294
  })
246
295
  }
247
296
 
297
+ expandParentsToChildren(folders, islands)
248
298
  return { ...layout, folders, bridges }
249
299
  }
250
300
 
@@ -17,6 +17,7 @@ import {
17
17
  inspectTargetFile,
18
18
  invokeStep,
19
19
  listSessionIntents,
20
+ nextAttachSessionId,
20
21
  readActiveSession,
21
22
  requestReplan,
22
23
  sendBlueprint,
@@ -142,6 +143,7 @@ function jsonFilePlugin(): Plugin {
142
143
  }
143
144
  sendJson(res, 200, {
144
145
  focusedSessionId: readActiveSession(dataDir),
146
+ nextAttachSessionId: nextAttachSessionId(dataDir),
145
147
  intents: listSessionIntents(dataDir, knownFileIds()),
146
148
  })
147
149
  return