prompt_objects 0.1.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 (117) hide show
  1. checksums.yaml +7 -0
  2. data/CLAUDE.md +108 -0
  3. data/Gemfile +10 -0
  4. data/Gemfile.lock +231 -0
  5. data/IMPLEMENTATION_PLAN.md +1073 -0
  6. data/LICENSE +21 -0
  7. data/README.md +73 -0
  8. data/Rakefile +27 -0
  9. data/design-doc-v2.md +1232 -0
  10. data/exe/prompt_objects +572 -0
  11. data/exe/prompt_objects_mcp +34 -0
  12. data/frontend/.gitignore +3 -0
  13. data/frontend/index.html +13 -0
  14. data/frontend/package-lock.json +4417 -0
  15. data/frontend/package.json +32 -0
  16. data/frontend/postcss.config.js +6 -0
  17. data/frontend/src/App.tsx +95 -0
  18. data/frontend/src/components/CapabilitiesPanel.tsx +44 -0
  19. data/frontend/src/components/ChatPanel.tsx +251 -0
  20. data/frontend/src/components/Dashboard.tsx +83 -0
  21. data/frontend/src/components/Header.tsx +141 -0
  22. data/frontend/src/components/MarkdownMessage.tsx +153 -0
  23. data/frontend/src/components/MessageBus.tsx +55 -0
  24. data/frontend/src/components/ModelSelector.tsx +112 -0
  25. data/frontend/src/components/NotificationPanel.tsx +134 -0
  26. data/frontend/src/components/POCard.tsx +56 -0
  27. data/frontend/src/components/PODetail.tsx +117 -0
  28. data/frontend/src/components/PromptPanel.tsx +51 -0
  29. data/frontend/src/components/SessionsPanel.tsx +174 -0
  30. data/frontend/src/components/ThreadsSidebar.tsx +119 -0
  31. data/frontend/src/components/index.ts +11 -0
  32. data/frontend/src/hooks/useWebSocket.ts +363 -0
  33. data/frontend/src/index.css +37 -0
  34. data/frontend/src/main.tsx +10 -0
  35. data/frontend/src/store/index.ts +246 -0
  36. data/frontend/src/types/index.ts +146 -0
  37. data/frontend/tailwind.config.js +25 -0
  38. data/frontend/tsconfig.json +30 -0
  39. data/frontend/vite.config.ts +29 -0
  40. data/lib/prompt_objects/capability.rb +46 -0
  41. data/lib/prompt_objects/cli.rb +431 -0
  42. data/lib/prompt_objects/connectors/base.rb +73 -0
  43. data/lib/prompt_objects/connectors/mcp.rb +524 -0
  44. data/lib/prompt_objects/environment/exporter.rb +83 -0
  45. data/lib/prompt_objects/environment/git.rb +118 -0
  46. data/lib/prompt_objects/environment/importer.rb +159 -0
  47. data/lib/prompt_objects/environment/manager.rb +401 -0
  48. data/lib/prompt_objects/environment/manifest.rb +218 -0
  49. data/lib/prompt_objects/environment.rb +283 -0
  50. data/lib/prompt_objects/human_queue.rb +144 -0
  51. data/lib/prompt_objects/llm/anthropic_adapter.rb +137 -0
  52. data/lib/prompt_objects/llm/factory.rb +84 -0
  53. data/lib/prompt_objects/llm/gemini_adapter.rb +209 -0
  54. data/lib/prompt_objects/llm/openai_adapter.rb +104 -0
  55. data/lib/prompt_objects/llm/response.rb +61 -0
  56. data/lib/prompt_objects/loader.rb +32 -0
  57. data/lib/prompt_objects/mcp/server.rb +167 -0
  58. data/lib/prompt_objects/mcp/tools/get_conversation.rb +60 -0
  59. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +54 -0
  60. data/lib/prompt_objects/mcp/tools/inspect_po.rb +73 -0
  61. data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +37 -0
  62. data/lib/prompt_objects/mcp/tools/respond_to_request.rb +68 -0
  63. data/lib/prompt_objects/mcp/tools/send_message.rb +71 -0
  64. data/lib/prompt_objects/message_bus.rb +97 -0
  65. data/lib/prompt_objects/primitive.rb +13 -0
  66. data/lib/prompt_objects/primitives/http_get.rb +72 -0
  67. data/lib/prompt_objects/primitives/list_files.rb +95 -0
  68. data/lib/prompt_objects/primitives/read_file.rb +81 -0
  69. data/lib/prompt_objects/primitives/write_file.rb +73 -0
  70. data/lib/prompt_objects/prompt_object.rb +415 -0
  71. data/lib/prompt_objects/registry.rb +88 -0
  72. data/lib/prompt_objects/server/api/routes.rb +297 -0
  73. data/lib/prompt_objects/server/app.rb +174 -0
  74. data/lib/prompt_objects/server/file_watcher.rb +113 -0
  75. data/lib/prompt_objects/server/public/assets/index-2acS2FYZ.js +77 -0
  76. data/lib/prompt_objects/server/public/assets/index-DXU5uRXQ.css +1 -0
  77. data/lib/prompt_objects/server/public/index.html +14 -0
  78. data/lib/prompt_objects/server/websocket_handler.rb +619 -0
  79. data/lib/prompt_objects/server.rb +166 -0
  80. data/lib/prompt_objects/session/store.rb +826 -0
  81. data/lib/prompt_objects/universal/add_capability.rb +74 -0
  82. data/lib/prompt_objects/universal/add_primitive.rb +113 -0
  83. data/lib/prompt_objects/universal/ask_human.rb +109 -0
  84. data/lib/prompt_objects/universal/create_capability.rb +219 -0
  85. data/lib/prompt_objects/universal/create_primitive.rb +170 -0
  86. data/lib/prompt_objects/universal/list_capabilities.rb +55 -0
  87. data/lib/prompt_objects/universal/list_primitives.rb +145 -0
  88. data/lib/prompt_objects/universal/modify_primitive.rb +180 -0
  89. data/lib/prompt_objects/universal/request_primitive.rb +287 -0
  90. data/lib/prompt_objects/universal/think.rb +41 -0
  91. data/lib/prompt_objects/universal/verify_primitive.rb +173 -0
  92. data/lib/prompt_objects.rb +62 -0
  93. data/objects/coordinator.md +48 -0
  94. data/objects/greeter.md +30 -0
  95. data/objects/reader.md +33 -0
  96. data/prompt_objects.gemspec +50 -0
  97. data/templates/basic/.gitignore +2 -0
  98. data/templates/basic/manifest.yml +7 -0
  99. data/templates/basic/objects/basic.md +32 -0
  100. data/templates/developer/.gitignore +5 -0
  101. data/templates/developer/manifest.yml +17 -0
  102. data/templates/developer/objects/code_reviewer.md +33 -0
  103. data/templates/developer/objects/coordinator.md +39 -0
  104. data/templates/developer/objects/debugger.md +35 -0
  105. data/templates/empty/.gitignore +5 -0
  106. data/templates/empty/manifest.yml +14 -0
  107. data/templates/empty/objects/.gitkeep +0 -0
  108. data/templates/empty/objects/assistant.md +41 -0
  109. data/templates/minimal/.gitignore +5 -0
  110. data/templates/minimal/manifest.yml +7 -0
  111. data/templates/minimal/objects/assistant.md +41 -0
  112. data/templates/writer/.gitignore +5 -0
  113. data/templates/writer/manifest.yml +17 -0
  114. data/templates/writer/objects/coordinator.md +33 -0
  115. data/templates/writer/objects/editor.md +33 -0
  116. data/templates/writer/objects/researcher.md +34 -0
  117. metadata +343 -0
