@jkwd/inbase 0.1.4 → 0.1.5

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,451 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
+ import { Canvas, useFrame, useThree } from '@react-three/fiber'
3
+ import {
4
+ fileChangeKind,
5
+ filesImporting,
6
+ folderAt,
7
+ folderChangeHighlights,
8
+ folderOfFile,
9
+ } from '../layout'
10
+ import { CONFIG, WORLD_VOID, type ChangeKind } from '../theme'
11
+ import type { CodebaseGraph, PlacedFile, PlacedFolder, WorldLayout } from '../types'
12
+ import { FileBlock } from './FileBlock'
13
+ import { FolderArea } from './FolderArea'
14
+ import { RelationLines } from './RelationLines'
15
+
16
+ type SelectionThumbnailProps = {
17
+ graph: CodebaseGraph
18
+ layout: WorldLayout
19
+ selectedId: string | null
20
+ selectedFolder: string | null
21
+ landAt: [number, number]
22
+ importedBy?: boolean
23
+ minimized?: boolean
24
+ plannedIds?: string[]
25
+ createdIds?: string[]
26
+ deletedIds?: string[]
27
+ onMinimize?: () => void
28
+ onHide: () => void
29
+ }
30
+
31
+ type ThumbnailTarget = {
32
+ folder: PlacedFolder
33
+ files: Array<{ file: CodebaseGraph['files'][number]; placed: PlacedFile }>
34
+ relatedIds: Set<string>
35
+ selectedFileId: string | null
36
+ camera: {
37
+ position: [number, number, number]
38
+ lookAt: [number, number, number]
39
+ }
40
+ }
41
+
42
+ function elevatedCamera(
43
+ lookAt: [number, number, number],
44
+ spanX: number,
45
+ spanY: number,
46
+ spanZ: number,
47
+ towardX: 1 | -1,
48
+ ): ThumbnailTarget['camera'] {
49
+ const span = Math.max(spanX, spanZ, 4)
50
+ const distance = Math.max(12, span * 0.9, spanY * 1.8)
51
+ const height = Math.max(CONFIG.eyeHeight * 6, spanY + 8, distance * 0.7)
52
+ return {
53
+ position: [
54
+ lookAt[0] + towardX * distance * 0.55,
55
+ lookAt[1] + height,
56
+ lookAt[2] + distance * 0.72,
57
+ ],
58
+ lookAt,
59
+ }
60
+ }
61
+
62
+ function filesOnFolder(
63
+ graph: CodebaseGraph,
64
+ layout: WorldLayout,
65
+ folderPath: string,
66
+ ) {
67
+ return graph.files.flatMap((file) => {
68
+ if (file.folder !== folderPath && folderOfFile(file.id) !== folderPath) {
69
+ return []
70
+ }
71
+ const placed = layout.files[file.id]
72
+ return placed ? [{ file, placed }] : []
73
+ })
74
+ }
75
+
76
+ function resolveTarget(
77
+ graph: CodebaseGraph,
78
+ layout: WorldLayout,
79
+ selectedId: string | null,
80
+ selectedFolder: string | null,
81
+ landAt: [number, number],
82
+ importedBy: boolean,
83
+ ): ThumbnailTarget | null {
84
+ const selectedFile = selectedId
85
+ ? graph.files.find((file) => file.id === selectedId)
86
+ : undefined
87
+ const selectedPlaced = selectedFile
88
+ ? layout.files[selectedFile.id]
89
+ : undefined
90
+
91
+ const folder =
92
+ (selectedFile
93
+ ? layout.folders[selectedFile.folder] ??
94
+ layout.folders[folderOfFile(selectedFile.id)]
95
+ : undefined) ??
96
+ (selectedFolder ? layout.folders[selectedFolder] : undefined) ??
97
+ folderAt(landAt[0], landAt[1], layout) ??
98
+ Object.values(layout.folders)[0]
99
+ if (!folder) return null
100
+
101
+ const islandFiles = filesOnFolder(graph, layout, folder.path)
102
+ const relatedIds = new Set(
103
+ selectedFile
104
+ ? importedBy
105
+ ? filesImporting(graph.files, selectedFile.id).map((file) => file.id)
106
+ : selectedFile.imports
107
+ : [],
108
+ )
109
+ const extraFiles = graph.files.flatMap((file) => {
110
+ if (!relatedIds.has(file.id)) return []
111
+ if (islandFiles.some((entry) => entry.file.id === file.id)) return []
112
+ const placed = layout.files[file.id]
113
+ return placed ? [{ file, placed }] : []
114
+ })
115
+ const files = [...islandFiles, ...extraFiles]
116
+
117
+ if (selectedPlaced) {
118
+ const [width, height, depth] = selectedPlaced.size
119
+ return {
120
+ folder,
121
+ files,
122
+ relatedIds,
123
+ selectedFileId: selectedFile?.id ?? null,
124
+ camera: elevatedCamera(
125
+ [
126
+ selectedPlaced.position[0],
127
+ selectedPlaced.position[1],
128
+ selectedPlaced.position[2],
129
+ ],
130
+ width,
131
+ height,
132
+ depth,
133
+ selectedPlaced.aisleFace,
134
+ ),
135
+ }
136
+ }
137
+
138
+ const tallest = files.reduce(
139
+ (height, entry) => Math.max(height, entry.placed.size[1]),
140
+ CONFIG.minHeight,
141
+ )
142
+ return {
143
+ folder,
144
+ files,
145
+ relatedIds,
146
+ selectedFileId: null,
147
+ camera: elevatedCamera(
148
+ [folder.x, 1.2, folder.z + folder.depth / 2],
149
+ folder.width,
150
+ tallest,
151
+ folder.depth,
152
+ 1,
153
+ ),
154
+ }
155
+ }
156
+
157
+ const MIN_ZOOM = 0.55
158
+ const MAX_ZOOM = 3.4
159
+
160
+ function clampZoom(value: number) {
161
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value))
162
+ }
163
+
164
+ function touchDistance(touches: TouchList) {
165
+ if (touches.length < 2) return 0
166
+ return Math.hypot(
167
+ touches[0].clientX - touches[1].clientX,
168
+ touches[0].clientY - touches[1].clientY,
169
+ )
170
+ }
171
+
172
+ function CameraRig({
173
+ position,
174
+ lookAt,
175
+ zoom,
176
+ }: {
177
+ position: [number, number, number]
178
+ lookAt: [number, number, number]
179
+ zoom: number
180
+ }) {
181
+ const { camera } = useThree()
182
+ const aim = () => {
183
+ const scale = 1 / zoom
184
+ camera.up.set(0, 1, 0)
185
+ camera.position.set(
186
+ lookAt[0] + (position[0] - lookAt[0]) * scale,
187
+ lookAt[1] + (position[1] - lookAt[1]) * scale,
188
+ lookAt[2] + (position[2] - lookAt[2]) * scale,
189
+ )
190
+ camera.lookAt(lookAt[0], lookAt[1], lookAt[2])
191
+ camera.updateProjectionMatrix()
192
+ }
193
+
194
+ useLayoutEffect(aim, [camera, lookAt, position, zoom])
195
+ useFrame(aim)
196
+ return null
197
+ }
198
+
199
+ function ThumbnailScene({
200
+ graph,
201
+ layout,
202
+ importedBy,
203
+ target,
204
+ highlightedFolders,
205
+ planned,
206
+ created,
207
+ deleted,
208
+ zoom,
209
+ }: {
210
+ graph: CodebaseGraph
211
+ layout: WorldLayout
212
+ importedBy: boolean
213
+ target: ThumbnailTarget
214
+ highlightedFolders: Partial<Record<string, ChangeKind>>
215
+ planned: Set<string>
216
+ created: Set<string>
217
+ deleted: Set<string>
218
+ zoom: number
219
+ }) {
220
+ return (
221
+ <>
222
+ <color attach="background" args={[WORLD_VOID]} />
223
+ <hemisphereLight args={['#d7e2ee', '#2a3038', 1.1]} />
224
+ <directionalLight position={[8, 60, 8]} intensity={1.35} />
225
+ <ambientLight intensity={0.7} />
226
+ <pointLight
227
+ position={[
228
+ target.camera.position[0],
229
+ target.camera.position[1] - 1.4,
230
+ target.camera.position[2],
231
+ ]}
232
+ color="#f4f1e8"
233
+ intensity={7}
234
+ distance={40}
235
+ decay={1.2}
236
+ />
237
+ <CameraRig
238
+ position={target.camera.position}
239
+ lookAt={target.camera.lookAt}
240
+ zoom={zoom}
241
+ />
242
+ <FolderArea
243
+ folder={target.folder}
244
+ highlightKind={highlightedFolders[target.folder.path] ?? null}
245
+ previewLabels
246
+ />
247
+ {target.files.map(({ file, placed }) => {
248
+ const selected = file.id === target.selectedFileId
249
+ const related = target.relatedIds.has(file.id)
250
+ const changeKind = fileChangeKind(file.id, planned, created, deleted)
251
+ return (
252
+ <FileBlock
253
+ key={file.id}
254
+ file={file}
255
+ placed={placed}
256
+ selected={selected}
257
+ related={related}
258
+ planned={Boolean(changeKind)}
259
+ changeKind={changeKind}
260
+ added={created.has(file.id) || file.userCreated}
261
+ highlightMapChange
262
+ previewLabels
263
+ dimmed={
264
+ Boolean(target.selectedFileId) &&
265
+ !selected &&
266
+ !related &&
267
+ !changeKind
268
+ }
269
+ />
270
+ )
271
+ })}
272
+ {target.selectedFileId && (
273
+ <RelationLines
274
+ selectedId={target.selectedFileId}
275
+ aimedRelation={null}
276
+ files={graph.files}
277
+ layout={layout}
278
+ fromAbove
279
+ importedBy={importedBy}
280
+ />
281
+ )}
282
+ </>
283
+ )
284
+ }
285
+
286
+ export function SelectionThumbnail({
287
+ graph,
288
+ layout,
289
+ selectedId,
290
+ selectedFolder,
291
+ landAt,
292
+ importedBy = false,
293
+ minimized = false,
294
+ plannedIds = [],
295
+ createdIds = [],
296
+ deletedIds = [],
297
+ onMinimize,
298
+ onHide,
299
+ }: SelectionThumbnailProps) {
300
+ const stageRef = useRef<HTMLDivElement>(null)
301
+ const zoomRef = useRef(1)
302
+ const pinchRef = useRef({ start: 0, zoom: 1 })
303
+ const [zoom, setZoom] = useState(1)
304
+ const planned = useMemo(() => new Set(plannedIds), [plannedIds])
305
+ const created = useMemo(() => new Set(createdIds), [createdIds])
306
+ const deleted = useMemo(() => new Set(deletedIds), [deletedIds])
307
+ const highlightedFolders = useMemo(
308
+ () => folderChangeHighlights(planned, created, deleted, layout.folders),
309
+ [created, deleted, layout.folders, planned],
310
+ )
311
+ const target = useMemo(
312
+ () =>
313
+ resolveTarget(
314
+ graph,
315
+ layout,
316
+ selectedId,
317
+ selectedFolder,
318
+ landAt,
319
+ importedBy,
320
+ ),
321
+ [graph, importedBy, landAt, layout, selectedFolder, selectedId],
322
+ )
323
+
324
+ useEffect(() => {
325
+ setZoom(1)
326
+ zoomRef.current = 1
327
+ }, [selectedId, selectedFolder, landAt])
328
+
329
+ useEffect(() => {
330
+ zoomRef.current = zoom
331
+ }, [zoom])
332
+
333
+ useEffect(() => {
334
+ const stage = stageRef.current
335
+ if (!stage || minimized) return
336
+
337
+ const applyZoom = (next: number) => {
338
+ const clamped = clampZoom(next)
339
+ zoomRef.current = clamped
340
+ setZoom(clamped)
341
+ }
342
+
343
+ const onWheel = (event: WheelEvent) => {
344
+ if (!event.ctrlKey && !event.metaKey) return
345
+ event.preventDefault()
346
+ event.stopPropagation()
347
+ applyZoom(zoomRef.current * Math.exp(-event.deltaY * 0.01))
348
+ }
349
+
350
+ const onGestureStart = (event: Event) => {
351
+ event.preventDefault()
352
+ pinchRef.current.zoom = zoomRef.current
353
+ }
354
+
355
+ const onGestureChange = (event: Event) => {
356
+ event.preventDefault()
357
+ const scale = Number((event as Event & { scale?: number }).scale)
358
+ if (!Number.isFinite(scale) || scale <= 0) return
359
+ applyZoom(pinchRef.current.zoom * scale)
360
+ }
361
+
362
+ const onTouchStart = (event: TouchEvent) => {
363
+ if (event.touches.length !== 2) return
364
+ pinchRef.current = {
365
+ start: touchDistance(event.touches),
366
+ zoom: zoomRef.current,
367
+ }
368
+ }
369
+
370
+ const onTouchMove = (event: TouchEvent) => {
371
+ if (event.touches.length !== 2 || pinchRef.current.start <= 0) return
372
+ event.preventDefault()
373
+ event.stopPropagation()
374
+ applyZoom(
375
+ pinchRef.current.zoom *
376
+ (touchDistance(event.touches) / pinchRef.current.start),
377
+ )
378
+ }
379
+
380
+ stage.addEventListener('wheel', onWheel, { passive: false, capture: true })
381
+ stage.addEventListener('gesturestart', onGestureStart, { capture: true })
382
+ stage.addEventListener('gesturechange', onGestureChange, { capture: true })
383
+ stage.addEventListener('touchstart', onTouchStart, { passive: true })
384
+ stage.addEventListener('touchmove', onTouchMove, { passive: false, capture: true })
385
+ return () => {
386
+ stage.removeEventListener('wheel', onWheel, { capture: true })
387
+ stage.removeEventListener('gesturestart', onGestureStart, { capture: true })
388
+ stage.removeEventListener('gesturechange', onGestureChange, { capture: true })
389
+ stage.removeEventListener('touchstart', onTouchStart)
390
+ stage.removeEventListener('touchmove', onTouchMove, { capture: true })
391
+ }
392
+ }, [minimized])
393
+
394
+ if (!target) return null
395
+
396
+ return (
397
+ <div className="hud-thumbnail" data-minimized={minimized}>
398
+ <div className="hud-thumbnail-bar">
399
+ <span>3D view</span>
400
+ <div className="hud-panel-controls">
401
+ {onMinimize && (
402
+ <button
403
+ className="hud-button hud-icon-button hud-panel-control"
404
+ type="button"
405
+ aria-label={minimized ? 'Restore 3D view' : 'Minimize 3D view'}
406
+ onClick={onMinimize}
407
+ >
408
+ {minimized ? '+' : '−'}
409
+ </button>
410
+ )}
411
+ <button
412
+ className="hud-button hud-icon-button hud-panel-control"
413
+ type="button"
414
+ aria-label="Close 3D view"
415
+ onClick={onHide}
416
+ >
417
+ ×
418
+ </button>
419
+ </div>
420
+ </div>
421
+ {!minimized && (
422
+ <div className="hud-thumbnail-stage" ref={stageRef}>
423
+ <Canvas
424
+ shadows={false}
425
+ dpr={[1, 1.5]}
426
+ resize={{ offsetSize: true }}
427
+ gl={{ antialias: true, toneMappingExposure: 1.25 }}
428
+ camera={{
429
+ fov: 50,
430
+ near: 0.1,
431
+ far: 400,
432
+ position: target.camera.position,
433
+ }}
434
+ >
435
+ <ThumbnailScene
436
+ graph={graph}
437
+ layout={layout}
438
+ importedBy={importedBy}
439
+ target={target}
440
+ highlightedFolders={highlightedFolders}
441
+ planned={planned}
442
+ created={created}
443
+ deleted={deleted}
444
+ zoom={zoom}
445
+ />
446
+ </Canvas>
447
+ </div>
448
+ )}
449
+ </div>
450
+ )
451
+ }
@@ -8,9 +8,13 @@ import { SelectionController } from './SelectionController'
8
8
  import { UserContextTracker } from './UserContextTracker'
