prompt_objects 0.3.0 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/Gemfile.lock +31 -29
  4. data/exe/prompt_objects +161 -0
  5. data/frontend/package-lock.json +123 -0
  6. data/frontend/package.json +4 -0
  7. data/frontend/src/App.tsx +58 -51
  8. data/frontend/src/canvas/CanvasView.tsx +113 -0
  9. data/frontend/src/canvas/ForceLayout.ts +115 -0
  10. data/frontend/src/canvas/SceneManager.ts +587 -0
  11. data/frontend/src/canvas/canvasStore.ts +47 -0
  12. data/frontend/src/canvas/constants.ts +95 -0
  13. data/frontend/src/canvas/controls/CameraControls.ts +98 -0
  14. data/frontend/src/canvas/edges/MessageArc.ts +149 -0
  15. data/frontend/src/canvas/inspector/InspectorPanel.tsx +31 -0
  16. data/frontend/src/canvas/inspector/POInspector.tsx +262 -0
  17. data/frontend/src/canvas/inspector/ToolCallInspector.tsx +67 -0
  18. data/frontend/src/canvas/nodes/PONode.ts +249 -0
  19. data/frontend/src/canvas/nodes/ToolCallNode.ts +116 -0
  20. data/frontend/src/canvas/types.ts +24 -0
  21. data/frontend/src/components/ChatPanel.tsx +13 -5
  22. data/frontend/src/components/Header.tsx +13 -1
  23. data/frontend/src/hooks/useWebSocket.ts +246 -189
  24. data/frontend/src/index.css +48 -0
  25. data/frontend/src/store/index.ts +19 -0
  26. data/frontend/src/types/index.ts +3 -0
  27. data/lib/prompt_objects/connectors/mcp.rb +3 -6
  28. data/lib/prompt_objects/environment.rb +8 -0
  29. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +1 -2
  30. data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +1 -2
  31. data/lib/prompt_objects/primitives/list_files.rb +1 -2
  32. data/lib/prompt_objects/prompt_object.rb +25 -7
  33. data/lib/prompt_objects/server/app.rb +9 -0
  34. data/lib/prompt_objects/server/public/assets/index-6y64NXFy.css +1 -0
  35. data/lib/prompt_objects/server/public/assets/index-xvyeb-5Z.js +4345 -0
  36. data/lib/prompt_objects/server/public/index.html +2 -2
  37. data/prompt_objects.gemspec +1 -1
  38. data/tools/thread-explorer.html +1043 -0
  39. metadata +18 -4
  40. data/lib/prompt_objects/server/public/assets/index-Bkme6COu.css +0 -1
  41. data/lib/prompt_objects/server/public/assets/index-CQ7lVDF_.js +0 -77