@@ -0,0 +1,174 @@
1
+ import { useMemo } from 'react'
2
+ import type { PromptObject, Session, ThreadType } from '../types'
3
+
4
+ interface SessionsPanelProps {
5
+ po: PromptObject
6
+ createSession: (target: string, name?: string) => void
7
+ switchSession: (target: string, sessionId: string) => void
8
+ createThread?: (target: string, name?: string) => void
9
+ }
10
+
11
+ // Build a tree structure from flat sessions list
12
+ interface ThreadNode {
13
+ session: Session
14
+ children: ThreadNode[]
15
+ depth: number
16
+ }
17
+
18
+ function buildThreadTree(sessions: Session[]): ThreadNode[] {
19
+ const sessionMap = new Map<string, Session>()
20
+ const childrenMap = new Map<string, Session[]>()
21
+
22
+ // Index all sessions
23
+ sessions.forEach((s) => {
24
+ sessionMap.set(s.id, s)
25
+ if (s.parent_session_id) {
26
+ const children = childrenMap.get(s.parent_session_id) || []
27
+ children.push(s)
28
+ childrenMap.set(s.parent_session_id, children)
29
+ }
30
+ })
31
+
32
+ // Build tree recursively
33
+ function buildNode(session: Session, depth: number): ThreadNode {
34
+ const children = childrenMap.get(session.id) || []
35
+ return {
36
+ session,
37
+ children: children
38
+ .sort((a, b) => (a.updated_at || '').localeCompare(b.updated_at || ''))
39
+ .map((child) => buildNode(child, depth + 1)),
40
+ depth,
41
+ }
42
+ }
43
+
44
+ // Get root sessions (no parent)
45
+ const roots = sessions
46
+ .filter((s) => !s.parent_session_id)
47
+ .sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''))
48
+
49
+ return roots.map((root) => buildNode(root, 0))
50
+ }
51
+
52
+ // Flatten tree for rendering
53
+ function flattenTree(nodes: ThreadNode[]): ThreadNode[] {
54
+ const result: ThreadNode[] = []
55
+ function traverse(node: ThreadNode) {
56
+ result.push(node)
57
+ node.children.forEach(traverse)
58
+ }
59
+ nodes.forEach(traverse)
60
+ return result
61
+ }
62
+
63
+ // Thread type icons/badges
64
+ function ThreadTypeBadge({ type }: { type: ThreadType }) {
65
+ switch (type) {
66
+ case 'delegation':
67
+ return (
68
+ <span className="text-xs bg-purple-600/30 text-purple-300 px-1.5 py-0.5 rounded">
69
+ ↳ delegation
70
+ </span>
71
+ )
72
+ case 'fork':
73
+ return (
74
+ <span className="text-xs bg-blue-600/30 text-blue-300 px-1.5 py-0.5 rounded">
75
+ ⑂ fork
76
+ </span>
77
+ )
78
+ case 'continuation':
79
+ return (
80
+ <span className="text-xs bg-gray-600/30 text-gray-300 px-1.5 py-0.5 rounded">
81
+ → continued
82
+ </span>
83
+ )
84
+ default:
85
+ return null
86
+ }
87
+ }
88
+
89
+ export function SessionsPanel({
90
+ po,
91
+ createSession,
92
+ switchSession,
93
+ createThread,
94
+ }: SessionsPanelProps) {
95
+ const sessions = po.sessions || []
96
+ const currentSessionId = po.current_session?.id
97
+
98
+ // Build and flatten thread tree
99
+ const flatNodes = useMemo(() => {
100
+ const tree = buildThreadTree(sessions)
101
+ return flattenTree(tree)
102
+ }, [sessions])
103
+
104
+ const handleNewThread = () => {
105
+ if (createThread) {
106
+ createThread(po.name)
107
+ } else {
108
+ // Fallback to createSession
109
+ const name = prompt('Thread name (optional):')
110
+ createSession(po.name, name || undefined)
111
+ }
112
+ }
113
+
114
+ return (
115
+ <div className="h-full overflow-auto p-4">
116
+ <div className="flex items-center justify-between mb-4">
117
+ <h3 className="text-lg font-medium text-white">Threads</h3>
118
+ <button
119
+ onClick={handleNewThread}
120
+ className="px-3 py-1.5 bg-po-accent text-white text-sm rounded hover:bg-po-accent/80 transition-colors"
121
+ >
122
+ + New Thread
123
+ </button>
124
+ </div>
125
+
126
+ {flatNodes.length === 0 ? (
127
+ <div className="text-gray-500 text-center py-8">
128
+ No threads yet. Start a conversation to create one.
129
+ </div>
130
+ ) : (
131
+ <div className="space-y-1">
132
+ {flatNodes.map(({ session, depth }) => (
133
+ <button
134
+ key={session.id}
135
+ onClick={() => switchSession(po.name, session.id)}
136
+ className={`w-full text-left p-3 rounded-lg border transition-colors ${
137
+ session.id === currentSessionId
138
+ ? 'bg-po-accent/20 border-po-accent'
139
+ : 'bg-po-surface border-po-border hover:border-po-accent/50'
140
+ }`}
141
+ style={{ marginLeft: `${depth * 16}px`, width: `calc(100% - ${depth * 16}px)` }}
142
+ >
143
+ <div className="flex items-center gap-2 mb-1">
144
+ {depth > 0 && (
145
+ <span className="text-gray-500 text-xs">↳</span>
146
+ )}
147
+ <span className="font-medium text-white truncate flex-1">
148
+ {session.name || `Thread ${session.id.slice(0, 8)}`}
149
+ </span>
150
+ <ThreadTypeBadge type={session.thread_type} />
151
+ {session.id === currentSessionId && (
152
+ <span className="text-xs bg-po-accent text-white px-2 py-0.5 rounded flex-shrink-0">
153
+ Active
154
+ </span>
155
+ )}
156
+ </div>
157
+ <div className="text-sm text-gray-400 flex items-center gap-2">
158
+ <span>{session.message_count} messages</span>
159
+ {session.parent_po && (
160
+ <span className="text-purple-400">from {session.parent_po}</span>
161
+ )}
162
+ {session.updated_at && (
163
+ <span className="ml-auto">
164
+ {new Date(session.updated_at).toLocaleDateString()}
165
+ </span>
166
+ )}
167
+ </div>
168
+ </button>
169
+ ))}
170
+ </div>
171
+ )}
172
+ </div>
173
+ )
174
+ }
@@ -0,0 +1,119 @@
1
+ import { useMemo } from 'react'
2
+ import type { PromptObject, Session, ThreadType } from '../types'
3
+
4
+ interface ThreadsSidebarProps {
5
+ po: PromptObject
6
+ switchSession: (target: string, sessionId: string) => void
7
+ createThread: (target: string) => void
8
+ }
9
+
10
+ // Build a flat list with depth info
11
+ interface ThreadItem {
12
+ session: Session
13
+ depth: number
14
+ }
15
+
16
+ function buildThreadList(sessions: Session[]): ThreadItem[] {
17
+ const childrenMap = new Map<string, Session[]>()
18
+
19
+ // Index children
20
+ sessions.forEach((s) => {
21
+ if (s.parent_session_id) {
22
+ const children = childrenMap.get(s.parent_session_id) || []
23
+ children.push(s)
24
+ childrenMap.set(s.parent_session_id, children)
25
+ }
26
+ })
27
+
28
+ // Build flat list with depth
29
+ const result: ThreadItem[] = []
30
+
31
+ function traverse(session: Session, depth: number) {
32
+ result.push({ session, depth })
33
+ const children = childrenMap.get(session.id) || []
34
+ children
35
+ .sort((a, b) => (a.updated_at || '').localeCompare(b.updated_at || ''))
36
+ .forEach((child) => traverse(child, depth + 1))
37
+ }
38
+
39
+ // Get roots and traverse
40
+ const roots = sessions
41
+ .filter((s) => !s.parent_session_id)
42
+ .sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''))
43
+
44
+ roots.forEach((root) => traverse(root, 0))
45
+
46
+ return result
47
+ }
48
+
49
+ function ThreadTypeIcon({ type }: { type: ThreadType }) {
50
+ switch (type) {
51
+ case 'delegation':
52
+ return <span className="text-purple-400" title="Delegation">↳</span>
53
+ case 'fork':
54
+ return <span className="text-blue-400" title="Fork">⑂</span>
55
+ case 'continuation':
56
+ return <span className="text-gray-400" title="Continuation">→</span>
57
+ default:
58
+ return null
59
+ }
60
+ }
61
+
62
+ export function ThreadsSidebar({ po, switchSession, createThread }: ThreadsSidebarProps) {
63
+ const sessions = po.sessions || []
64
+ const currentSessionId = po.current_session?.id
65
+
66
+ const threadList = useMemo(() => buildThreadList(sessions), [sessions])
67
+
68
+ return (
69
+ <div className="h-full flex flex-col">
70
+ <div className="p-3 border-b border-po-border flex items-center justify-between">
71
+ <h2 className="text-sm font-medium text-gray-400">Threads</h2>
72
+ <button
73
+ onClick={() => createThread(po.name)}
74
+ className="text-xs text-gray-500 hover:text-po-accent transition-colors"
75
+ title="New thread"
76
+ >
77
+ + New
78
+ </button>
79
+ </div>
80
+
81
+ <div className="flex-1 overflow-auto p-2 space-y-1">
82
+ {threadList.length === 0 ? (
83
+ <div className="text-xs text-gray-500 text-center py-4">
84
+ No threads yet
85
+ </div>
86
+ ) : (
87
+ threadList.map(({ session, depth }) => (
88
+ <button
89
+ key={session.id}
90
+ onClick={() => switchSession(po.name, session.id)}
91
+ className={`w-full text-left p-2 rounded text-xs transition-colors ${
92
+ session.id === currentSessionId
93
+ ? 'bg-po-accent/20 border border-po-accent'
94
+ : 'hover:bg-po-surface border border-transparent'
95
+ }`}
96
+ style={{ paddingLeft: `${8 + depth * 12}px` }}
97
+ >
98
+ <div className="flex items-center gap-1">
99
+ {depth > 0 && <ThreadTypeIcon type={session.thread_type} />}
100
+ <span className={`truncate flex-1 ${session.id === currentSessionId ? 'text-white' : 'text-gray-300'}`}>
101
+ {session.name || `Thread ${session.id.slice(0, 6)}`}
102
+ </span>
103
+ {session.id === currentSessionId && (
104
+ <span className="w-1.5 h-1.5 rounded-full bg-po-accent flex-shrink-0" />
105
+ )}
106
+ </div>
107
+ <div className="text-gray-500 text-[10px] mt-0.5">
108
+ {session.message_count} msgs
109
+ {session.parent_po && (
110
+ <span className="text-purple-400 ml-1">from {session.parent_po}</span>
111
+ )}
112
+ </div>
113
+ </button>
114
+ ))
115
+ )}
116
+ </div>
117
+ </div>
118
+ )
119
+ }
@@ -0,0 +1,11 @@
1
+ export { Header } from './Header'
2
+ export { Dashboard } from './Dashboard'
3
+ export { POCard } from './POCard'
4
+ export { PODetail } from './PODetail'
5
+ export { ChatPanel } from './ChatPanel'
6
+ export { SessionsPanel } from './SessionsPanel'
7
+ export { CapabilitiesPanel } from './CapabilitiesPanel'
8
+ export { MessageBus } from './MessageBus'
9
+ export { NotificationPanel } from './NotificationPanel'
10
+ export { MarkdownMessage } from './MarkdownMessage'
11
+ export { PromptPanel } from './PromptPanel'
@@ -0,0 +1,363 @@
1
+ import { useEffect, useRef, useCallback } from 'react'
2
+ import { useStore } from '../store'
3
+ import type {
4
+ WSMessage,
5
+ PromptObject,
6
+ BusMessage,
7
+ Notification,
8
+ Environment,
9
+ Message,
10
+ LLMConfig,
11
+ SendMessagePayload,
12
+ RespondToNotificationPayload,
13
+ CreateSessionPayload,
14
+ SwitchSessionPayload,
15
+ CreateThreadPayload,
16
+ ThreadType,
17
+ } from '../types'
18
+
19
+ export function useWebSocket() {
20
+ const ws = useRef<WebSocket | null>(null)
21
+ const reconnectTimeout = useRef<number | null>(null)
22
+
23
+ const {
24
+ setConnected,
25
+ setEnvironment,
26
+ setPromptObject,
27
+ removePromptObject,
28
+ updateSessionMessages,
29
+ switchPOSession,
30
+ addBusMessage,
31
+ addNotification,
32
+ removeNotification,
33
+ appendStreamChunk,
34
+ clearStream,
35
+ setPendingResponse,
36
+ clearPendingResponse,
37
+ setLLMConfig,
38
+ updateCurrentLLM,
39
+ } = useStore()
40
+
41
+ const connect = useCallback(() => {
42
+ // Determine WebSocket URL
43
+ // In dev mode (Vite on 5173), connect directly to Ruby server on 3000
44
+ // In production, connect to same host
45
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
46
+ const isDev = window.location.port === '5173'
47
+ const host = isDev ? 'localhost:3000' : window.location.host
48
+ const wsUrl = `${protocol}//${host}`
49
+
50
+ console.log('Connecting to WebSocket:', wsUrl)
51
+ ws.current = new WebSocket(wsUrl)
52
+
53
+ ws.current.onopen = () => {
54
+ console.log('WebSocket connected')
55
+ setConnected(true)
56
+
57
+ // Clear any pending reconnect
58
+ if (reconnectTimeout.current) {
59
+ clearTimeout(reconnectTimeout.current)
60
+ reconnectTimeout.current = null
61
+ }
62
+ }
63
+
64
+ ws.current.onclose = () => {
65
+ console.log('WebSocket disconnected')
66
+ setConnected(false)
67
+
68
+ // Attempt to reconnect after 2 seconds
69
+ reconnectTimeout.current = window.setTimeout(() => {
70
+ console.log('Attempting to reconnect...')
71
+ connect()
72
+ }, 2000)
73
+ }
74
+
75
+ ws.current.onerror = (error) => {
76
+ console.error('WebSocket error:', error)
77
+ }
78
+
79
+ ws.current.onmessage = (event) => {
80
+ try {
81
+ const message: WSMessage = JSON.parse(event.data)
82
+ handleMessage(message)
83
+ } catch (error) {
84
+ console.error('Failed to parse WebSocket message:', error)
85
+ }
86
+ }
87
+ }, [setConnected])
88
+
89
+ const handleMessage = useCallback(
90
+ (message: WSMessage) => {
91
+ switch (message.type) {
92
+ case 'environment':
93
+ setEnvironment(message.payload as Environment)
94
+ break
95
+
96
+ case 'po_state': {
97
+ const { name, state } = message.payload as {
98
+ name: string
99
+ state: Partial<PromptObject>
100
+ }
101
+ setPromptObject(name, state)
102
+ break
103
+ }
104
+
105
+ case 'po_response': {
106
+ const { target, content } = message.payload as {
107
+ target: string
108
+ content: string
109
+ }
110
+ setPendingResponse(target, content)
111
+ // Clear after a short delay to allow UI to update
112
+ setTimeout(() => clearPendingResponse(target), 100)
113
+ break
114
+ }
115
+
116
+ case 'stream': {
117
+ const { target, chunk } = message.payload as {
118
+ target: string
119
+ chunk: string
120
+ }
121
+ appendStreamChunk(target, chunk)
122
+ break
123
+ }
124
+
125
+ case 'stream_end': {
126
+ const { target } = message.payload as { target: string }
127
+ clearStream(target)
128
+ break
129
+ }
130
+
131
+ case 'bus_message':
132
+ addBusMessage(message.payload as BusMessage)
133
+ break
134
+
135
+ case 'notification':
136
+ addNotification(message.payload as Notification)
137
+ break
138
+
139
+ case 'notification_resolved': {
140
+ const { id } = message.payload as { id: string }
141
+ removeNotification(id)
142
+ break
143
+ }
144
+
145
+ // Live file updates
146
+ case 'po_added': {
147
+ const { name, state } = message.payload as {
148
+ name: string
149
+ state: Partial<PromptObject>
150
+ }
151
+ console.log('PO added:', name)
152
+ setPromptObject(name, state)
153
+ break
154
+ }
155
+
156
+ case 'po_modified': {
157
+ const { name, state } = message.payload as {
158
+ name: string
159
+ state: Partial<PromptObject>
160
+ }
161
+ console.log('PO modified:', name)
162
+ setPromptObject(name, state)
163
+ break
164
+ }
165
+
166
+ case 'po_removed': {
167
+ const { name } = message.payload as { name: string }
168
+ console.log('PO removed:', name)
169
+ removePromptObject(name)
170
+ break
171
+ }
172
+
173
+ case 'session_updated': {
174
+ const { target, session_id, messages } = message.payload as {
175
+ target: string
176
+ session_id: string
177
+ messages: Message[]
178
+ }
179
+ updateSessionMessages(target, session_id, messages)
180
+ break
181
+ }
182
+
183
+ case 'thread_created': {
184
+ const { target, thread_id, thread_type } = message.payload as {
185
+ target: string
186
+ thread_id: string
187
+ name: string | null
188
+ thread_type: ThreadType
189
+ }
190
+ console.log('Thread created:', target, thread_id, thread_type)
191
+ // IMMEDIATELY switch to the new thread so user sees their message
192
+ // This ensures session_updated messages for this thread are displayed
193
+ switchPOSession(target, thread_id)
194
+ break
195
+ }
196
+
197
+ case 'thread_tree': {
198
+ // Thread tree response - could be used for navigation
199
+ console.log('Thread tree received:', message.payload)
200
+ break
201
+ }
202
+
203
+ case 'llm_config':
204
+ setLLMConfig(message.payload as LLMConfig)
205
+ break
206
+
207
+ case 'llm_switched': {
208
+ const { provider, model } = message.payload as {
209
+ provider: string
210
+ model: string
211
+ }
212
+ updateCurrentLLM(provider, model)
213
+ break
214
+ }
215
+
216
+ case 'error': {
217
+ const { message: errorMsg } = message.payload as { message: string }
218
+ console.error('Server error:', errorMsg)
219
+ break
220
+ }
221
+
222
+ case 'pong':
223
+ // Heartbeat response, ignore
224
+ break
225
+
226
+ default:
227
+ console.log('Unknown message type:', message.type)
228
+ }
229
+ },
230
+ [
231
+ setEnvironment,
232
+ setPromptObject,
233
+ removePromptObject,
234
+ updateSessionMessages,
235
+ switchPOSession,
236
+ setPendingResponse,
237
+ clearPendingResponse,
238
+ appendStreamChunk,
239
+ clearStream,
240
+ addBusMessage,
241
+ addNotification,
242
+ removeNotification,
243
+ setLLMConfig,
244
+ updateCurrentLLM,
245
+ ]
246
+ )
247
+
248
+ // Connect on mount
249
+ useEffect(() => {
250
+ connect()
251
+
252
+ return () => {
253
+ if (reconnectTimeout.current) {
254
+ clearTimeout(reconnectTimeout.current)
255
+ }
256
+ ws.current?.close()
257
+ }
258
+ }, [connect])
259
+
260
+ // Send message to a PO
261
+ const sendMessage = useCallback((target: string, content: string, newThread?: boolean) => {
262
+ if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
263
+ console.error('WebSocket not connected')
264
+ return
265
+ }
266
+
267
+ const payload: SendMessagePayload = { target, content, new_thread: newThread }
268
+ ws.current.send(
269
+ JSON.stringify({
270
+ type: 'send_message',
271
+ payload,
272
+ })
273
+ )
274
+ }, [])
275
+
276
+ // Respond to a notification
277
+ const respondToNotification = useCallback((id: string, response: string) => {
278
+ if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
279
+ console.error('WebSocket not connected')
280
+ return
281
+ }
282
+
283
+ const payload: RespondToNotificationPayload = { id, response }
284
+ ws.current.send(
285
+ JSON.stringify({
286
+ type: 'respond_to_notification',
287
+ payload,
288
+ })
289
+ )
290
+ }, [])
291
+
292
+ // Create a new session
293
+ const createSession = useCallback((target: string, name?: string) => {
294
+ if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
295
+ console.error('WebSocket not connected')
296
+ return
297
+ }
298
+
299
+ const payload: CreateSessionPayload = { target, name }
300
+ ws.current.send(
301
+ JSON.stringify({
302
+ type: 'create_session',
303
+ payload,
304
+ })
305
+ )
306
+ }, [])
307
+
308
+ // Switch to a different session
309
+ const switchSession = useCallback((target: string, sessionId: string) => {
310
+ if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
311
+ console.error('WebSocket not connected')
312
+ return
313
+ }
314
+
315
+ const payload: SwitchSessionPayload = { target, session_id: sessionId }
316
+ ws.current.send(
317
+ JSON.stringify({
318
+ type: 'switch_session',
319
+ payload,
320
+ })
321
+ )
322
+ }, [])
323
+
324
+ // Switch LLM provider/model
325
+ const switchLLM = useCallback((provider: string, model?: string) => {
326
+ if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
327
+ console.error('WebSocket not connected')
328
+ return
329
+ }
330
+
331
+ ws.current.send(
332
+ JSON.stringify({
333
+ type: 'switch_llm',
334
+ payload: { provider, model },
335
+ })
336
+ )
337
+ }, [])
338
+
339
+ // Create a new thread (defaults to root thread)
340
+ const createThread = useCallback((target: string, name?: string, threadType?: ThreadType) => {
341
+ if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
342
+ console.error('WebSocket not connected')
343
+ return
344
+ }
345
+
346
+ const payload: CreateThreadPayload = { target, name, thread_type: threadType }
347
+ ws.current.send(
348
+ JSON.stringify({
349
+ type: 'create_thread',
350
+ payload,
351
+ })
352
+ )
353
+ }, [])
354
+
355
+ return {
356
+ sendMessage,
357
+ respondToNotification,
358
+ createSession,
359
+ switchSession,
360
+ switchLLM,
361
+ createThread,
362
+ }
363
+ }
@@ -0,0 +1,37 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* Base styles */
6
+ html {
7
+ color-scheme: dark;
8
+ }
9
+
10
+ body {
11
+ @apply antialiased;
12
+ }
13
+
14
+ /* Custom scrollbar */
15
+ ::-webkit-scrollbar {
16
+ width: 8px;
17
+ height: 8px;
18
+ }
19
+
20
+ ::-webkit-scrollbar-track {
21
+ @apply bg-po-bg;
22
+ }
23
+
24
+ ::-webkit-scrollbar-thumb {
25
+ @apply bg-po-border rounded-full;
26
+ }
27
+
28
+ ::-webkit-scrollbar-thumb:hover {
29
+ @apply bg-po-accent;
30
+ }
31
+
32
+ /* Custom utilities */
33
+ @layer utilities {
34
+ .scrollbar-thin {
35
+ scrollbar-width: thin;
36
+ }
37
+ }