9
9
  import { BlockPlacer } from './BlockPlacer'
10
10
  import { IslandPlacer } from './IslandPlacer'
11
- import { folderOfFile, filesImporting } from '../layout'
11
+ import {
12
+ fileChangeKind,
13
+ filesImporting,
14
+ folderChangeHighlights,
15
+ folderOfFile,
16
+ } from '../layout'
12
17
  import { WORLD_VOID } from '../theme'
13
- import type { ChangeKind } from '../theme'
14
18
  import type {
15
19
  CodebaseGraph,
16
20
  FileNode,
@@ -127,37 +131,12 @@ export function World({
127
131
  Boolean(selectedFolder) ||
128
132
  planned.size > 0 ||
129
133
  deleted.size > 0
130
- const highlightedFolders: Partial<Record<string, ChangeKind>> = {}
131
- const folderKinds = new Map<string, Set<ChangeKind>>()
132
- const addFolderKind = (id: string, kind: ChangeKind) => {
133
- const folder = folderOfFile(id)
134
- const kinds = folderKinds.get(folder) ?? new Set<ChangeKind>()
135
- kinds.add(kind)
136
- folderKinds.set(folder, kinds)
137
- }
138
- for (const id of planned) {
139
- addFolderKind(id, created.has(id) ? 'add' : 'edit')
140
- }
141
- for (const id of deleted) addFolderKind(id, 'remove')
142
- for (const [folder, kinds] of folderKinds) {
143
- if (kinds.size === 1) highlightedFolders[folder] = [...kinds][0]
144
- }
145
- if (planned.size > 0 || deleted.size > 0) {
146
- for (const folder of Object.values(layout.folders)) {
147
- if (!folder.added) continue
148
- const kinds = folderKinds.get(folder.path)
149
- if (!kinds || kinds.size === 1) {
150
- highlightedFolders[folder.path] ??= 'add'
151
- }
152
- }
153
- }
154
-
155
- const changeKindOf = (id: string): ChangeKind | null => {
156
- if (deleted.has(id)) return 'remove'
157
- if (created.has(id)) return 'add'
158
- if (planned.has(id)) return 'edit'
159
- return null
160
- }
134
+ const highlightedFolders = folderChangeHighlights(
135
+ planned,
136
+ created,
137
+ deleted,
138
+ layout.folders,
139
+ )
161
140
 
162
141
  return (
163
142
  <>
@@ -188,7 +167,9 @@ export function World({
188
167
  naming={folder.path === namingIslandId}
189
168
  selected={folder.path === selectedFolder}
190
169
  mapMode={mapping}
191
- highlightKind={highlightedFolders[folder.path] ?? null}
170
+ highlightKind={
171
+ mapping ? highlightedFolders[folder.path] ?? null : null
172
+ }
192
173
  />
193
174
  ))}
194
175
  {layout.bridges.map((bridge) => (
@@ -200,7 +181,7 @@ export function World({
200
181
  const selected = file.id === selectedId
201
182
  const isRelated = related.has(file.id)
202
183
  const isPlanned = planned.has(file.id) || deleted.has(file.id)
203
- const changeKind = changeKindOf(file.id)
184
+ const changeKind = fileChangeKind(file.id, planned, created, deleted)
204
185
  const naming = file.id === namingId
205
186
  return (
206
187
  <FileBlock
@@ -54,10 +54,16 @@ export const CHANGE_HIGHLIGHT: Record<
54
54
  },
55
55
  }
56
56
 
57
+ export const FILE_SELECTION = {
58
+ color: '#c026ff',
59
+ emissive: '#6d00b8',
60
+ }
61
+
57
62
  export const MAP_SELECTION = {
58
63
  color: '#000000',
64
+ island: FILE_SELECTION.color,
59
65
  islandPad: 0.38,
60
- blockPad: 0.2,
66
+ blockPad: 0.1,
61
67
  }
62
68
 
63
69
  export function fileHeight(lines: number) {
@@ -152,6 +152,13 @@ export function isReviewingIntent(status: AgentIntentStatus) {
152
152
  )
153
153
  }
154
154
 
155
+ export function canStopSession(intent: {
156
+ sessionId: string | null
157
+ status: AgentIntentStatus
158
+ }) {
159
+ return Boolean(intent.sessionId) && intent.status !== 'idle'
160
+ }
161
+
155
162
  export type PlanStep = {
156
163
  index: number
157
164
  title: string
@@ -208,6 +215,11 @@ export type WorkflowAction =
208
215
  | 'blueprint_send'
209
216
  | 'blueprint_update'
210
217
 
218
+ export type AgentIntentBundle = {
219
+ focusedSessionId: string | null
220
+ intents: AgentIntent[]
221
+ }
222
+
211
223
  export type AgentIntent = {
212
224
  updatedAt: string | null
213
225
  showMap: boolean