prompt_objects 0.3.1 → 0.5.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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/CLAUDE.md +112 -44
  4. data/Gemfile.lock +31 -29
  5. data/README.md +5 -0
  6. data/frontend/index.html +5 -1
  7. data/frontend/package-lock.json +123 -0
  8. data/frontend/package.json +4 -0
  9. data/frontend/src/App.tsx +70 -71
  10. data/frontend/src/canvas/CanvasView.tsx +113 -0
  11. data/frontend/src/canvas/ForceLayout.ts +115 -0
  12. data/frontend/src/canvas/SceneManager.ts +587 -0
  13. data/frontend/src/canvas/canvasStore.ts +47 -0
  14. data/frontend/src/canvas/constants.ts +95 -0
  15. data/frontend/src/canvas/controls/CameraControls.ts +98 -0
  16. data/frontend/src/canvas/edges/MessageArc.ts +149 -0
  17. data/frontend/src/canvas/inspector/InspectorPanel.tsx +31 -0
  18. data/frontend/src/canvas/inspector/POInspector.tsx +262 -0
  19. data/frontend/src/canvas/inspector/ToolCallInspector.tsx +67 -0
  20. data/frontend/src/canvas/nodes/PONode.ts +249 -0
  21. data/frontend/src/canvas/nodes/ToolCallNode.ts +116 -0
  22. data/frontend/src/canvas/types.ts +24 -0
  23. data/frontend/src/components/ContextMenu.tsx +5 -4
  24. data/frontend/src/components/Inspector.tsx +232 -0
  25. data/frontend/src/components/MarkdownMessage.tsx +22 -20
  26. data/frontend/src/components/MethodList.tsx +90 -0
  27. data/frontend/src/components/ModelSelector.tsx +13 -14
  28. data/frontend/src/components/NotificationPanel.tsx +29 -33
  29. data/frontend/src/components/ObjectList.tsx +78 -0
  30. data/frontend/src/components/PaneSlot.tsx +30 -0
  31. data/frontend/src/components/SourcePane.tsx +202 -0
  32. data/frontend/src/components/SystemBar.tsx +74 -0
  33. data/frontend/src/components/Transcript.tsx +76 -0
  34. data/frontend/src/components/UsagePanel.tsx +27 -27
  35. data/frontend/src/components/Workspace.tsx +260 -0
  36. data/frontend/src/components/index.ts +10 -9
  37. data/frontend/src/hooks/useResize.ts +55 -0
  38. data/frontend/src/hooks/useWebSocket.ts +274 -189
  39. data/frontend/src/index.css +69 -3
  40. data/frontend/src/store/index.ts +23 -0
  41. data/frontend/src/types/index.ts +5 -0
  42. data/frontend/tailwind.config.js +28 -9
  43. data/lib/prompt_objects/capability.rb +23 -1
  44. data/lib/prompt_objects/connectors/mcp.rb +5 -22
  45. data/lib/prompt_objects/environment.rb +8 -0
  46. data/lib/prompt_objects/llm/openai_adapter.rb +22 -0
  47. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +1 -2
  48. data/lib/prompt_objects/mcp/tools/inspect_po.rb +1 -31
  49. data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +2 -8
  50. data/lib/prompt_objects/primitives/list_files.rb +1 -2
  51. data/lib/prompt_objects/prompt_object.rb +150 -6
  52. data/lib/prompt_objects/server/api/routes.rb +3 -48
  53. data/lib/prompt_objects/server/app.rb +9 -0
  54. data/lib/prompt_objects/server/public/assets/index-D1myxE0l.js +4345 -0
  55. data/lib/prompt_objects/server/public/assets/index-DdCcwC-Z.css +1 -0
  56. data/lib/prompt_objects/server/public/index.html +7 -3
  57. data/lib/prompt_objects/server/websocket_handler.rb +23 -100
  58. data/lib/prompt_objects/server.rb +6 -62
  59. data/prompt_objects.gemspec +1 -1
  60. data/templates/arc-agi-1/primitives/find_objects.rb +1 -1
  61. data/templates/arc-agi-1/primitives/grid_diff.rb +2 -2
  62. data/templates/arc-agi-1/primitives/grid_info.rb +1 -1
  63. data/templates/arc-agi-1/primitives/grid_transform.rb +1 -1
  64. data/templates/arc-agi-1/primitives/render_grid.rb +1 -0
  65. data/templates/arc-agi-1/primitives/test_solution.rb +3 -0
  66. metadata +26 -14
  67. data/frontend/src/components/CapabilitiesPanel.tsx +0 -141
  68. data/frontend/src/components/ChatPanel.tsx +0 -288
  69. data/frontend/src/components/Dashboard.tsx +0 -83
  70. data/frontend/src/components/Header.tsx +0 -141
  71. data/frontend/src/components/MessageBus.tsx +0 -56
  72. data/frontend/src/components/POCard.tsx +0 -56
  73. data/frontend/src/components/PODetail.tsx +0 -124
  74. data/frontend/src/components/PromptPanel.tsx +0 -156
  75. data/frontend/src/components/SessionsPanel.tsx +0 -174
  76. data/frontend/src/components/ThreadsSidebar.tsx +0 -163
  77. data/lib/prompt_objects/server/public/assets/index-Bkme6COu.css +0 -1
  78. data/lib/prompt_objects/server/public/assets/index-CQ7lVDF_.js +0 -77
