@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.
- package/README.md +1 -1
- package/apps/explorer/scripts/session-store.d.ts +2 -0
- package/apps/explorer/scripts/session-store.mjs +72 -10
- package/apps/explorer/src/App.tsx +44 -5
- package/apps/explorer/src/agentIntent.ts +7 -1
- package/apps/explorer/src/index.css +135 -1
- package/apps/explorer/src/scene/DistantFileBlocks.tsx +44 -0
- package/apps/explorer/src/scene/FileBlock.tsx +28 -37
- package/apps/explorer/src/scene/FolderArea.tsx +4 -2
- package/apps/explorer/src/scene/MapView.tsx +149 -37
- package/apps/explorer/src/scene/SelectionThumbnail.tsx +9 -1
- package/apps/explorer/src/scene/WalkLodTracker.tsx +92 -0
- package/apps/explorer/src/scene/World.tsx +115 -25
- package/apps/explorer/src/scene/walkLod.ts +201 -0
- package/apps/explorer/src/types.ts +1 -0
- package/apps/explorer/src/ui/HUD.tsx +67 -38
- package/apps/explorer/src/ui/MapContextMenu.tsx +89 -0
- package/apps/explorer/src/userCreated.ts +50 -0
- package/apps/explorer/vite.config.ts +2 -0
- package/bin/session.mjs +21 -5
- package/package.json +1 -1
- package/skill/commands/inbase.md +2 -2
- package/skill/inbase/SKILL.md +9 -6
|
@@ -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
|
+
}
|
|
@@ -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">
|
|
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
|
-
|
|
326
|
+
nextAttachLabel,
|
|
309
327
|
}: {
|
|
310
328
|
instruction: string
|
|
311
329
|
onInstructionChange: (value: string) => void
|
|
312
330
|
blueprintDefined: boolean
|
|
313
331
|
awaitingAttach: boolean
|
|
314
|
-
|
|
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
|
-
{
|
|
360
|
+
{awaitingAttach && nextAttachLabel ? (
|
|
343
361
|
<p>
|
|
344
|
-
|
|
345
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
584
|
+
const queuedBehind =
|
|
567
585
|
intent.awaitingAttach &&
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
? sessionLabel(
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
660
|
+
nextAttachLabel={queuedBehind}
|
|
639
661
|
/>
|
|
640
662
|
) : intent.awaitingAttach ? (
|
|
641
|
-
|
|
642
|
-
attachedSession.sessionId !== sessionId ? (
|
|
663
|
+
queuedBehind ? (
|
|
643
664
|
<p className="hud-mode-hint">
|
|
644
|
-
|
|
645
|
-
|
|
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
|
|
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
|
-
|
|
704
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1445
|
-
sessions.find((session) => session.
|
|
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={
|
|
1819
|
+
title={attached ? `${label} · Attached` : `${label} · Waiting`}
|
|
1792
1820
|
onClick={() => {
|
|
1793
1821
|
if (session.sessionId) onFocusSession?.(session.sessionId)
|
|
1794
1822
|
}}
|
|
1795
1823
|
>
|
|
1796
|
-
|
|
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
|
-
|
|
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
|