prompt_objects 0.3.1 → 0.4.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/Gemfile.lock +31 -29
  4. data/frontend/package-lock.json +123 -0
  5. data/frontend/package.json +4 -0
  6. data/frontend/src/App.tsx +58 -51
  7. data/frontend/src/canvas/CanvasView.tsx +113 -0
  8. data/frontend/src/canvas/ForceLayout.ts +115 -0
  9. data/frontend/src/canvas/SceneManager.ts +587 -0
  10. data/frontend/src/canvas/canvasStore.ts +47 -0
  11. data/frontend/src/canvas/constants.ts +95 -0
  12. data/frontend/src/canvas/controls/CameraControls.ts +98 -0
  13. data/frontend/src/canvas/edges/MessageArc.ts +149 -0
  14. data/frontend/src/canvas/inspector/InspectorPanel.tsx +31 -0
  15. data/frontend/src/canvas/inspector/POInspector.tsx +262 -0
  16. data/frontend/src/canvas/inspector/ToolCallInspector.tsx +67 -0
  17. data/frontend/src/canvas/nodes/PONode.ts +249 -0
  18. data/frontend/src/canvas/nodes/ToolCallNode.ts +116 -0
  19. data/frontend/src/canvas/types.ts +24 -0
  20. data/frontend/src/components/ChatPanel.tsx +13 -5
  21. data/frontend/src/components/Header.tsx +13 -1
  22. data/frontend/src/hooks/useWebSocket.ts +246 -189
  23. data/frontend/src/index.css +48 -0
  24. data/frontend/src/store/index.ts +19 -0
  25. data/frontend/src/types/index.ts +3 -0
  26. data/lib/prompt_objects/connectors/mcp.rb +3 -6
  27. data/lib/prompt_objects/environment.rb +8 -0
  28. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +1 -2
  29. data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +1 -2
  30. data/lib/prompt_objects/primitives/list_files.rb +1 -2
  31. data/lib/prompt_objects/prompt_object.rb +24 -6
  32. data/lib/prompt_objects/server/app.rb +9 -0
  33. data/lib/prompt_objects/server/public/assets/index-6y64NXFy.css +1 -0
  34. data/lib/prompt_objects/server/public/assets/index-xvyeb-5Z.js +4345 -0
  35. data/lib/prompt_objects/server/public/index.html +2 -2
  36. data/prompt_objects.gemspec +1 -1
  37. metadata +17 -4
  38. data/lib/prompt_objects/server/public/assets/index-Bkme6COu.css +0 -1
  39. data/lib/prompt_objects/server/public/assets/index-CQ7lVDF_.js +0 -77
