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
@@ -1,141 +0,0 @@
1
- import { useState } from 'react'
2
- import type { PromptObject, CapabilityInfo } from '../types'
3
-
4
- interface CapabilitiesPanelProps {
5
- po: PromptObject
6
- }
7
-
8
- export function CapabilitiesPanel({ po }: CapabilitiesPanelProps) {
9
- const capabilities = po.capabilities || []
10
- const universalCapabilities = po.universal_capabilities || []
11
-
12
- return (
13
- <div className="h-full overflow-auto p-4">
14
- {/* Declared Capabilities */}
15
- <div className="mb-6">
16
- <h3 className="text-lg font-medium text-white mb-3">
17
- Declared Capabilities
18
- <span className="ml-2 text-sm text-gray-500">({capabilities.length})</span>
19
- </h3>
20
-
21
- {capabilities.length === 0 ? (
22
- <div className="text-gray-500 text-sm py-4 px-3 bg-po-bg rounded-lg border border-po-border">
23
- No capabilities declared. This PO can only use universal capabilities.
24
- </div>
25
- ) : (
26
- <div className="space-y-1">
27
- {capabilities.map((cap) => (
28
- <CapabilityItem key={cap.name} capability={cap} accent />
29
- ))}
30
- </div>
31
- )}
32
- </div>
33
-
34
- {/* Universal Capabilities */}
35
- <div>
36
- <h3 className="text-lg font-medium text-white mb-3">
37
- Universal Capabilities
38
- <span className="ml-2 text-sm text-gray-500">({universalCapabilities.length})</span>
39
- </h3>
40
- <p className="text-xs text-gray-500 mb-3">
41
- Available to all Prompt Objects automatically.
42
- </p>
43
-
44
- <div className="space-y-1">
45
- {universalCapabilities.map((cap) => (
46
- <CapabilityItem key={cap.name} capability={cap} />
47
- ))}
48
- </div>
49
- </div>
50
- </div>
51
- )
52
- }
53
-
54
- interface CapabilityItemProps {
55
- capability: CapabilityInfo
56
- accent?: boolean // Use accent color for name (for declared caps)
57
- }
58
-
59
- function CapabilityItem({ capability, accent }: CapabilityItemProps) {
60
- const [expanded, setExpanded] = useState(false)
61
-
62
- return (
63
- <div className="bg-po-bg border border-po-border rounded-lg overflow-hidden">
64
- <button
65
- onClick={() => setExpanded(!expanded)}
66
- className="w-full px-3 py-2 flex items-center justify-between hover:bg-po-surface transition-colors"
67
- >
68
- <span className={`font-mono text-sm ${accent ? 'text-po-accent' : 'text-gray-300'}`}>
69
- {capability.name}
70
- </span>
71
- <span className="text-gray-500 text-xs">{expanded ? '▼' : '▶'}</span>
72
- </button>
73
- {expanded && (
74
- <div className="px-3 py-2 border-t border-po-border bg-po-surface space-y-3">
75
- <p className="text-xs text-gray-400">{capability.description}</p>
76
-
77
- {capability.parameters && (
78
- <ParametersDisplay parameters={capability.parameters} />
79
- )}
80
- </div>
81
- )}
82
- </div>
83
- )
84
- }
85
-
86
- interface ParametersDisplayProps {
87
- parameters: Record<string, unknown>
88
- }
89
-
90
- function ParametersDisplay({ parameters }: ParametersDisplayProps) {
91
- const properties = (parameters.properties as Record<string, unknown>) || {}
92
- const required = (parameters.required as string[]) || []
93
-
94
- const propertyNames = Object.keys(properties)
95
-
96
- if (propertyNames.length === 0) {
97
- return null
98
- }
99
-
100
- return (
101
- <div>
102
- <div className="text-xs text-gray-500 mb-2 font-medium">Parameters</div>
103
- <div className="space-y-2">
104
- {propertyNames.map((propName) => {
105
- const prop = properties[propName] as Record<string, unknown>
106
- const isRequired = required.includes(propName)
107
-
108
- const propType = prop.type ? String(prop.type) : null
109
- const propDescription = prop.description ? String(prop.description) : null
110
- const propEnum = prop.enum as string[] | undefined
111
-
112
- return (
113
- <div key={propName} className="bg-po-bg rounded p-2">
114
- <div className="flex items-center gap-2">
115
- <span className="font-mono text-xs text-po-accent">{propName}</span>
116
- {propType && (
117
- <span className="text-xs text-gray-600">({propType})</span>
118
- )}
119
- {isRequired && (
120
- <span className="text-xs text-red-400">required</span>
121
- )}
122
- </div>
123
- {propDescription && (
124
- <p className="text-xs text-gray-500 mt-1">{propDescription}</p>
125
- )}
126
- {propEnum && propEnum.length > 0 && (
127
- <div className="mt-1 flex flex-wrap gap-1">
128
- {propEnum.map((val) => (
129
- <span key={val} className="text-xs bg-po-surface px-1.5 py-0.5 rounded text-gray-400">
130
- {val}
131
- </span>
132
- ))}
133
- </div>
134
- )}
135
- </div>
136
- )
137
- })}
138
- </div>
139
- </div>
140
- )
141
- }
@@ -1,288 +0,0 @@
1
- import { useState, useRef, useEffect } from 'react'
2
- import { useStore } from '../store'
3
- import { MarkdownMessage } from './MarkdownMessage'
4
- import type { PromptObject, Message, ToolCall } from '../types'
5
-
6
- interface ChatPanelProps {
7
- po: PromptObject
8
- sendMessage: (target: string, content: string, newThread?: boolean) => void
9
- }
10
-
11
- export function ChatPanel({ po, sendMessage }: ChatPanelProps) {
12
- const [input, setInput] = useState('')
13
- const [continueThread, setContinueThread] = useState(false)
14
- const messagesEndRef = useRef<HTMLDivElement>(null)
15
- const { streamingContent } = useStore()
16
-
17
- const messages = po.current_session?.messages || []
18
- const streaming = streamingContent[po.name]
19
- const hasMessages = messages.length > 0
20
-
21
- useEffect(() => {
22
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
23
- }, [messages, streaming])
24
-
25
- const handleSubmit = (e: React.FormEvent) => {
26
- e.preventDefault()
27
- if (!input.trim()) return
28
-
29
- const content = input.trim()
30
-
31
- // Determine if we should create a new thread
32
- const shouldCreateNewThread = !continueThread && hasMessages
33
-
34
- // Send message to server - it will handle thread creation + message in one operation
35
- // Server sends immediate session_updated with user message for instant feedback
36
- sendMessage(po.name, content, shouldCreateNewThread)
37
- setInput('')
38
- }
39
-
40
- return (
41
- <div className="h-full flex flex-col">
42
- {/* Messages */}
43
- <div className="flex-1 overflow-auto p-4 space-y-4">
44
- {messages.length === 0 && !streaming && (
45
- <div className="h-full flex items-center justify-center text-gray-500">
46
- <div className="text-center">
47
- <div className="text-2xl mb-2">💬</div>
48
- <div>Start a conversation with {po.name}</div>
49
- </div>
50
- </div>
51
- )}
52
-
53
- {messages.map((message, index) => (
54
- <MessageBubble key={index} message={message} />
55
- ))}
56
-
57
- {/* Streaming content */}
58
- {streaming && (
59
- <div className="flex gap-3">
60
- <div className="w-8 h-8 rounded-full bg-po-accent flex items-center justify-center text-white text-sm font-medium flex-shrink-0">
61
- AI
62
- </div>
63
- <div className="flex-1 bg-po-surface rounded-lg p-3 text-gray-200">
64
- <MarkdownMessage content={streaming} />
65
- <span className="inline-block w-2 h-4 bg-po-accent animate-pulse ml-1" />
66
- </div>
67
- </div>
68
- )}
69
-
70
- <div ref={messagesEndRef} />
71
- </div>
72
-
73
- {/* Input */}
74
- <form onSubmit={handleSubmit} className="border-t border-po-border p-4">
75
- <div className="flex gap-3">
76
- <input
77
- type="text"
78
- value={input}
79
- 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'}
83
- />
84
- <button
85
- type="submit"
86
- disabled={!input.trim() || po.status !== 'idle'}
87
- 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
- >
89
- Send
90
- </button>
91
- </div>
92
-
93
- {/* Thread toggle - only show when there are existing messages */}
94
- {hasMessages && (
95
- <div className="flex items-center gap-3 mt-2 text-xs">
96
- <button
97
- type="button"
98
- onClick={() => setContinueThread(false)}
99
- className={`px-2 py-1 rounded transition-colors ${
100
- !continueThread
101
- ? 'bg-po-accent/20 text-po-accent border border-po-accent'
102
- : 'text-gray-400 hover:text-white'
103
- }`}
104
- >
105
- New thread
106
- </button>
107
- <button
108
- type="button"
109
- onClick={() => setContinueThread(true)}
110
- className={`px-2 py-1 rounded transition-colors ${
111
- continueThread
112
- ? 'bg-po-accent/20 text-po-accent border border-po-accent'
113
- : 'text-gray-400 hover:text-white'
114
- }`}
115
- >
116
- Continue thread
117
- </button>
118
- <span className="text-gray-500">
119
- {continueThread
120
- ? 'Will add to current conversation'
121
- : 'Will start a fresh conversation'}
122
- </span>
123
- </div>
124
- )}
125
- </form>
126
- </div>
127
- )
128
- }
129
-
130
- function MessageBubble({ message }: { message: Message }) {
131
- const isUser = message.role === 'user'
132
- const isAssistant = message.role === 'assistant'
133
- const isTool = message.role === 'tool'
134
-
135
- if (isTool) {
136
- // Tool messages contain results from tool calls
137
- const results = message.results || []
138
- if (results.length === 0) return null
139
-
140
- return (
141
- <div className="space-y-2 ml-11">
142
- {results.map((result, idx) => (
143
- <ToolResultDisplay key={result.tool_call_id || idx} result={result} />
144
- ))}
145
- </div>
146
- )
147
- }
148
-
149
- return (
150
- <div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
151
- <div
152
- className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium flex-shrink-0 ${
153
- isUser ? 'bg-po-border text-white' : 'bg-po-accent text-white'
154
- }`}
155
- >
156
- {isUser ? 'You' : 'AI'}
157
- </div>
158
- <div
159
- className={`flex-1 max-w-[80%] rounded-lg p-3 ${
160
- isUser
161
- ? 'bg-po-accent text-white'
162
- : 'bg-po-surface text-gray-200'
163
- }`}
164
- >
165
- {message.content && (
166
- isAssistant ? (
167
- <MarkdownMessage content={message.content} />
168
- ) : (
169
- <div className="whitespace-pre-wrap">{message.content}</div>
170
- )
171
- )}
172
-
173
- {/* Tool calls */}
174
- {isAssistant && message.tool_calls && message.tool_calls.length > 0 && (
175
- <div className="mt-2 space-y-2">
176
- {message.tool_calls.map((tc) => (
177
- <ToolCallDisplay key={tc.id} toolCall={tc} />
178
- ))}
179
- </div>
180
- )}
181
- </div>
182
- </div>
183
- )
184
- }
185
-
186
- function ToolCallDisplay({ toolCall }: { toolCall: ToolCall }) {
187
- const [expanded, setExpanded] = useState(false)
188
- const { notifications } = useStore()
189
-
190
- // Special display for ask_human
191
- if (toolCall.name === 'ask_human') {
192
- const question = toolCall.arguments.question as string
193
- const options = toolCall.arguments.options as string[] | undefined
194
-
195
- // Check if there's a pending notification for this (by matching question)
196
- const isPending = notifications.some((n) => n.message === question)
197
-
198
- return (
199
- <div className="bg-po-warning/10 border border-po-warning/30 rounded-lg p-3">
200
- <div className="flex items-center gap-2 mb-2">
201
- <span className="text-po-warning text-sm font-medium">
202
- Waiting for human input
203
- </span>
204
- {isPending ? (
205
- <span className="text-xs bg-po-warning text-black px-2 py-0.5 rounded animate-pulse">
206
- PENDING
207
- </span>
208
- ) : (
209
- <span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded">
210
- RESOLVED
211
- </span>
212
- )}
213
- </div>
214
- <p className="text-gray-200 text-sm mb-2">{question}</p>
215
- {options && options.length > 0 && (
216
- <div className="flex flex-wrap gap-2">
217
- {options.map((opt, i) => (
218
- <span
219
- key={i}
220
- className="text-xs bg-po-bg px-2 py-1 rounded text-gray-400"
221
- >
222
- {opt}
223
- </span>
224
- ))}
225
- </div>
226
- )}
227
- </div>
228
- )
229
- }
230
-
231
- // Default display for other tool calls - expandable
232
- return (
233
- <div className="text-xs bg-po-bg/50 rounded overflow-hidden">
234
- <button
235
- onClick={() => setExpanded(!expanded)}
236
- className="w-full px-2 py-1 text-left font-mono flex items-center gap-1 hover:bg-po-bg/70 transition-colors"
237
- >
238
- <span className="text-gray-500">{expanded ? '▼' : '▶'}</span>
239
- <span className="text-po-accent">{toolCall.name}</span>
240
- <span className="text-gray-500">
241
- ({Object.keys(toolCall.arguments).length} args)
242
- </span>
243
- </button>
244
- {expanded && (
245
- <div className="px-2 pb-2 border-t border-po-border/50">
246
- <pre className="text-gray-400 whitespace-pre-wrap break-all mt-1 text-[10px] leading-relaxed">
247
- {JSON.stringify(toolCall.arguments, null, 2)}
248
- </pre>
249
- </div>
250
- )}
251
- </div>
252
- )
253
- }
254
-
255
- function ToolResultDisplay({ result }: { result: { tool_call_id: string; name?: string; content: string } }) {
256
- const [expanded, setExpanded] = useState(false)
257
- const content = result.content || ''
258
- const truncatedContent = content.slice(0, 200)
259
- const isTruncated = content.length > 200
260
-
261
- return (
262
- <div className="text-xs bg-po-surface/50 border border-po-border/30 rounded overflow-hidden">
263
- <button
264
- onClick={() => setExpanded(!expanded)}
265
- className="w-full px-2 py-1 text-left font-mono flex items-center gap-1 hover:bg-po-surface transition-colors"
266
- >
267
- <span className="text-gray-500">{expanded ? '▼' : '▶'}</span>
268
- <span className="text-gray-400">↳ Result</span>
269
- {result.name && <span className="text-po-accent/70">from {result.name}</span>}
270
- </button>
271
- {expanded && (
272
- <div className="px-2 pb-2 border-t border-po-border/30">
273
- <pre className="text-gray-400 whitespace-pre-wrap break-all mt-1 text-[10px] leading-relaxed">
274
- {content}
275
- </pre>
276
- </div>
277
- )}
278
- {!expanded && (
279
- <div className="px-2 pb-1 text-gray-500">
280
- <span className="whitespace-pre-wrap break-all">
281
- {truncatedContent}
282
- {isTruncated && '...'}
283
- </span>
284
- </div>
285
- )}
286
- </div>
287
- )
288
- }
@@ -1,83 +0,0 @@
1
- import { usePromptObjects, useStore, usePONotifications } from '../store'
2
- import { POCard } from './POCard'
3
- import type { PromptObject } from '../types'
4
-
5
- interface DashboardProps {
6
- compact?: boolean
7
- }
8
-
9
- export function Dashboard({ compact = false }: DashboardProps) {
10
- const promptObjects = usePromptObjects()
11
-
12
- if (promptObjects.length === 0) {
13
- return (
14
- <div className="h-full flex items-center justify-center text-gray-500">
15
- <div className="text-center">
16
- <div className="text-4xl mb-4">🔮</div>
17
- <div className="text-lg">No Prompt Objects loaded</div>
18
- <div className="text-sm mt-2">
19
- Waiting for environment to connect...
20
- </div>
21
- </div>
22
- </div>
23
- )
24
- }
25
-
26
- // Compact mode: simple list for sidebar
27
- if (compact) {
28
- return (
29
- <div className="p-2 space-y-1">
30
- {promptObjects.map((po) => (
31
- <CompactPOItem key={po.name} po={po} />
32
- ))}
33
- </div>
34
- )
35
- }
36
-
37
- // Full dashboard view
38
- return (
39
- <div className="h-full overflow-auto p-6">
40
- <h1 className="text-2xl font-semibold text-white mb-6">
41
- Prompt Objects
42
- </h1>
43
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
44
- {promptObjects.map((po) => (
45
- <POCard key={po.name} po={po} />
46
- ))}
47
- </div>
48
- </div>
49
- )
50
- }
51
-
52
- function CompactPOItem({ po }: { po: PromptObject }) {
53
- const { selectPO, selectedPO } = useStore()
54
- const notifications = usePONotifications(po.name)
55
- const isSelected = selectedPO === po.name
56
-
57
- const statusColors = {
58
- idle: 'bg-gray-500',
59
- thinking: 'bg-po-accent animate-pulse',
60
- calling_tool: 'bg-po-warning animate-pulse',
61
- }
62
-
63
- return (
64
- <button
65
- onClick={() => selectPO(po.name)}
66
- className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
67
- isSelected
68
- ? 'bg-po-accent/20 border border-po-accent'
69
- : 'hover:bg-po-bg border border-transparent'
70
- }`}
71
- >
72
- <div className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColors[po.status]}`} />
73
- <span className={`flex-1 truncate text-sm ${isSelected ? 'text-white' : 'text-gray-300'}`}>
74
- {po.name}
75
- </span>
76
- {notifications.length > 0 && (
77
- <span className="bg-po-warning text-black text-xs font-bold px-1.5 py-0.5 rounded-full">
78
- {notifications.length}
79
- </span>
80
- )}
81
- </button>
82
- )
83
- }
@@ -1,141 +0,0 @@
1
- import { useState, useEffect, useRef } from 'react'
2
- import { useStore, useNotificationCount } from '../store'
3
- import { ModelSelector } from './ModelSelector'
4
-
5
- interface Props {
6
- switchLLM: (provider: string, model?: string) => void
7
- }
8
-
9
- export function Header({ switchLLM }: Props) {
10
- const { connected, environment, selectedPO, selectPO, toggleBus, busOpen, notifications } =
11
- useStore()
12
- const notificationCount = useNotificationCount()
13
- const [showNotifications, setShowNotifications] = useState(false)
14
- const [animate, setAnimate] = useState(false)
15
- const prevCount = useRef(notificationCount)
16
-
17
- // Animate badge when count increases
18
- useEffect(() => {
19
- if (notificationCount > prevCount.current) {
20
- setAnimate(true)
21
- const timer = setTimeout(() => setAnimate(false), 500)
22
- return () => clearTimeout(timer)
23
- }
24
- prevCount.current = notificationCount
25
- }, [notificationCount])
26
-
27
- return (
28
- <header className="h-14 bg-po-surface border-b border-po-border flex items-center px-4 gap-4">
29
- {/* Logo / Title */}
30
- <button
31
- onClick={() => selectPO(null)}
32
- className="text-lg font-semibold text-white hover:text-po-accent transition-colors"
33
- >
34
- PromptObjects
35
- </button>
36
-
37
- {/* Environment info */}
38
- {environment && (
39
- <div className="text-sm text-gray-400">
40
- <span className="text-gray-500">/</span>
41
- <span className="ml-2">{environment.name}</span>
42
- <span className="ml-3 text-gray-500">
43
- {environment.po_count} POs, {environment.primitive_count} primitives
44
- </span>
45
- </div>
46
- )}
47
-
48
- {/* Breadcrumb for selected PO */}
49
- {selectedPO && (
50
- <div className="text-sm text-gray-400">
51
- <span className="text-gray-500">/</span>
52
- <span className="ml-2 text-po-accent">{selectedPO}</span>
53
- </div>
54
- )}
55
-
56
- <div className="flex-1" />
57
-
58
- {/* Model selector */}
59
- <ModelSelector switchLLM={switchLLM} />
60
-
61
- {/* Connection status */}
62
- <div className="flex items-center gap-2 text-sm">
63
- <div
64
- className={`w-2 h-2 rounded-full ${
65
- connected ? 'bg-green-500' : 'bg-red-500'
66
- }`}
67
- />
68
- <span className="text-gray-400">
69
- {connected ? 'Connected' : 'Disconnected'}
70
- </span>
71
- </div>
72
-
73
- {/* Notification bell with badge */}
74
- <div className="relative">
75
- <button
76
- onClick={() => setShowNotifications(!showNotifications)}
77
- className={`relative p-2 rounded transition-colors ${
78
- notificationCount > 0
79
- ? 'text-po-warning hover:bg-po-warning/20'
80
- : 'text-gray-400 hover:text-white hover:bg-po-border'
81
- }`}
82
- title={notificationCount > 0 ? `${notificationCount} pending requests` : 'No notifications'}
83
- >
84
- {/* Bell icon */}
85
- <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
86
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
87
- </svg>
88
- {/* Badge */}
89
- {notificationCount > 0 && (
90
- <span
91
- className={`absolute -top-1 -right-1 bg-po-warning text-black text-xs font-bold w-5 h-5 flex items-center justify-center rounded-full ${
92
- animate ? 'animate-bounce' : ''
93
- }`}
94
- >
95
- {notificationCount}
96
- </span>
97
- )}
98
- </button>
99
-
100
- {/* Dropdown with notification summaries */}
101
- {showNotifications && notifications.length > 0 && (
102
- <div className="absolute right-0 top-full mt-2 w-80 bg-po-surface border border-po-border rounded-lg shadow-xl z-50">
103
- <div className="p-3 border-b border-po-border">
104
- <h3 className="font-medium text-white">Pending Requests</h3>
105
- </div>
106
- <div className="max-h-64 overflow-auto">
107
- {notifications.map((n) => (
108
- <div key={n.id} className="p-3 border-b border-po-border last:border-0 hover:bg-po-bg">
109
- <div className="flex items-center gap-2 mb-1">
110
- <span className="text-xs bg-po-warning text-black px-1.5 py-0.5 rounded font-medium">
111
- {n.type}
112
- </span>
113
- <span className="text-xs text-po-accent">{n.po_name}</span>
114
- </div>
115
- <p className="text-sm text-gray-300 line-clamp-2">{n.message}</p>
116
- </div>
117
- ))}
118
- </div>
119
- <div className="p-2 border-t border-po-border">
120
- <p className="text-xs text-gray-500 text-center">
121
- Respond in the notification panel below
122
- </p>
123
- </div>
124
- </div>
125
- )}
126
- </div>
127
-
128
- {/* Message Bus toggle */}
129
- <button
130
- onClick={toggleBus}
131
- className={`px-3 py-1.5 text-sm rounded transition-colors ${
132
- busOpen
133
- ? 'bg-po-accent text-white'
134
- : 'bg-po-border text-gray-300 hover:bg-po-accent/50'
135
- }`}
136
- >
137
- Bus
138
- </button>
139
- </header>
140
- )
141
- }