@@ -0,0 +1,249 @@
1
+ import * as THREE from 'three'
2
+ import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'
3
+ import { COLORS, CSS_COLORS, NODE, ANIMATION } from '../constants'
4
+
5
+ const vertexShader = /* glsl */ `
6
+ varying vec2 vUv;
7
+ void main() {
8
+ vUv = uv;
9
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
10
+ }
11
+ `
12
+
13
+ const fragmentShader = /* glsl */ `
14
+ uniform vec3 uColor;
15
+ uniform vec3 uGlowColor;
16
+ uniform float uGlowIntensity;
17
+ uniform float uTime;
18
+ uniform float uHovered;
19
+ uniform float uSelected;
20
+
21
+ varying vec2 vUv;
22
+
23
+ void main() {
24
+ // Distance from center (0 at center, 1 at edge)
25
+ float dist = length(vUv - 0.5) * 2.0;
26
+
27
+ // Base dark fill
28
+ vec3 base = uColor * 0.3;
29
+
30
+ // Edge glow
31
+ float edgeGlow = smoothstep(0.5, 1.0, dist) * uGlowIntensity;
32
+ // Animated pulse
33
+ float pulse = sin(uTime * ${ANIMATION.pulseSpeed.toFixed(1)}) * 0.15 + 0.85;
34
+ edgeGlow *= pulse;
35
+
36
+ vec3 color = mix(base, uGlowColor, edgeGlow);
37
+
38
+ // Hover brightening
39
+ color = mix(color, uGlowColor * 1.2, uHovered * 0.3);
40
+
41
+ // Selection brightening
42
+ color = mix(color, uGlowColor * 1.5, uSelected * 0.4);
43
+
44
+ // Alpha: solid center, slight transparency at edges
45
+ float alpha = smoothstep(1.0, 0.8, dist);
46
+
47
+ gl_FragColor = vec4(color, alpha);
48
+ }
49
+ `
50
+
51
+ type POStatus = 'idle' | 'thinking' | 'calling_tool'
52
+
53
+ const STATUS_COLORS: Record<POStatus, number> = {
54
+ idle: COLORS.statusIdle,
55
+ thinking: COLORS.statusThinking,
56
+ calling_tool: COLORS.statusCallingTool,
57
+ }
58
+
59
+ const STATUS_CSS_COLORS: Record<POStatus, string> = {
60
+ idle: CSS_COLORS.statusIdle,
61
+ thinking: CSS_COLORS.statusThinking,
62
+ calling_tool: CSS_COLORS.statusCallingTool,
63
+ }
64
+
65
+ // Color for when this PO is being called by another PO (delegation)
66
+ const DELEGATED_COLOR = 0x06b6d4 // cyan-500
67
+ const DELEGATED_CSS_COLOR = '#06b6d4'
68
+
69
+ export class PONode {
70
+ readonly id: string
71
+ readonly group: THREE.Group
72
+ readonly mesh: THREE.Mesh
73
+
74
+ private material: THREE.ShaderMaterial
75
+ private statusRing: THREE.LineLoop
76
+ private statusRingMaterial: THREE.LineBasicMaterial
77
+ private label: CSS2DObject
78
+ private labelEl: HTMLDivElement
79
+ private nameEl: HTMLSpanElement
80
+ private statusEl: HTMLSpanElement
81
+ private badge: CSS2DObject
82
+ private badgeEl: HTMLDivElement
83
+ private badgeCountEl: HTMLSpanElement
84
+
85
+ private targetPosition = new THREE.Vector3()
86
+
87
+ constructor(id: string, name: string) {
88
+ this.id = id
89
+ this.group = new THREE.Group()
90
+ this.group.userData = { type: 'po', id }
91
+
92
+ // Hexagonal geometry
93
+ const geometry = new THREE.CircleGeometry(NODE.poRadius, NODE.poSides)
94
+
95
+ // Shader material
96
+ this.material = new THREE.ShaderMaterial({
97
+ vertexShader,
98
+ fragmentShader,
99
+ uniforms: {
100
+ uColor: { value: new THREE.Color(COLORS.nodeFill) },
101
+ uGlowColor: { value: new THREE.Color(COLORS.nodeGlow) },
102
+ uGlowIntensity: { value: 0.5 },
103
+ uTime: { value: 0 },
104
+ uHovered: { value: 0 },
105
+ uSelected: { value: 0 },
106
+ },
107
+ transparent: true,
108
+ })
109
+
110
+ this.mesh = new THREE.Mesh(geometry, this.material)
111
+ this.mesh.userData = { type: 'po', id }
112
+ this.group.add(this.mesh)
113
+
114
+ // Status ring (hex outline)
115
+ const ringGeometry = new THREE.BufferGeometry()
116
+ const ringPoints: THREE.Vector3[] = []
117
+ for (let i = 0; i <= NODE.poSides; i++) {
118
+ const angle = (i / NODE.poSides) * Math.PI * 2 - Math.PI / 2
119
+ ringPoints.push(
120
+ new THREE.Vector3(
121
+ Math.cos(angle) * (NODE.poRadius + 3),
122
+ Math.sin(angle) * (NODE.poRadius + 3),
123
+ 0
124
+ )
125
+ )
126
+ }
127
+ ringGeometry.setFromPoints(ringPoints)
128
+
129
+ this.statusRingMaterial = new THREE.LineBasicMaterial({
130
+ color: STATUS_COLORS.idle,
131
+ transparent: true,
132
+ opacity: 0.6,
133
+ })
134
+ this.statusRing = new THREE.LineLoop(ringGeometry, this.statusRingMaterial)
135
+ this.group.add(this.statusRing)
136
+
137
+ // CSS2D Label (name + status)
138
+ this.labelEl = document.createElement('div')
139
+ this.labelEl.className = 'canvas-node-label'
140
+
141
+ this.nameEl = document.createElement('span')
142
+ this.nameEl.className = 'canvas-node-name'
143
+ this.nameEl.textContent = name
144
+
145
+ this.statusEl = document.createElement('span')
146
+ this.statusEl.className = 'canvas-node-status'
147
+ this.statusEl.textContent = 'idle'
148
+
149
+ this.labelEl.appendChild(this.nameEl)
150
+ this.labelEl.appendChild(this.statusEl)
151
+
152
+ this.label = new CSS2DObject(this.labelEl)
153
+ this.label.position.set(0, -NODE.labelOffsetY, 0)
154
+ this.group.add(this.label)
155
+
156
+ // Notification badge
157
+ this.badgeEl = document.createElement('div')
158
+ this.badgeEl.className = 'canvas-node-badge'
159
+ this.badgeEl.style.display = 'none'
160
+
161
+ this.badgeCountEl = document.createElement('span')
162
+ this.badgeCountEl.textContent = '0'
163
+ this.badgeEl.appendChild(this.badgeCountEl)
164
+
165
+ this.badge = new CSS2DObject(this.badgeEl)
166
+ this.badge.position.set(NODE.badgeOffsetX, -NODE.badgeOffsetY, 0)
167
+ this.group.add(this.badge)
168
+ }
169
+
170
+ setStatus(status: POStatus): void {
171
+ this.statusRingMaterial.color.setHex(STATUS_COLORS[status])
172
+
173
+ // Update glow intensity based on status
174
+ const intensity = status === 'idle' ? 0.3 : status === 'thinking' ? 0.8 : 0.6
175
+ this.material.uniforms.uGlowIntensity.value = intensity
176
+
177
+ // Update glow color for calling_tool
178
+ if (status === 'calling_tool') {
179
+ this.material.uniforms.uGlowColor.value.setHex(COLORS.statusCallingTool)
180
+ } else {
181
+ this.material.uniforms.uGlowColor.value.setHex(COLORS.nodeGlow)
182
+ }
183
+
184
+ this.statusEl.textContent = status.replace('_', ' ')
185
+ this.statusEl.style.color = STATUS_CSS_COLORS[status]
186
+ }
187
+
188
+ setDelegatedBy(callerName: string): void {
189
+ // Override visual to show this PO is working on behalf of another PO.
190
+ // The server doesn't send status updates for delegated POs, so we
191
+ // infer this from the caller's tool_calls.
192
+ this.statusRingMaterial.color.setHex(DELEGATED_COLOR)
193
+ this.statusRingMaterial.opacity = 1.0
194
+ this.material.uniforms.uGlowColor.value.setHex(DELEGATED_COLOR)
195
+ this.material.uniforms.uGlowIntensity.value = 0.7
196
+ this.statusEl.textContent = `called by ${callerName}`
197
+ this.statusEl.style.color = DELEGATED_CSS_COLOR
198
+ }
199
+
200
+ clearDelegated(): void {
201
+ // Restore to whatever the server status is — caller should
202
+ // call setStatus() after this to re-apply server state.
203
+ this.statusRingMaterial.opacity = 0.6
204
+ }
205
+
206
+ setNotificationCount(count: number): void {
207
+ if (count > 0) {
208
+ this.badgeEl.style.display = 'flex'
209
+ this.badgeCountEl.textContent = String(count)
210
+ } else {
211
+ this.badgeEl.style.display = 'none'
212
+ }
213
+ }
214
+
215
+ setHovered(hovered: boolean): void {
216
+ this.material.uniforms.uHovered.value = hovered ? 1 : 0
217
+ }
218
+
219
+ setSelected(selected: boolean): void {
220
+ this.material.uniforms.uSelected.value = selected ? 1 : 0
221
+ this.statusRingMaterial.opacity = selected ? 1.0 : 0.6
222
+ }
223
+
224
+ setPosition(x: number, y: number): void {
225
+ this.targetPosition.set(x, y, 0)
226
+ }
227
+
228
+ getPosition(): THREE.Vector3 {
229
+ return this.group.position.clone()
230
+ }
231
+
232
+ update(_delta: number, elapsed: number): void {
233
+ // Lerp toward target position
234
+ this.group.position.lerp(this.targetPosition, ANIMATION.positionLerpFactor)
235
+
236
+ // Update time uniform for pulse animation
237
+ this.material.uniforms.uTime.value = elapsed
238
+ }
239
+
240
+ dispose(): void {
241
+ this.mesh.geometry.dispose()
242
+ this.material.dispose()
243
+ this.statusRing.geometry.dispose()
244
+ this.statusRingMaterial.dispose()
245
+ this.labelEl.remove()
246
+ this.badgeEl.remove()
247
+ this.group.parent?.remove(this.group)
248
+ }
249
+ }
@@ -0,0 +1,116 @@
1
+ import * as THREE from 'three'
2
+ import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'
3
+ import { COLORS, NODE, ANIMATION } from '../constants'
4
+
5
+ type Phase = 'fadein' | 'active' | 'fadeout' | 'expired'
6
+
7
+ export class ToolCallNode {
8
+ readonly id: string
9
+ readonly callerPO: string
10
+ readonly group: THREE.Group
11
+ readonly mesh: THREE.Mesh
12
+
13
+ private material: THREE.MeshBasicMaterial
14
+ private label: CSS2DObject
15
+ private labelEl: HTMLDivElement
16
+ private phase: Phase = 'fadein'
17
+ private phaseTime = 0
18
+ private targetPosition = new THREE.Vector3()
19
+
20
+ constructor(id: string, toolName: string, callerPO: string) {
21
+ this.id = id
22
+ this.callerPO = callerPO
23
+ this.group = new THREE.Group()
24
+ this.group.userData = { type: 'toolcall', id }
25
+
26
+ // Diamond shape (rotated square)
27
+ const r = NODE.toolCallRadius
28
+ const shape = new THREE.Shape()
29
+ shape.moveTo(0, r)
30
+ shape.lineTo(r, 0)
31
+ shape.lineTo(0, -r)
32
+ shape.lineTo(-r, 0)
33
+ shape.closePath()
34
+
35
+ const geometry = new THREE.ShapeGeometry(shape)
36
+
37
+ this.material = new THREE.MeshBasicMaterial({
38
+ color: COLORS.toolCallFill,
39
+ transparent: true,
40
+ opacity: 0,
41
+ })
42
+
43
+ this.mesh = new THREE.Mesh(geometry, this.material)
44
+ this.mesh.userData = { type: 'toolcall', id }
45
+ this.group.add(this.mesh)
46
+
47
+ // Label
48
+ this.labelEl = document.createElement('div')
49
+ this.labelEl.className = 'canvas-toolcall-label'
50
+ this.labelEl.textContent = toolName
51
+
52
+ this.label = new CSS2DObject(this.labelEl)
53
+ this.label.position.set(0, -(NODE.toolCallRadius + 12), 0)
54
+ this.group.add(this.label)
55
+ }
56
+
57
+ setPosition(x: number, y: number): void {
58
+ this.targetPosition.set(x, y, 0)
59
+ }
60
+
61
+ triggerFadeOut(): void {
62
+ if (this.phase !== 'expired') {
63
+ this.phase = 'fadeout'
64
+ this.phaseTime = 0
65
+ }
66
+ }
67
+
68
+ isExpired(): boolean {
69
+ return this.phase === 'expired'
70
+ }
71
+
72
+ update(delta: number): void {
73
+ this.phaseTime += delta
74
+ this.group.position.lerp(this.targetPosition, ANIMATION.positionLerpFactor)
75
+
76
+ switch (this.phase) {
77
+ case 'fadein':
78
+ this.material.opacity = Math.min(this.phaseTime / ANIMATION.toolCallFadeInDuration, 1)
79
+ if (this.phaseTime >= ANIMATION.toolCallFadeInDuration) {
80
+ this.phase = 'active'
81
+ this.phaseTime = 0
82
+ }
83
+ break
84
+
85
+ case 'active':
86
+ this.material.opacity = 1
87
+ if (this.phaseTime >= ANIMATION.toolCallActiveDuration) {
88
+ this.phase = 'fadeout'
89
+ this.phaseTime = 0
90
+ }
91
+ break
92
+
93
+ case 'fadeout':
94
+ this.material.opacity = Math.max(
95
+ 1 - this.phaseTime / ANIMATION.toolCallFadeOutDuration,
96
+ 0
97
+ )
98
+ this.labelEl.style.opacity = String(this.material.opacity)
99
+ if (this.phaseTime >= ANIMATION.toolCallFadeOutDuration) {
100
+ this.phase = 'expired'
101
+ }
102
+ break
103
+
104
+ case 'expired':
105
+ this.material.opacity = 0
106
+ break
107
+ }
108
+ }
109
+
110
+ dispose(): void {
111
+ this.mesh.geometry.dispose()
112
+ this.material.dispose()
113
+ this.labelEl.remove()
114
+ this.group.parent?.remove(this.group)
115
+ }
116
+ }
@@ -0,0 +1,24 @@
1
+ // Canvas-specific type definitions
2
+
3
+ export interface CanvasNodeSelection {
4
+ type: 'po' | 'toolcall'
5
+ id: string
6
+ }
7
+
8
+ export interface ActiveToolCall {
9
+ id: string
10
+ toolName: string
11
+ callerPO: string
12
+ params: Record<string, unknown>
13
+ status: 'active' | 'completed' | 'error'
14
+ result?: string
15
+ startedAt: number
16
+ completedAt?: number
17
+ }
18
+
19
+ export interface ActiveMessageArc {
20
+ id: string
21
+ from: string
22
+ to: string
23
+ timestamp: number
24
+ }
@@ -12,11 +12,13 @@ export function ChatPanel({ po, sendMessage }: ChatPanelProps) {
12
12
  const [input, setInput] = useState('')
13
13
  const [continueThread, setContinueThread] = useState(false)
14
14
  const messagesEndRef = useRef<HTMLDivElement>(null)
15
- const { streamingContent } = useStore()
15
+ const { streamingContent, connected } = useStore()
16
16
 
17
17
  const messages = po.current_session?.messages || []
18
18
  const streaming = streamingContent[po.name]
19
19
  const hasMessages = messages.length > 0
20
+ const isBusy = po.status !== 'idle' && connected
21
+ const canSend = connected && !isBusy && !!input.trim()
20
22
 
21
23
  useEffect(() => {
22
24
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -72,18 +74,24 @@ export function ChatPanel({ po, sendMessage }: ChatPanelProps) {
72
74
 
73
75
  {/* Input */}
74
76
  <form onSubmit={handleSubmit} className="border-t border-po-border p-4">
77
+ {!connected && (
78
+ <div className="mb-2 text-xs text-po-warning flex items-center gap-2">
79
+ <div className="w-2 h-2 rounded-full bg-po-warning animate-pulse" />
80
+ Reconnecting...
81
+ </div>
82
+ )}
75
83
  <div className="flex gap-3">
76
84
  <input
77
85
  type="text"
78
86
  value={input}
79
87
  onChange={(e) => setInput(e.target.value)}
80
- placeholder={`Message ${po.name}...`}
81
- className="flex-1 bg-po-surface border border-po-border rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-po-accent"
82
- disabled={po.status !== 'idle'}
88
+ placeholder={!connected ? 'Disconnected — waiting to reconnect...' : isBusy ? `${po.name} is ${po.status.replace('_', ' ')}...` : `Message ${po.name}...`}
89
+ className="flex-1 bg-po-surface border border-po-border rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-po-accent disabled:opacity-50"
90
+ disabled={isBusy}
83
91
  />
84
92
  <button
85
93
  type="submit"
86
- disabled={!input.trim() || po.status !== 'idle'}
94
+ disabled={!canSend}
87
95
  className="px-4 py-2 bg-po-accent text-white rounded-lg font-medium hover:bg-po-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
88
96
  >
89
97
  Send
@@ -7,7 +7,7 @@ interface Props {
7
7
  }
8
8
 
9
9
  export function Header({ switchLLM }: Props) {
10
- const { connected, environment, selectedPO, selectPO, toggleBus, busOpen, notifications } =
10
+ const { connected, environment, selectedPO, selectPO, toggleBus, busOpen, notifications, currentView, setCurrentView } =
11
11
  useStore()
12
12
  const notificationCount = useNotificationCount()
13
13
  const [showNotifications, setShowNotifications] = useState(false)
@@ -125,6 +125,18 @@ export function Header({ switchLLM }: Props) {
125
125
  )}
126
126
  </div>
127
127
 
128
+ {/* Canvas toggle */}
129
+ <button
130
+ onClick={() => setCurrentView(currentView === 'canvas' ? 'dashboard' : 'canvas')}
131
+ className={`px-3 py-1.5 text-sm rounded transition-colors ${
132
+ currentView === 'canvas'
133
+ ? 'bg-po-accent text-white'
134
+ : 'bg-po-border text-gray-300 hover:bg-po-accent/50'
135
+ }`}
136
+ >
137
+ Canvas
138
+ </button>
139
+
128
140
  {/* Message Bus toggle */}
129
141
  <button
130
142
  onClick={toggleBus}