@@ -0,0 +1,113 @@
1
+ import { useEffect, useRef, useCallback } from 'react'
2
+ import { useStore } from '../store'
3
+ import { useCanvasStore } from './canvasStore'
4
+ import { SceneManager } from './SceneManager'
5
+ import { InspectorPanel } from './inspector/InspectorPanel'
6
+
7
+ export function CanvasView() {
8
+ const containerRef = useRef<HTMLDivElement>(null)
9
+ const sceneRef = useRef<SceneManager | null>(null)
10
+ const syncScheduled = useRef(false)
11
+
12
+ // Schedule a throttled sync via requestAnimationFrame
13
+ const scheduleSync = useCallback(() => {
14
+ if (syncScheduled.current) return
15
+ syncScheduled.current = true
16
+ requestAnimationFrame(() => {
17
+ syncScheduled.current = false
18
+ const scene = sceneRef.current
19
+ if (!scene) return
20
+ const state = useStore.getState()
21
+ scene.syncPromptObjects(state.promptObjects)
22
+ scene.syncBusMessages(state.busMessages)
23
+ scene.syncNotifications(state.notifications)
24
+ })
25
+ }, [])
26
+
27
+ // Mount/unmount SceneManager
28
+ useEffect(() => {
29
+ const container = containerRef.current
30
+ if (!container) return
31
+
32
+ const scene = new SceneManager(container)
33
+ sceneRef.current = scene
34
+
35
+ // Initial sync
36
+ const state = useStore.getState()
37
+ scene.syncPromptObjects(state.promptObjects)
38
+ scene.syncBusMessages(state.busMessages)
39
+ scene.syncNotifications(state.notifications)
40
+ scene.start()
41
+
42
+ // Fit all after a short delay to let force layout settle
43
+ const fitTimer = setTimeout(() => scene.fitAll(), 500)
44
+
45
+ return () => {
46
+ clearTimeout(fitTimer)
47
+ scene.dispose()
48
+ sceneRef.current = null
49
+ }
50
+ }, [])
51
+
52
+ // Subscribe to store changes (non-React API to avoid re-renders)
53
+ useEffect(() => {
54
+ const unsub = useStore.subscribe(scheduleSync)
55
+ return unsub
56
+ }, [scheduleSync])
57
+
58
+ // Keyboard shortcuts
59
+ useEffect(() => {
60
+ const handler = (e: KeyboardEvent) => {
61
+ if (e.key === 'f' && !e.ctrlKey && !e.metaKey) {
62
+ // Don't intercept when typing in an input
63
+ if ((e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA') return
64
+ sceneRef.current?.fitAll()
65
+ }
66
+ if (e.key === 'Escape') {
67
+ useCanvasStore.getState().selectNode(null)
68
+ }
69
+ }
70
+ window.addEventListener('keydown', handler)
71
+ return () => window.removeEventListener('keydown', handler)
72
+ }, [])
73
+
74
+ const showLabels = useCanvasStore((s) => s.showLabels)
75
+ const toggleLabels = useCanvasStore((s) => s.toggleLabels)
76
+
77
+ return (
78
+ <div className="flex-1 flex overflow-hidden relative">
79
+ {/* Three.js container */}
80
+ <div ref={containerRef} className="flex-1 relative" />
81
+
82
+ {/* Toolbar overlay */}
83
+ <div className="absolute top-3 left-3 flex gap-2 z-10">
84
+ <button
85
+ onClick={() => sceneRef.current?.fitAll()}
86
+ className="px-3 py-1.5 text-sm bg-po-surface/80 backdrop-blur border border-po-border rounded hover:border-po-accent transition-colors text-gray-300 hover:text-white"
87
+ title="Fit all nodes (F)"
88
+ >
89
+ Fit All
90
+ </button>
91
+ <button
92
+ onClick={toggleLabels}
93
+ className={`px-3 py-1.5 text-sm backdrop-blur border rounded transition-colors ${
94
+ showLabels
95
+ ? 'bg-po-accent/20 border-po-accent text-white'
96
+ : 'bg-po-surface/80 border-po-border text-gray-300 hover:text-white'
97
+ }`}
98
+ title="Toggle labels"
99
+ >
100
+ Labels
101
+ </button>
102
+ </div>
103
+
104
+ {/* Help hint */}
105
+ <div className="absolute bottom-3 left-3 text-xs text-gray-500 z-10">
106
+ Scroll to zoom · Shift+drag to pan · F to fit · Click node to inspect
107
+ </div>
108
+
109
+ {/* Inspector panel */}
110
+ <InspectorPanel />
111
+ </div>
112
+ )
113
+ }
@@ -0,0 +1,115 @@
1
+ import {
2
+ forceSimulation,
3
+ forceManyBody,
4
+ forceCenter,
5
+ forceCollide,
6
+ forceLink,
7
+ type Simulation,
8
+ type SimulationNodeDatum,
9
+ type SimulationLinkDatum,
10
+ } from 'd3-force'
11
+ import { FORCE } from './constants'
12
+
13
+ export interface ForceNode extends SimulationNodeDatum {
14
+ id: string
15
+ type: 'po' | 'toolcall'
16
+ }
17
+
18
+ export interface ForceLink extends SimulationLinkDatum<ForceNode> {
19
+ id: string
20
+ }
21
+
22
+ export class ForceLayout {
23
+ private simulation: Simulation<ForceNode, ForceLink>
24
+ private nodes: ForceNode[] = []
25
+ private links: ForceLink[] = []
26
+ private dirty = false
27
+
28
+ constructor() {
29
+ this.simulation = forceSimulation<ForceNode, ForceLink>()
30
+ .alphaDecay(FORCE.alphaDecay)
31
+ .velocityDecay(FORCE.velocityDecay)
32
+ .force('charge', forceManyBody<ForceNode>().strength(FORCE.chargeStrength))
33
+ .force('center', forceCenter<ForceNode>(0, 0).strength(FORCE.centerStrength))
34
+ .force(
35
+ 'collision',
36
+ forceCollide<ForceNode>().radius(FORCE.collisionRadius)
37
+ )
38
+ .force(
39
+ 'link',
40
+ forceLink<ForceNode, ForceLink>()
41
+ .id((d) => d.id)
42
+ .distance(FORCE.linkDistance)
43
+ )
44
+ .stop() // Manual tick mode — we call tick() from animation loop
45
+ }
46
+
47
+ addNode(id: string, type: 'po' | 'toolcall'): void {
48
+ if (this.nodes.find((n) => n.id === id)) return
49
+ this.nodes.push({ id, type })
50
+ this.dirty = true
51
+ }
52
+
53
+ removeNode(id: string): void {
54
+ const idx = this.nodes.findIndex((n) => n.id === id)
55
+ if (idx === -1) return
56
+ this.nodes.splice(idx, 1)
57
+ // Also remove any links referencing this node
58
+ this.links = this.links.filter((l) => {
59
+ const src = typeof l.source === 'object' ? (l.source as ForceNode).id : l.source
60
+ const tgt = typeof l.target === 'object' ? (l.target as ForceNode).id : l.target
61
+ return src !== id && tgt !== id
62
+ })
63
+ this.dirty = true
64
+ }
65
+
66
+ addLink(id: string, sourceId: string, targetId: string): void {
67
+ if (this.links.find((l) => l.id === id)) return
68
+ this.links.push({ id, source: sourceId, target: targetId })
69
+ this.dirty = true
70
+ }
71
+
72
+ removeLink(id: string): void {
73
+ const idx = this.links.findIndex((l) => l.id === id)
74
+ if (idx === -1) return
75
+ this.links.splice(idx, 1)
76
+ this.dirty = true
77
+ }
78
+
79
+ tick(): void {
80
+ if (this.dirty) {
81
+ this.rebuild()
82
+ this.dirty = false
83
+ }
84
+ this.simulation.tick()
85
+ }
86
+
87
+ getPositions(): Map<string, { x: number; y: number }> {
88
+ const positions = new Map<string, { x: number; y: number }>()
89
+ for (const node of this.nodes) {
90
+ positions.set(node.id, { x: node.x ?? 0, y: node.y ?? 0 })
91
+ }
92
+ return positions
93
+ }
94
+
95
+ reheat(): void {
96
+ this.simulation.alpha(0.8).restart().stop()
97
+ }
98
+
99
+ private rebuild(): void {
100
+ this.simulation.nodes(this.nodes)
101
+ const linkForce = this.simulation.force('link') as ReturnType<
102
+ typeof forceLink<ForceNode, ForceLink>
103
+ >
104
+ if (linkForce) {
105
+ linkForce.links(this.links)
106
+ }
107
+ this.reheat()
108
+ }
109
+
110
+ dispose(): void {
111
+ this.simulation.stop()
112
+ this.nodes = []
113
+ this.links = []
114
+ }
115
+ }