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,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 = 0x5a8fc2 // steel blue (matches po-status-delegated)
67
+ const DELEGATED_CSS_COLOR = '#5a8fc2'
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
+ }
@@ -35,7 +35,6 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
35
35
  }
36
36
  }, [onClose])
37
37
 
38
- // Adjust position to stay within viewport
39
38
  const adjustedStyle = {
40
39
  top: y,
41
40
  left: x,
@@ -44,7 +43,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
44
43
  return (
45
44
  <div
46
45
  ref={menuRef}
47
- className="fixed z-50 bg-po-surface border border-po-border rounded-lg shadow-xl py-1 min-w-[160px]"
46
+ className="fixed z-50 bg-po-surface-2 border border-po-border rounded shadow-xl py-0.5 min-w-[140px]"
48
47
  style={adjustedStyle}
49
48
  >
50
49
  {items.map((item, idx) => (
@@ -54,8 +53,10 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
54
53
  item.onClick()
55
54
  onClose()
56
55
  }}
57
- className={`w-full text-left px-3 py-1.5 text-xs hover:bg-po-bg transition-colors flex items-center gap-2 ${
58
- item.danger ? 'text-red-400 hover:text-red-300' : 'text-gray-300 hover:text-white'
56
+ className={`w-full text-left px-2.5 py-1.5 text-xs transition-colors duration-150 flex items-center gap-1.5 ${
57
+ item.danger
58
+ ? 'text-po-error hover:bg-po-surface-3'
59
+ : 'text-po-text-secondary hover:bg-po-surface-3 hover:text-po-text-primary'
59
60
  }`}
60
61
  >
61
62
  {item.icon && <span>{item.icon}</span>}
@@ -0,0 +1,232 @@
1
+ import { useState, useMemo } from 'react'
2
+ import { useStore, usePONotifications } from '../store'
3
+ import { useResize } from '../hooks/useResize'
4
+ import { MethodList } from './MethodList'
5
+ import { SourcePane } from './SourcePane'
6
+ import { Workspace } from './Workspace'
7
+ import { ContextMenu } from './ContextMenu'
8
+ import { PaneSlot } from './PaneSlot'
9
+ import type { PromptObject, CapabilityInfo } from '../types'
10
+
11
+ interface InspectorProps {
12
+ po: PromptObject
13
+ sendMessage: (target: string, content: string, newThread?: boolean) => void
14
+ createSession?: (target: string, name?: string) => void
15
+ switchSession: (target: string, sessionId: string) => void
16
+ createThread: (target: string) => void
17
+ updatePrompt: (target: string, prompt: string) => void
18
+ requestUsage?: (sessionId: string, includeTree?: boolean) => void
19
+ exportThread?: (sessionId: string, format?: string) => void
20
+ }
21
+
22
+ export function Inspector({
23
+ po,
24
+ sendMessage,
25
+ switchSession,
26
+ createThread,
27
+ updatePrompt,
28
+ requestUsage,
29
+ exportThread,
30
+ }: InspectorProps) {
31
+ const [selectedCapability, setSelectedCapability] = useState<CapabilityInfo | null>(null)
32
+ const [threadMenuOpen, setThreadMenuOpen] = useState(false)
33
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number; sessionId: string } | null>(null)
34
+ const notifications = usePONotifications(po.name)
35
+ const topPaneCollapsed = useStore((s) => s.topPaneCollapsed)
36
+ const toggleTopPane = useStore((s) => s.toggleTopPane)
37
+
38
+ const topPaneResize = useResize({
39
+ direction: 'vertical',
40
+ initialSize: 260,
41
+ minSize: 120,
42
+ maxSize: 600,
43
+ })
44
+
45
+ const methodListResize = useResize({
46
+ direction: 'horizontal',
47
+ initialSize: 192,
48
+ minSize: 120,
49
+ maxSize: 320,
50
+ })
51
+
52
+ const sessions = po.sessions || []
53
+ const currentSessionId = po.current_session?.id
54
+
55
+ // Sort sessions: current first, then by updated_at desc
56
+ const sortedSessions = useMemo(() => {
57
+ return [...sessions].sort((a, b) => {
58
+ if (a.id === currentSessionId) return -1
59
+ if (b.id === currentSessionId) return 1
60
+ return (b.updated_at || '').localeCompare(a.updated_at || '')
61
+ })
62
+ }, [sessions, currentSessionId])
63
+
64
+ const isActive = po.status !== 'idle'
65
+
66
+ const statusDot = {
67
+ idle: 'bg-po-status-idle',
68
+ thinking: 'bg-po-status-active',
69
+ calling_tool: 'bg-po-status-calling',
70
+ }[po.status] || 'bg-po-status-idle'
71
+
72
+ const statusGlow = {
73
+ idle: '',
74
+ thinking: 'shadow-[0_0_6px_rgba(212,149,42,0.7)]',
75
+ calling_tool: 'shadow-[0_0_6px_rgba(59,154,110,0.7)]',
76
+ }[po.status] || ''
77
+
78
+ const statusLabelColor = {
79
+ idle: 'text-po-text-ghost',
80
+ thinking: 'text-po-status-active',
81
+ calling_tool: 'text-po-status-calling',
82
+ }[po.status] || 'text-po-text-ghost'
83
+
84
+ const statusLabel = {
85
+ idle: 'idle',
86
+ thinking: 'thinking...',
87
+ calling_tool: 'calling tool...',
88
+ }[po.status] || po.status
89
+
90
+ const handleThreadContextMenu = (e: React.MouseEvent, sessionId: string) => {
91
+ e.preventDefault()
92
+ setContextMenu({ x: e.clientX, y: e.clientY, sessionId })
93
+ }
94
+
95
+ return (
96
+ <div className="h-full flex flex-col">
97
+ {/* Inspector Header */}
98
+ <div className="h-8 bg-po-surface-2 border-b border-po-border flex items-center px-3 gap-2 flex-shrink-0">
99
+ <div className="relative flex-shrink-0">
100
+ <div className={`w-2 h-2 rounded-full ${statusDot} ${statusGlow} ${isActive ? 'animate-pulse' : ''}`} />
101
+ </div>
102
+ <span className="font-mono text-xs text-po-text-primary font-medium">{po.name}</span>
103
+ <span className={`text-2xs font-medium truncate ${statusLabelColor} ${isActive ? 'animate-pulse' : ''}`}>{statusLabel}</span>
104
+ {po.description && (
105
+ <span className="text-2xs text-po-text-ghost truncate hidden sm:inline">{po.description}</span>
106
+ )}
107
+ {notifications.length > 0 && (
108
+ <span className="text-2xs font-mono bg-po-warning text-po-bg px-1 rounded font-bold">
109
+ {notifications.length}
110
+ </span>
111
+ )}
112
+
113
+ <div className="flex-1" />
114
+
115
+ {/* Thread picker */}
116
+ <div className="relative">
117
+ <button
118
+ onClick={() => setThreadMenuOpen(!threadMenuOpen)}
119
+ className="flex items-center gap-1 text-2xs text-po-text-secondary hover:text-po-text-primary transition-colors duration-150"
120
+ >
121
+ <span className="font-mono">
122
+ {currentSessionId
123
+ ? sessions.find(s => s.id === currentSessionId)?.name || `Thread ${currentSessionId.slice(0, 6)}`
124
+ : 'No thread'}
125
+ </span>
126
+ <svg className={`w-3 h-3 transition-transform ${threadMenuOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
127
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
128
+ </svg>
129
+ </button>
130
+
131
+ {threadMenuOpen && (
132
+ <div className="absolute right-0 top-full mt-1 w-56 bg-po-surface-2 border border-po-border rounded shadow-xl z-50 overflow-hidden">
133
+ <button
134
+ onClick={() => { createThread(po.name); setThreadMenuOpen(false) }}
135
+ className="w-full text-left px-2.5 py-1.5 text-xs text-po-accent hover:bg-po-surface-3 transition-colors duration-150 border-b border-po-border"
136
+ >
137
+ + New Thread
138
+ </button>
139
+ <div className="max-h-48 overflow-auto">
140
+ {sortedSessions.map((session) => (
141
+ <button
142
+ key={session.id}
143
+ onClick={() => { switchSession(po.name, session.id); setThreadMenuOpen(false) }}
144
+ onContextMenu={(e) => handleThreadContextMenu(e, session.id)}
145
+ className={`w-full text-left px-2.5 py-1.5 text-xs transition-colors duration-150 ${
146
+ session.id === currentSessionId
147
+ ? 'bg-po-accent-wash text-po-accent'
148
+ : 'text-po-text-secondary hover:bg-po-surface-3'
149
+ }`}
150
+ >
151
+ <div className="flex items-center gap-1.5">
152
+ {session.thread_type === 'delegation' && <span className="text-po-status-delegated">&#8627;</span>}
153
+ <span className="font-mono truncate flex-1">
154
+ {session.name || `Thread ${session.id.slice(0, 6)}`}
155
+ </span>
156
+ <span className="text-2xs text-po-text-ghost">{session.message_count}m</span>
157
+ </div>
158
+ {session.parent_po && (
159
+ <div className="text-2xs text-po-status-delegated mt-0.5">from {session.parent_po}</div>
160
+ )}
161
+ </button>
162
+ ))}
163
+ </div>
164
+ </div>
165
+ )}
166
+ </div>
167
+ </div>
168
+
169
+ {/* Top: Methods + Source (collapsible, resizable height) */}
170
+ <PaneSlot
171
+ label="Methods | Source"
172
+ collapsed={topPaneCollapsed}
173
+ onToggle={toggleTopPane}
174
+ height={topPaneResize.size}
175
+ resizeHandle={
176
+ <div
177
+ className="resize-handle-h"
178
+ onMouseDown={topPaneResize.onMouseDown}
179
+ />
180
+ }
181
+ >
182
+ <div className="flex h-full">
183
+ {/* Method List (resizable width) */}
184
+ <div style={{ width: methodListResize.size }} className="flex-shrink-0">
185
+ <MethodList
186
+ po={po}
187
+ selectedCapability={selectedCapability}
188
+ onSelectCapability={setSelectedCapability}
189
+ />
190
+ </div>
191
+
192
+ {/* Resize handle */}
193
+ <div
194
+ className="resize-handle"
195
+ onMouseDown={methodListResize.onMouseDown}
196
+ />
197
+
198
+ {/* Source Pane */}
199
+ <SourcePane
200
+ po={po}
201
+ selectedCapability={selectedCapability}
202
+ onSave={(prompt) => updatePrompt(po.name, prompt)}
203
+ />
204
+ </div>
205
+ </PaneSlot>
206
+
207
+ {/* Bottom: Workspace */}
208
+ <div className="flex-1 overflow-hidden">
209
+ <Workspace po={po} sendMessage={sendMessage} />
210
+ </div>
211
+
212
+ {/* Context menu for thread right-click */}
213
+ {contextMenu && (
214
+ <ContextMenu
215
+ x={contextMenu.x}
216
+ y={contextMenu.y}
217
+ onClose={() => setContextMenu(null)}
218
+ items={[
219
+ ...(requestUsage ? [
220
+ { label: 'View Usage', onClick: () => requestUsage(contextMenu.sessionId) },
221
+ { label: 'View Tree Usage', onClick: () => requestUsage(contextMenu.sessionId, true) },
222
+ ] : []),
223
+ ...(exportThread ? [
224
+ { label: 'Export Markdown', onClick: () => exportThread(contextMenu.sessionId, 'markdown') },
225
+ { label: 'Export JSON', onClick: () => exportThread(contextMenu.sessionId, 'json') },
226
+ ] : []),
227
+ ]}
228
+ />
229
+ )}
230
+ </div>
231
+ )
232
+ }