@@ -0,0 +1,587 @@
1
+ import * as THREE from 'three'
2
+ import { CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js'
3
+ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
4
+ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
5
+ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
6
+ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
7
+
8
+ import { ForceLayout } from './ForceLayout'
9
+ import { CameraControls } from './controls/CameraControls'
10
+ import { PONode } from './nodes/PONode'
11
+ import { ToolCallNode } from './nodes/ToolCallNode'
12
+ import { MessageArc } from './edges/MessageArc'
13
+ import { useCanvasStore } from './canvasStore'
14
+ import { COLORS, BLOOM, NODE } from './constants'
15
+ import type { PromptObject, BusMessage, Notification, ToolCall } from '../types'
16
+
17
+ export class SceneManager {
18
+ private container: HTMLDivElement
19
+ private scene: THREE.Scene
20
+ private camera: THREE.OrthographicCamera
21
+ private renderer: THREE.WebGLRenderer
22
+ private cssRenderer: CSS2DRenderer
23
+ private composer: EffectComposer
24
+ private cameraControls: CameraControls
25
+ private forceLayout: ForceLayout
26
+ private raycaster: THREE.Raycaster
27
+ private pointer: THREE.Vector2
28
+
29
+ private poNodes = new Map<string, PONode>()
30
+ private toolCallNodes = new Map<string, ToolCallNode>()
31
+ private messageArcs = new Map<string, MessageArc>()
32
+
33
+ private animationFrameId: number | null = null
34
+ private clock = new THREE.Clock()
35
+ private running = false
36
+
37
+ // Track processed bus messages to avoid duplicates
38
+ private processedArcIds = new Set<string>()
39
+
40
+ // Track PO status transitions and tool calls
41
+ private prevPOStatuses = new Map<string, string>()
42
+ private seenToolCallIds = new Set<string>()
43
+ // Map from PO name → set of tool call node IDs currently active for that PO
44
+ private poToolCallNodes = new Map<string, Set<string>>()
45
+ // Track active PO-to-PO delegations: tool_call_id → { callerPO, targetPO }
46
+ private activeDelegations = new Map<string, { callerPO: string; targetPO: string }>()
47
+
48
+ constructor(container: HTMLDivElement) {
49
+ this.container = container
50
+ const { width, height } = container.getBoundingClientRect()
51
+
52
+ // Scene
53
+ this.scene = new THREE.Scene()
54
+ this.scene.background = new THREE.Color(COLORS.background)
55
+
56
+ // Orthographic camera (true 2D)
57
+ const aspect = width / height
58
+ const frustumSize = 500
59
+ this.camera = new THREE.OrthographicCamera(
60
+ (-frustumSize * aspect) / 2,
61
+ (frustumSize * aspect) / 2,
62
+ frustumSize / 2,
63
+ -frustumSize / 2,
64
+ 0.1,
65
+ 1000
66
+ )
67
+ this.camera.position.z = 100
68
+
69
+ // WebGL renderer
70
+ this.renderer = new THREE.WebGLRenderer({ antialias: true })
71
+ this.renderer.setSize(width, height)
72
+ this.renderer.setPixelRatio(window.devicePixelRatio)
73
+ container.appendChild(this.renderer.domElement)
74
+
75
+ // CSS2D renderer (overlaid for labels)
76
+ this.cssRenderer = new CSS2DRenderer()
77
+ this.cssRenderer.setSize(width, height)
78
+ this.cssRenderer.domElement.style.position = 'absolute'
79
+ this.cssRenderer.domElement.style.top = '0'
80
+ this.cssRenderer.domElement.style.left = '0'
81
+ this.cssRenderer.domElement.style.pointerEvents = 'none'
82
+ container.appendChild(this.cssRenderer.domElement)
83
+
84
+ // Post-processing (bloom)
85
+ this.composer = new EffectComposer(this.renderer)
86
+ this.composer.addPass(new RenderPass(this.scene, this.camera))
87
+ const bloomPass = new UnrealBloomPass(
88
+ new THREE.Vector2(width, height),
89
+ BLOOM.strength,
90
+ BLOOM.radius,
91
+ BLOOM.threshold
92
+ )
93
+ this.composer.addPass(bloomPass)
94
+ this.composer.addPass(new OutputPass())
95
+
96
+ // Camera controls
97
+ this.cameraControls = new CameraControls(this.camera, this.renderer.domElement)
98
+
99
+ // Force layout
100
+ this.forceLayout = new ForceLayout()
101
+
102
+ // Raycaster for picking
103
+ this.raycaster = new THREE.Raycaster()
104
+ this.pointer = new THREE.Vector2()
105
+
106
+ // Event listeners
107
+ this.renderer.domElement.addEventListener('click', this.onClick)
108
+ this.renderer.domElement.addEventListener('pointermove', this.onPointerMove)
109
+ window.addEventListener('resize', this.onResize)
110
+ }
111
+
112
+ // --- Public sync API ---
113
+
114
+ syncPromptObjects(pos: Record<string, PromptObject>): void {
115
+ const currentIds = new Set(this.poNodes.keys())
116
+ const newIds = new Set(Object.keys(pos))
117
+
118
+ // Remove nodes that no longer exist
119
+ for (const id of currentIds) {
120
+ if (!newIds.has(id)) {
121
+ const node = this.poNodes.get(id)!
122
+ this.scene.remove(node.group)
123
+ node.dispose()
124
+ this.poNodes.delete(id)
125
+ this.forceLayout.removeNode(id)
126
+ this.fadeOutToolCallsForPO(id)
127
+ this.prevPOStatuses.delete(id)
128
+ }
129
+ }
130
+
131
+ // Add or update nodes
132
+ for (const [name, po] of Object.entries(pos)) {
133
+ let node = this.poNodes.get(name)
134
+ if (!node) {
135
+ node = new PONode(name, name)
136
+ this.poNodes.set(name, node)
137
+ this.scene.add(node.group)
138
+ this.forceLayout.addNode(name, 'po')
139
+ }
140
+
141
+ // Handle delegation visuals (server-driven via po_delegation_started/completed)
142
+ if (po.delegated_by) {
143
+ // This PO is being called by another PO — show delegation state
144
+ if (!this.activeDelegations.has(name)) {
145
+ // New delegation — activate visual and create arc
146
+ node.setDelegatedBy(po.delegated_by)
147
+ this.activeDelegations.set(name, { callerPO: po.delegated_by, targetPO: name })
148
+
149
+ // Create a message arc from caller to target
150
+ const callerNode = this.poNodes.get(po.delegated_by)
151
+ if (callerNode) {
152
+ const arcId = `delegation-${name}`
153
+ if (!this.messageArcs.has(arcId)) {
154
+ const arc = new MessageArc(
155
+ arcId,
156
+ po.delegated_by,
157
+ name,
158
+ callerNode.getPosition(),
159
+ node.getPosition()
160
+ )
161
+ this.messageArcs.set(arcId, arc)
162
+ this.scene.add(arc.group)
163
+ }
164
+ }
165
+ }
166
+ // Skip normal setStatus when delegated — the delegation visual takes priority
167
+ } else {
168
+ // Not delegated — check if we need to clear a previous delegation
169
+ if (this.activeDelegations.has(name)) {
170
+ node.clearDelegated()
171
+ this.activeDelegations.delete(name)
172
+ }
173
+ node.setStatus(po.status)
174
+ }
175
+
176
+ // Detect status transitions for tool call visualization
177
+ const prevStatus = this.prevPOStatuses.get(name)
178
+ this.prevPOStatuses.set(name, po.status)
179
+
180
+ if (po.status === 'calling_tool') {
181
+ // PO is calling tools — extract any new tool_calls from its messages
182
+ this.extractAndCreateToolCalls(name, po)
183
+ } else if (prevStatus === 'calling_tool') {
184
+ // PO just finished calling tools — fade out its tool call nodes
185
+ this.fadeOutToolCallsForPO(name)
186
+ }
187
+
188
+ // Even when 'thinking', scan for tool calls we haven't seen yet.
189
+ // The server often sends status back to 'thinking' between individual
190
+ // tool calls in a multi-tool sequence, so we'd miss them if we only
191
+ // check on the 'calling_tool' transition.
192
+ if (po.status === 'thinking') {
193
+ this.extractAndCreateToolCalls(name, po)
194
+ }
195
+ }
196
+ }
197
+
198
+ syncBusMessages(messages: BusMessage[]): void {
199
+ for (const msg of messages) {
200
+ const arcId = `${msg.from}-${msg.to}-${msg.timestamp}`
201
+ if (this.processedArcIds.has(arcId)) continue
202
+ this.processedArcIds.add(arcId)
203
+
204
+ const fromNode = this.poNodes.get(msg.from)
205
+ const toNode = this.poNodes.get(msg.to)
206
+ if (!fromNode || !toNode) continue
207
+
208
+ // Create arc for all PO-to-PO bus messages
209
+ const arc = new MessageArc(
210
+ arcId,
211
+ msg.from,
212
+ msg.to,
213
+ fromNode.getPosition(),
214
+ toNode.getPosition()
215
+ )
216
+ this.messageArcs.set(arcId, arc)
217
+ this.scene.add(arc.group)
218
+ }
219
+
220
+ // Keep processed sets from growing unbounded
221
+ if (this.processedArcIds.size > 500) {
222
+ const ids = Array.from(this.processedArcIds)
223
+ for (let i = 0; i < 200; i++) {
224
+ this.processedArcIds.delete(ids[i])
225
+ }
226
+ }
227
+ if (this.seenToolCallIds.size > 200) {
228
+ const ids = Array.from(this.seenToolCallIds)
229
+ for (let i = 0; i < 100; i++) {
230
+ this.seenToolCallIds.delete(ids[i])
231
+ }
232
+ }
233
+ }
234
+
235
+ syncNotifications(notifications: Notification[]): void {
236
+ // Count notifications per PO
237
+ const counts = new Map<string, number>()
238
+ for (const n of notifications) {
239
+ counts.set(n.po_name, (counts.get(n.po_name) || 0) + 1)
240
+ }
241
+
242
+ // Update badges on all PO nodes
243
+ for (const [name, node] of this.poNodes) {
244
+ node.setNotificationCount(counts.get(name) || 0)
245
+ }
246
+ }
247
+
248
+ fitAll(): void {
249
+ if (this.poNodes.size === 0) return
250
+
251
+ const bounds = new THREE.Box3()
252
+ for (const node of this.poNodes.values()) {
253
+ bounds.expandByPoint(node.getPosition())
254
+ }
255
+
256
+ // Expand bounds by node radius
257
+ const padding = new THREE.Vector3(100, 100, 0)
258
+ bounds.min.sub(padding)
259
+ bounds.max.add(padding)
260
+
261
+ this.cameraControls.fitAll(bounds)
262
+ }
263
+
264
+ // --- Tool call extraction ---
265
+
266
+ private extractAndCreateToolCalls(poName: string, po: PromptObject): void {
267
+ const messages = po.current_session?.messages
268
+ if (!messages || messages.length === 0) return
269
+
270
+ // Scan recent messages for tool_calls we haven't visualized yet,
271
+ // and also for tool results to enrich existing tool call entries.
272
+ const newToolCalls: ToolCall[] = []
273
+ for (let i = messages.length - 1; i >= 0; i--) {
274
+ const msg = messages[i]
275
+ if (msg.role === 'assistant' && msg.tool_calls) {
276
+ for (const tc of msg.tool_calls) {
277
+ if (!this.seenToolCallIds.has(tc.id)) {
278
+ newToolCalls.push(tc)
279
+ }
280
+ }
281
+ }
282
+ // Enrich with results from tool messages
283
+ if (msg.role === 'tool' && msg.results) {
284
+ for (const result of msg.results) {
285
+ const tcNodeId = `tc-${result.tool_call_id}`
286
+ if (this.toolCallNodes.has(tcNodeId)) {
287
+ useCanvasStore.getState().updateToolCall(tcNodeId, {
288
+ result: result.content,
289
+ status: 'completed',
290
+ completedAt: Date.now(),
291
+ })
292
+ }
293
+ }
294
+ }
295
+ // Don't scan the entire history — last 10 messages is enough
296
+ // (a multi-tool call can generate several messages)
297
+ if (messages.length - 1 - i >= 10) break
298
+ }
299
+
300
+ if (newToolCalls.length === 0) return
301
+
302
+ const callerNode = this.poNodes.get(poName)
303
+ if (!callerNode) return
304
+ const callerPos = callerNode.getPosition()
305
+
306
+ // Get how many tool call nodes this PO already has (for offset positioning)
307
+ let poTcSet = this.poToolCallNodes.get(poName)
308
+ if (!poTcSet) {
309
+ poTcSet = new Set()
310
+ this.poToolCallNodes.set(poName, poTcSet)
311
+ }
312
+
313
+ for (const tc of newToolCalls) {
314
+ this.seenToolCallIds.add(tc.id)
315
+
316
+ // Skip PO-to-PO calls — delegation visuals are handled by server events
317
+ // (po_delegation_started/completed → delegated_by in store → syncPromptObjects)
318
+ if (this.poNodes.has(tc.name)) continue
319
+
320
+ // Primitive tool call: create a diamond ToolCallNode
321
+ const existingCount = poTcSet.size
322
+ const angle = (existingCount * (2 * Math.PI / 6)) - Math.PI / 2
323
+ const offsetDist = NODE.poRadius + 40
324
+ const offsetX = Math.cos(angle) * offsetDist
325
+ const offsetY = Math.sin(angle) * offsetDist
326
+
327
+ const tcNodeId = `tc-${tc.id}`
328
+ const tcNode = new ToolCallNode(tcNodeId, tc.name, poName)
329
+ tcNode.setPosition(callerPos.x + offsetX, callerPos.y + offsetY)
330
+
331
+ this.toolCallNodes.set(tcNodeId, tcNode)
332
+ this.scene.add(tcNode.group)
333
+ poTcSet.add(tcNodeId)
334
+
335
+ // Register in canvas store for inspector
336
+ useCanvasStore.getState().addToolCall({
337
+ id: tcNodeId,
338
+ toolName: tc.name,
339
+ callerPO: poName,
340
+ params: tc.arguments,
341
+ status: 'active',
342
+ startedAt: Date.now(),
343
+ })
344
+ }
345
+ }
346
+
347
+ private fadeOutToolCallsForPO(poName: string): void {
348
+ const tcSet = this.poToolCallNodes.get(poName)
349
+ if (!tcSet) return
350
+
351
+ for (const tcNodeId of tcSet) {
352
+ const tcNode = this.toolCallNodes.get(tcNodeId)
353
+ if (tcNode) {
354
+ tcNode.triggerFadeOut()
355
+ }
356
+ // Mark as completed in canvas store
357
+ useCanvasStore.getState().updateToolCall(tcNodeId, {
358
+ status: 'completed',
359
+ completedAt: Date.now(),
360
+ })
361
+ }
362
+ // Clear the set — expired nodes will be removed by the animation loop
363
+ tcSet.clear()
364
+ }
365
+
366
+ // --- Lifecycle ---
367
+
368
+ start(): void {
369
+ if (this.running) return
370
+ this.running = true
371
+ this.clock.start()
372
+ this.animate()
373
+ }
374
+
375
+ stop(): void {
376
+ this.running = false
377
+ if (this.animationFrameId !== null) {
378
+ cancelAnimationFrame(this.animationFrameId)
379
+ this.animationFrameId = null
380
+ }
381
+ }
382
+
383
+ dispose(): void {
384
+ this.stop()
385
+
386
+ // Dispose all nodes
387
+ for (const node of this.poNodes.values()) {
388
+ node.dispose()
389
+ }
390
+ this.poNodes.clear()
391
+
392
+ for (const node of this.toolCallNodes.values()) {
393
+ node.dispose()
394
+ }
395
+ this.toolCallNodes.clear()
396
+
397
+ for (const arc of this.messageArcs.values()) {
398
+ arc.dispose()
399
+ }
400
+ this.messageArcs.clear()
401
+
402
+ // Dispose Three.js resources
403
+ this.forceLayout.dispose()
404
+ this.cameraControls.dispose()
405
+ this.composer.dispose()
406
+ this.renderer.dispose()
407
+
408
+ // Remove event listeners
409
+ this.renderer.domElement.removeEventListener('click', this.onClick)
410
+ this.renderer.domElement.removeEventListener('pointermove', this.onPointerMove)
411
+ window.removeEventListener('resize', this.onResize)
412
+
413
+ // Remove DOM elements
414
+ this.container.removeChild(this.renderer.domElement)
415
+ this.container.removeChild(this.cssRenderer.domElement)
416
+ }
417
+
418
+ // --- Animation loop ---
419
+
420
+ private animate = (): void => {
421
+ if (!this.running) return
422
+ this.animationFrameId = requestAnimationFrame(this.animate)
423
+
424
+ const delta = this.clock.getDelta()
425
+ const elapsed = this.clock.elapsedTime
426
+
427
+ // 1. Tick force layout → get positions
428
+ this.forceLayout.tick()
429
+ const positions = this.forceLayout.getPositions()
430
+
431
+ // 2. Update PO node positions (lerp) and animations
432
+ for (const [id, node] of this.poNodes) {
433
+ const pos = positions.get(id)
434
+ if (pos) {
435
+ node.setPosition(pos.x, pos.y)
436
+ }
437
+ node.update(delta, elapsed)
438
+ }
439
+
440
+ // 3. Update tool call node positions to follow their caller PO
441
+ for (const [poName, tcSet] of this.poToolCallNodes) {
442
+ const callerNode = this.poNodes.get(poName)
443
+ if (!callerNode) continue
444
+ const callerPos = callerNode.getPosition()
445
+
446
+ let i = 0
447
+ for (const tcNodeId of tcSet) {
448
+ const tcNode = this.toolCallNodes.get(tcNodeId)
449
+ if (!tcNode) continue
450
+ // Keep tool call nodes orbiting around caller
451
+ const angle = (i * (2 * Math.PI / Math.max(tcSet.size, 1))) - Math.PI / 2
452
+ const offsetDist = NODE.poRadius + 40
453
+ tcNode.setPosition(
454
+ callerPos.x + Math.cos(angle) * offsetDist,
455
+ callerPos.y + Math.sin(angle) * offsetDist
456
+ )
457
+ i++
458
+ }
459
+ }
460
+
461
+ // 4. Update arcs (recalculate endpoints, advance particles)
462
+ for (const [id, arc] of this.messageArcs) {
463
+ const fromNode = this.poNodes.get(arc.from)
464
+ const toNode = this.poNodes.get(arc.to)
465
+ if (fromNode && toNode) {
466
+ arc.updateEndpoints(fromNode.getPosition(), toNode.getPosition())
467
+ }
468
+ arc.update(delta)
469
+
470
+ if (arc.isExpired()) {
471
+ this.scene.remove(arc.group)
472
+ arc.dispose()
473
+ this.messageArcs.delete(id)
474
+ }
475
+ }
476
+
477
+ // 5. Update tool call nodes (lifecycle)
478
+ for (const [id, node] of this.toolCallNodes) {
479
+ node.update(delta)
480
+ if (node.isExpired()) {
481
+ this.scene.remove(node.group)
482
+ node.dispose()
483
+ this.toolCallNodes.delete(id)
484
+ // Clean up from canvas store
485
+ useCanvasStore.getState().removeToolCall(id)
486
+ }
487
+ }
488
+
489
+ // 6. Render
490
+ this.composer.render()
491
+ this.cssRenderer.render(this.scene, this.camera)
492
+ }
493
+
494
+ // --- Event handlers ---
495
+
496
+ private onClick = (event: MouseEvent): void => {
497
+ this.updatePointer(event)
498
+ this.raycaster.setFromCamera(this.pointer, this.camera)
499
+
500
+ const meshes = this.getMeshes()
501
+ const intersects = this.raycaster.intersectObjects(meshes)
502
+
503
+ if (intersects.length > 0) {
504
+ const obj = intersects[0].object
505
+ const { type, id } = obj.userData
506
+ if (type && id) {
507
+ useCanvasStore.getState().selectNode({ type, id })
508
+
509
+ // Update visual selection on PO nodes
510
+ for (const node of this.poNodes.values()) {
511
+ node.setSelected(node.id === id)
512
+ }
513
+ return
514
+ }
515
+ }
516
+
517
+ // Click on empty space — deselect
518
+ useCanvasStore.getState().selectNode(null)
519
+ for (const node of this.poNodes.values()) {
520
+ node.setSelected(false)
521
+ }
522
+ }
523
+
524
+ private onPointerMove = (event: MouseEvent): void => {
525
+ this.updatePointer(event)
526
+ this.raycaster.setFromCamera(this.pointer, this.camera)
527
+
528
+ const meshes = this.getMeshes()
529
+ const intersects = this.raycaster.intersectObjects(meshes)
530
+
531
+ // Reset all hover states
532
+ for (const node of this.poNodes.values()) {
533
+ node.setHovered(false)
534
+ }
535
+
536
+ if (intersects.length > 0) {
537
+ const obj = intersects[0].object
538
+ const { type, id } = obj.userData
539
+ if (type === 'po' && id) {
540
+ const node = this.poNodes.get(id)
541
+ if (node) {
542
+ node.setHovered(true)
543
+ }
544
+ }
545
+ this.renderer.domElement.style.cursor = 'pointer'
546
+ useCanvasStore.getState().setHoveredNode(id || null)
547
+ } else {
548
+ this.renderer.domElement.style.cursor = 'default'
549
+ useCanvasStore.getState().setHoveredNode(null)
550
+ }
551
+ }
552
+
553
+ private onResize = (): void => {
554
+ const { width, height } = this.container.getBoundingClientRect()
555
+ const aspect = width / height
556
+ const frustumSize = 500
557
+
558
+ this.camera.left = (-frustumSize * aspect) / 2
559
+ this.camera.right = (frustumSize * aspect) / 2
560
+ this.camera.top = frustumSize / 2
561
+ this.camera.bottom = -frustumSize / 2
562
+ this.camera.updateProjectionMatrix()
563
+
564
+ this.renderer.setSize(width, height)
565
+ this.cssRenderer.setSize(width, height)
566
+ this.composer.setSize(width, height)
567
+ }
568
+
569
+ // --- Helpers ---
570
+
571
+ private updatePointer(event: MouseEvent): void {
572
+ const rect = this.renderer.domElement.getBoundingClientRect()
573
+ this.pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
574
+ this.pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
575
+ }
576
+
577
+ private getMeshes(): THREE.Mesh[] {
578
+ const meshes: THREE.Mesh[] = []
579
+ for (const node of this.poNodes.values()) {
580
+ meshes.push(node.mesh)
581
+ }
582
+ for (const node of this.toolCallNodes.values()) {
583
+ meshes.push(node.mesh)
584
+ }
585
+ return meshes
586
+ }
587
+ }
@@ -0,0 +1,47 @@
1
+ import { create } from 'zustand'
2
+ import type { CanvasNodeSelection, ActiveToolCall } from './types'
3
+
4
+ interface CanvasStore {
5
+ selectedNode: CanvasNodeSelection | null
6
+ hoveredNode: string | null
7
+ showLabels: boolean
8
+ activeToolCalls: Map<string, ActiveToolCall>
9
+
10
+ selectNode: (node: CanvasNodeSelection | null) => void
11
+ setHoveredNode: (id: string | null) => void
12
+ toggleLabels: () => void
13
+ addToolCall: (tc: ActiveToolCall) => void
14
+ updateToolCall: (id: string, update: Partial<ActiveToolCall>) => void
15
+ removeToolCall: (id: string) => void
16
+ }
17
+
18
+ export const useCanvasStore = create<CanvasStore>((set) => ({
19
+ selectedNode: null,
20
+ hoveredNode: null,
21
+ showLabels: true,
22
+ activeToolCalls: new Map(),
23
+
24
+ selectNode: (node) => set({ selectedNode: node }),
25
+ setHoveredNode: (id) => set({ hoveredNode: id }),
26
+ toggleLabels: () => set((s) => ({ showLabels: !s.showLabels })),
27
+ addToolCall: (tc) =>
28
+ set((s) => {
29
+ const next = new Map(s.activeToolCalls)
30
+ next.set(tc.id, tc)
31
+ return { activeToolCalls: next }
32
+ }),
33
+ updateToolCall: (id, update) =>
34
+ set((s) => {
35
+ const existing = s.activeToolCalls.get(id)
36
+ if (!existing) return s
37
+ const next = new Map(s.activeToolCalls)
38
+ next.set(id, { ...existing, ...update })
39
+ return { activeToolCalls: next }
40
+ }),
41
+ removeToolCall: (id) =>
42
+ set((s) => {
43
+ const next = new Map(s.activeToolCalls)
44
+ next.delete(id)
45
+ return { activeToolCalls: next }
46
+ }),
47
+ }))