@swarmclawai/swarmclaw 0.3.1 → 0.4.5

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 (203) hide show
  1. package/README.md +33 -13
  2. package/bin/server-cmd.js +14 -7
  3. package/bin/swarmclaw.js +3 -1
  4. package/bin/update-cmd.js +120 -0
  5. package/next.config.ts +10 -0
  6. package/package.json +4 -1
  7. package/src/app/api/agents/[id]/route.ts +20 -18
  8. package/src/app/api/agents/[id]/thread/route.ts +4 -3
  9. package/src/app/api/agents/route.ts +8 -3
  10. package/src/app/api/auth/route.ts +3 -1
  11. package/src/app/api/claude-skills/route.ts +3 -1
  12. package/src/app/api/clawhub/install/route.ts +2 -2
  13. package/src/app/api/connectors/[id]/route.ts +14 -3
  14. package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
  15. package/src/app/api/connectors/route.ts +12 -4
  16. package/src/app/api/credentials/[id]/route.ts +2 -1
  17. package/src/app/api/credentials/route.ts +5 -3
  18. package/src/app/api/daemon/route.ts +6 -1
  19. package/src/app/api/documents/route.ts +2 -2
  20. package/src/app/api/files/serve/route.ts +8 -0
  21. package/src/app/api/ip/route.ts +3 -1
  22. package/src/app/api/knowledge/[id]/route.ts +5 -4
  23. package/src/app/api/knowledge/upload/route.ts +2 -2
  24. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  25. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  26. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  27. package/src/app/api/mcp-servers/route.ts +5 -3
  28. package/src/app/api/memory/[id]/route.ts +9 -8
  29. package/src/app/api/memory/route.ts +2 -2
  30. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  31. package/src/app/api/openclaw/directory/route.ts +26 -0
  32. package/src/app/api/openclaw/discover/route.ts +61 -0
  33. package/src/app/api/openclaw/sync/route.ts +30 -0
  34. package/src/app/api/orchestrator/graph/route.ts +25 -0
  35. package/src/app/api/orchestrator/run/route.ts +2 -2
  36. package/src/app/api/plugins/marketplace/route.ts +3 -1
  37. package/src/app/api/plugins/route.ts +3 -1
  38. package/src/app/api/projects/[id]/route.ts +55 -0
  39. package/src/app/api/projects/route.ts +27 -0
  40. package/src/app/api/providers/[id]/models/route.ts +2 -1
  41. package/src/app/api/providers/[id]/route.ts +13 -12
  42. package/src/app/api/providers/configs/route.ts +3 -1
  43. package/src/app/api/providers/route.ts +7 -3
  44. package/src/app/api/schedules/[id]/route.ts +16 -15
  45. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  46. package/src/app/api/schedules/route.ts +8 -3
  47. package/src/app/api/secrets/[id]/route.ts +16 -17
  48. package/src/app/api/secrets/route.ts +5 -3
  49. package/src/app/api/sessions/[id]/chat/route.ts +5 -2
  50. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  51. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  52. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  53. package/src/app/api/sessions/[id]/messages/route.ts +2 -1
  54. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  55. package/src/app/api/sessions/[id]/route.ts +2 -1
  56. package/src/app/api/sessions/route.ts +11 -4
  57. package/src/app/api/settings/route.ts +3 -1
  58. package/src/app/api/setup/doctor/route.ts +1 -0
  59. package/src/app/api/setup/openclaw-device/route.ts +3 -1
  60. package/src/app/api/skills/[id]/route.ts +23 -21
  61. package/src/app/api/skills/import/route.ts +2 -2
  62. package/src/app/api/skills/route.ts +5 -3
  63. package/src/app/api/tasks/[id]/approve/route.ts +74 -0
  64. package/src/app/api/tasks/[id]/route.ts +9 -5
  65. package/src/app/api/tasks/route.ts +5 -2
  66. package/src/app/api/tts/stream/route.ts +48 -0
  67. package/src/app/api/upload/route.ts +2 -2
  68. package/src/app/api/uploads/[filename]/route.ts +4 -1
  69. package/src/app/api/usage/route.ts +3 -1
  70. package/src/app/api/version/route.ts +3 -1
  71. package/src/app/api/webhooks/[id]/route.ts +31 -32
  72. package/src/app/api/webhooks/route.ts +5 -3
  73. package/src/app/icon.svg +58 -0
  74. package/src/app/page.tsx +11 -26
  75. package/src/cli/index.js +28 -9
  76. package/src/cli/index.ts +45 -2
  77. package/src/cli/spec.js +2 -8
  78. package/src/components/agents/agent-card.tsx +1 -1
  79. package/src/components/agents/agent-list.tsx +3 -1
  80. package/src/components/agents/agent-sheet.tsx +166 -81
  81. package/src/components/chat/chat-area.tsx +71 -34
  82. package/src/components/chat/chat-header.tsx +141 -29
  83. package/src/components/chat/chat-tool-toggles.tsx +12 -53
  84. package/src/components/chat/message-bubble.tsx +110 -42
  85. package/src/components/chat/tool-call-bubble.tsx +50 -6
  86. package/src/components/chat/tool-request-banner.tsx +1 -9
  87. package/src/components/chat/voice-overlay.tsx +80 -0
  88. package/src/components/connectors/connector-list.tsx +9 -10
  89. package/src/components/connectors/connector-sheet.tsx +55 -36
  90. package/src/components/input/chat-input.tsx +72 -56
  91. package/src/components/knowledge/knowledge-list.tsx +27 -31
  92. package/src/components/layout/app-layout.tsx +133 -90
  93. package/src/components/layout/daemon-indicator.tsx +3 -5
  94. package/src/components/logs/log-list.tsx +5 -9
  95. package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
  96. package/src/components/memory/memory-detail.tsx +1 -1
  97. package/src/components/plugins/plugin-list.tsx +227 -27
  98. package/src/components/projects/project-list.tsx +122 -0
  99. package/src/components/projects/project-sheet.tsx +135 -0
  100. package/src/components/providers/provider-list.tsx +46 -13
  101. package/src/components/providers/provider-sheet.tsx +0 -45
  102. package/src/components/runs/run-list.tsx +6 -15
  103. package/src/components/schedules/schedule-card.tsx +54 -4
  104. package/src/components/schedules/schedule-list.tsx +9 -4
  105. package/src/components/schedules/schedule-sheet.tsx +0 -47
  106. package/src/components/secrets/secrets-list.tsx +20 -2
  107. package/src/components/sessions/new-session-sheet.tsx +14 -15
  108. package/src/components/sessions/session-card.tsx +1 -1
  109. package/src/components/sessions/session-list.tsx +7 -7
  110. package/src/components/shared/connector-platform-icon.tsx +26 -20
  111. package/src/components/shared/model-combobox.tsx +148 -0
  112. package/src/components/shared/settings/section-heartbeat.tsx +8 -40
  113. package/src/components/shared/settings/section-orchestrator.tsx +9 -11
  114. package/src/components/shared/settings/section-web-search.tsx +56 -0
  115. package/src/components/shared/settings/settings-page.tsx +73 -0
  116. package/src/components/skills/skill-list.tsx +262 -35
  117. package/src/components/skills/skill-sheet.tsx +0 -45
  118. package/src/components/tasks/task-board.tsx +3 -6
  119. package/src/components/tasks/task-card.tsx +43 -1
  120. package/src/components/tasks/task-list.tsx +8 -7
  121. package/src/components/tasks/task-sheet.tsx +0 -44
  122. package/src/components/usage/usage-list.tsx +12 -4
  123. package/src/hooks/use-continuous-speech.ts +144 -0
  124. package/src/hooks/use-view-router.ts +52 -0
  125. package/src/hooks/use-voice-conversation.ts +80 -0
  126. package/src/hooks/use-ws.ts +66 -0
  127. package/src/instrumentation.ts +2 -0
  128. package/src/lib/chat.ts +14 -2
  129. package/src/lib/id.ts +6 -0
  130. package/src/lib/projects.ts +13 -0
  131. package/src/lib/provider-sets.ts +5 -0
  132. package/src/lib/providers/anthropic.ts +15 -2
  133. package/src/lib/providers/index.ts +8 -0
  134. package/src/lib/providers/ollama.ts +10 -2
  135. package/src/lib/providers/openai.ts +42 -13
  136. package/src/lib/providers/openclaw.ts +11 -0
  137. package/src/lib/server/api-routes.test.ts +5 -6
  138. package/src/lib/server/build-llm.ts +17 -4
  139. package/src/lib/server/chat-execution.ts +57 -8
  140. package/src/lib/server/collection-helpers.ts +54 -0
  141. package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
  142. package/src/lib/server/connectors/bluebubbles.ts +357 -0
  143. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  144. package/src/lib/server/connectors/googlechat.ts +46 -7
  145. package/src/lib/server/connectors/manager.ts +401 -6
  146. package/src/lib/server/connectors/media.ts +2 -2
  147. package/src/lib/server/connectors/openclaw.ts +64 -0
  148. package/src/lib/server/connectors/pairing.test.ts +99 -0
  149. package/src/lib/server/connectors/pairing.ts +256 -0
  150. package/src/lib/server/connectors/signal.ts +1 -0
  151. package/src/lib/server/connectors/teams.ts +5 -5
  152. package/src/lib/server/connectors/types.ts +10 -0
  153. package/src/lib/server/context-manager.ts +1 -1
  154. package/src/lib/server/daemon-state.ts +3 -0
  155. package/src/lib/server/data-dir.ts +1 -0
  156. package/src/lib/server/execution-log.ts +3 -3
  157. package/src/lib/server/heartbeat-service.ts +67 -3
  158. package/src/lib/server/knowledge-db.test.ts +2 -33
  159. package/src/lib/server/langgraph-checkpoint.ts +274 -0
  160. package/src/lib/server/main-agent-loop.ts +67 -8
  161. package/src/lib/server/memory-db.ts +6 -6
  162. package/src/lib/server/openclaw-approvals.ts +105 -0
  163. package/src/lib/server/openclaw-sync.ts +496 -0
  164. package/src/lib/server/orchestrator-lg.ts +422 -20
  165. package/src/lib/server/orchestrator.ts +29 -9
  166. package/src/lib/server/process-manager.ts +2 -2
  167. package/src/lib/server/queue.ts +39 -13
  168. package/src/lib/server/scheduler.ts +2 -2
  169. package/src/lib/server/session-mailbox.ts +2 -2
  170. package/src/lib/server/session-run-manager.ts +8 -3
  171. package/src/lib/server/session-tools/connector.ts +51 -4
  172. package/src/lib/server/session-tools/crud.ts +3 -3
  173. package/src/lib/server/session-tools/delegate.ts +5 -5
  174. package/src/lib/server/session-tools/file.ts +176 -3
  175. package/src/lib/server/session-tools/index.ts +4 -0
  176. package/src/lib/server/session-tools/memory.ts +2 -2
  177. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  178. package/src/lib/server/session-tools/sandbox.ts +197 -0
  179. package/src/lib/server/session-tools/search-providers.ts +270 -0
  180. package/src/lib/server/session-tools/session-info.ts +2 -2
  181. package/src/lib/server/session-tools/web.ts +47 -66
  182. package/src/lib/server/storage-mcp.test.ts +25 -2
  183. package/src/lib/server/storage.ts +36 -7
  184. package/src/lib/server/stream-agent-chat.ts +106 -22
  185. package/src/lib/server/task-result.test.ts +44 -0
  186. package/src/lib/server/task-result.ts +14 -0
  187. package/src/lib/server/task-validation.test.ts +23 -0
  188. package/src/lib/server/task-validation.ts +5 -3
  189. package/src/lib/server/ws-hub.ts +85 -0
  190. package/src/lib/tool-definitions.ts +44 -0
  191. package/src/lib/tts-stream.ts +130 -0
  192. package/src/lib/upload.ts +7 -1
  193. package/src/lib/view-routes.ts +28 -0
  194. package/src/lib/ws-client.ts +124 -0
  195. package/src/proxy.ts +3 -0
  196. package/src/stores/use-app-store.ts +28 -1
  197. package/src/stores/use-chat-store.ts +42 -14
  198. package/src/types/index.ts +34 -2
  199. package/src/app/api/agents/generate/route.ts +0 -42
  200. package/src/app/api/generate/info/route.ts +0 -12
  201. package/src/app/api/generate/route.ts +0 -106
  202. package/src/app/favicon.ico +0 -0
  203. package/src/components/shared/ai-gen-block.tsx +0 -77
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useEffect, useCallback, useState, useRef } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
+ import { useWs } from '@/hooks/use-ws'
5
6
  import { useChatStore } from '@/stores/use-chat-store'
6
7
  import { fetchMessages, clearMessages, deleteSession, devServer, checkBrowser, stopBrowser } from '@/lib/sessions'
7
8
  import { uploadImage } from '@/lib/upload'
@@ -11,13 +12,15 @@ import { ChatHeader } from './chat-header'
11
12
  import { DevServerBar } from './dev-server-bar'
12
13
  import { MessageList } from './message-list'
13
14
  import { SessionDebugPanel } from './session-debug-panel'
15
+ import { VoiceOverlay } from './voice-overlay'
16
+ import { useVoiceConversation } from '@/hooks/use-voice-conversation'
14
17
  import { ChatInput } from '@/components/input/chat-input'
15
18
  import { Dropdown, DropdownItem } from '@/components/shared/dropdown'
16
19
  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
17
20
  import { speak } from '@/lib/tts'
18
21
 
19
22
  const PROMPT_SUGGESTIONS = [
20
- { text: 'List all my sessions and agents', icon: 'book', gradient: 'from-[#6366F1]/10 to-[#818CF8]/5' },
23
+ { text: 'What can you help me with?', icon: 'book', gradient: 'from-[#6366F1]/10 to-[#818CF8]/5' },
21
24
  { text: 'Help me set up a new connector', icon: 'link', gradient: 'from-[#EC4899]/10 to-[#F472B6]/5' },
22
25
  { text: 'Create a new agent for me', icon: 'bot', gradient: 'from-[#34D399]/10 to-[#6EE7B7]/5' },
23
26
  { text: 'Schedule a recurring task', icon: 'check', gradient: 'from-[#F59E0B]/10 to-[#FBBF24]/5' },
@@ -42,6 +45,12 @@ export function ChatArea() {
42
45
  const setEditingAgentId = useAppStore((s) => s.setEditingAgentId)
43
46
  const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
44
47
 
48
+ const voice = useVoiceConversation()
49
+ const handleVoiceToggle = useCallback(() => {
50
+ if (voice.active) voice.stop()
51
+ else voice.start()
52
+ }, [voice])
53
+
45
54
  const [menuOpen, setMenuOpen] = useState(false)
46
55
  const [confirmDelete, setConfirmDelete] = useState(false)
47
56
  const [confirmClear, setConfirmClear] = useState(false)
@@ -90,33 +99,43 @@ export function ChatArea() {
90
99
  const isOrchestrated = session?.sessionType === 'orchestrated'
91
100
  const isServerActive = session?.active === true
92
101
  const isOngoingMonitored = appSettings.loopMode === 'ongoing' && !!session?.tools?.length
93
- useEffect(() => {
94
- if (!sessionId || (!isOrchestrated && !isServerActive && !isOngoingMonitored)) return
95
- const interval = setInterval(async () => {
96
- try {
97
- const msgs = await fetchMessages(sessionId)
98
- if (msgs.length > messages.length) {
99
- const newMsgs = msgs.slice(messages.length)
100
- setMessages(msgs)
101
- if (ttsEnabled && typeof document !== 'undefined' && document.visibilityState === 'visible') {
102
- const latestAssistant = [...newMsgs].reverse().find((m) => {
103
- if (m.role !== 'assistant') return false
104
- const isHeartbeat = m.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(m.text || '')
105
- return !isHeartbeat && !!m.text?.trim()
106
- })
107
- if (latestAssistant?.text) {
108
- void speak(latestAssistant.text)
109
- }
102
+ const shouldPollMessages = !!sessionId && (isOrchestrated || isServerActive || isOngoingMonitored)
103
+ const messagesLenRef = useRef(messages.length)
104
+ messagesLenRef.current = messages.length
105
+ const isServerActiveRef = useRef(isServerActive)
106
+ isServerActiveRef.current = isServerActive
107
+ const ttsEnabledRef = useRef(ttsEnabled)
108
+ ttsEnabledRef.current = ttsEnabled
109
+
110
+ const refreshMessages = useCallback(async () => {
111
+ if (!sessionId) return
112
+ try {
113
+ const msgs = await fetchMessages(sessionId)
114
+ if (msgs.length > messagesLenRef.current) {
115
+ const newMsgs = msgs.slice(messagesLenRef.current)
116
+ setMessages(msgs)
117
+ if (ttsEnabledRef.current && typeof document !== 'undefined' && document.visibilityState === 'visible') {
118
+ const latestAssistant = [...newMsgs].reverse().find((m) => {
119
+ if (m.role !== 'assistant') return false
120
+ const isHeartbeat = m.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(m.text || '')
121
+ return !isHeartbeat && !!m.text?.trim()
122
+ })
123
+ if (latestAssistant?.text) {
124
+ void speak(latestAssistant.text)
110
125
  }
111
126
  }
112
- // Check if session is still active on the server
113
- if (isServerActive) {
114
- await loadSessions()
115
- }
116
- } catch (err) { console.error('Failed to refresh messages:', err) }
117
- }, 2000)
118
- return () => clearInterval(interval)
119
- }, [sessionId, isOrchestrated, isServerActive, isOngoingMonitored, messages.length])
127
+ }
128
+ if (isServerActiveRef.current) await loadSessions()
129
+ } catch (err) { console.error('Failed to refresh messages:', err) }
130
+ }, [sessionId])
131
+
132
+ // Subscribe to WS messages for this session — always subscribe when session exists,
133
+ // only enable fallback polling when actively needed
134
+ useWs(
135
+ sessionId ? `messages:${sessionId}` : '',
136
+ refreshMessages,
137
+ shouldPollMessages ? 2000 : undefined,
138
+ )
120
139
 
121
140
  // When server-active flag drops, stop the streaming indicator
122
141
  useEffect(() => {
@@ -136,14 +155,17 @@ export function ChatArea() {
136
155
 
137
156
  // Poll browser status while session has browser tools
138
157
  const hasBrowserTool = session?.tools?.includes('browser')
139
- useEffect(() => {
158
+ const checkBrowserStatus = useCallback(() => {
140
159
  if (!sessionId || !hasBrowserTool) return
141
- const interval = setInterval(() => {
142
- checkBrowser(sessionId).then((r) => setBrowserActive(r.active)).catch(() => {})
143
- }, 5000)
144
- return () => clearInterval(interval)
160
+ checkBrowser(sessionId).then((r) => setBrowserActive(r.active)).catch(() => {})
145
161
  }, [sessionId, hasBrowserTool])
146
162
 
163
+ useWs(
164
+ hasBrowserTool && sessionId ? `browser:${sessionId}` : '',
165
+ checkBrowserStatus,
166
+ hasBrowserTool ? 5000 : undefined,
167
+ )
168
+
147
169
  const handleStopBrowser = useCallback(async () => {
148
170
  if (!sessionId) return
149
171
  await stopBrowser(sessionId)
@@ -237,6 +259,9 @@ export function ChatArea() {
237
259
  onBack={handleBack}
238
260
  browserActive={browserActive}
239
261
  onStopBrowser={handleStopBrowser}
262
+ voiceActive={voice.active}
263
+ voiceSupported={voice.supported}
264
+ onVoiceToggle={handleVoiceToggle}
240
265
  />
241
266
  )}
242
267
  {!isDesktop && (
@@ -248,6 +273,9 @@ export function ChatArea() {
248
273
  mobile
249
274
  browserActive={browserActive}
250
275
  onStopBrowser={handleStopBrowser}
276
+ voiceActive={voice.active}
277
+ voiceSupported={voice.supported}
278
+ onVoiceToggle={handleVoiceToggle}
251
279
  />
252
280
  )}
253
281
  <DevServerBar status={devServerStatus} onStop={handleStopDevServer} />
@@ -306,6 +334,15 @@ export function ChatArea() {
306
334
  <MessageList messages={messages} streaming={streamingForThisSession} />
307
335
  )}
308
336
 
337
+ {voice.active && (
338
+ <VoiceOverlay
339
+ state={voice.state}
340
+ interimText={voice.interimText}
341
+ transcript={voice.transcript}
342
+ onStop={voice.stop}
343
+ />
344
+ )}
345
+
309
346
  <SessionDebugPanel
310
347
  messages={messages}
311
348
  open={debugOpen}
@@ -334,7 +371,7 @@ export function ChatArea() {
334
371
  )}
335
372
  {!isMainChat && (
336
373
  <DropdownItem danger onClick={() => { setMenuOpen(false); setConfirmDelete(true) }}>
337
- Delete Session
374
+ Delete Chat
338
375
  </DropdownItem>
339
376
  )}
340
377
  </Dropdown>
@@ -342,7 +379,7 @@ export function ChatArea() {
342
379
  <ConfirmDialog
343
380
  open={confirmClear}
344
381
  title="Clear History"
345
- message="This will delete all messages in this session. This cannot be undone."
382
+ message="This will delete all messages in this chat. This cannot be undone."
346
383
  confirmLabel="Clear"
347
384
  danger
348
385
  onConfirm={handleClear}
@@ -350,7 +387,7 @@ export function ChatArea() {
350
387
  />
351
388
  <ConfirmDialog
352
389
  open={confirmDelete}
353
- title="Delete Session"
390
+ title="Delete Chat"
354
391
  message={`Delete "${session.name}"? This cannot be undone.`}
355
392
  confirmLabel="Delete"
356
393
  danger
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState, useMemo } from 'react'
3
+ import { useEffect, useState, useMemo, useRef } from 'react'
4
4
  import type { Session } from '@/types'
5
5
  import { useAppStore } from '@/stores/use-app-store'
6
6
  import { useChatStore } from '@/stores/use-chat-store'
@@ -18,6 +18,16 @@ function shortPath(p: string): string {
18
18
  return (p || '').replace(/^\/Users\/\w+/, '~')
19
19
  }
20
20
 
21
+ function formatDuration(sec: number): string {
22
+ if (sec >= 3600) {
23
+ const h = Math.floor(sec / 3600)
24
+ const m = Math.floor((sec % 3600) / 60)
25
+ return m > 0 ? `${h}h${m}m` : `${h}h`
26
+ }
27
+ if (sec >= 60) return `${Math.floor(sec / 60)}m`
28
+ return `${sec}s`
29
+ }
30
+
21
31
  const PROVIDER_LABELS: Record<string, string> = {
22
32
  'claude-cli': 'CLI',
23
33
  openai: 'OpenAI',
@@ -34,9 +44,12 @@ interface Props {
34
44
  mobile?: boolean
35
45
  browserActive?: boolean
36
46
  onStopBrowser?: () => void
47
+ onVoiceToggle?: () => void
48
+ voiceActive?: boolean
49
+ voiceSupported?: boolean
37
50
  }
38
51
 
39
- export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser }: Props) {
52
+ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported }: Props) {
40
53
  const ttsEnabled = useChatStore((s) => s.ttsEnabled)
41
54
  const toggleTts = useChatStore((s) => s.toggleTts)
42
55
  const debugOpen = useChatStore((s) => s.debugOpen)
@@ -49,6 +62,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
49
62
  const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
50
63
  const appSettings = useAppStore((s) => s.appSettings)
51
64
  const loadSessions = useAppStore((s) => s.loadSessions)
65
+ const loadAgents = useAppStore((s) => s.loadAgents)
52
66
  const connectors = useAppStore((s) => s.connectors)
53
67
  const loadConnectors = useAppStore((s) => s.loadConnectors)
54
68
  const providerLabel = PROVIDER_LABELS[session.provider] || session.provider
@@ -58,6 +72,8 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
58
72
  const modelName = session.model || agent?.model || ''
59
73
  const [copied, setCopied] = useState(false)
60
74
  const [heartbeatSaving, setHeartbeatSaving] = useState(false)
75
+ const [hbDropdownOpen, setHbDropdownOpen] = useState(false)
76
+ const hbDropdownRef = useRef<HTMLDivElement>(null)
61
77
  const [mainLoopSaving, setMainLoopSaving] = useState(false)
62
78
  const [mainLoopError, setMainLoopError] = useState('')
63
79
  const [mainLoopNotice, setMainLoopNotice] = useState('')
@@ -102,11 +118,53 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
102
118
  setTimeout(() => setCopied(false), 2000)
103
119
  }
104
120
 
105
- const heartbeatEnabled = session.heartbeatEnabled !== false
106
121
  const heartbeatSupported = (session.tools?.length ?? 0) > 0
107
122
  const loopIsOngoing = appSettings.loopMode === 'ongoing'
108
- const heartbeatIntervalRaw = session.heartbeatIntervalSec ?? appSettings.heartbeatIntervalSec ?? 120
109
- const heartbeatIntervalSec = Number.isFinite(Number(heartbeatIntervalRaw)) ? Math.max(0, Math.trunc(Number(heartbeatIntervalRaw))) : 120
123
+ const { heartbeatEnabled, heartbeatIntervalSec, heartbeatExplicitOptIn } = useMemo(() => {
124
+ // Resolve through the same cascade as the backend: settings → agent → session
125
+ const parseDur = (v: unknown): number | null => {
126
+ if (v === null || v === undefined) return null
127
+ if (typeof v === 'number') return Number.isFinite(v) ? Math.max(0, Math.min(86400, Math.trunc(v))) : null
128
+ if (typeof v !== 'string') return null
129
+ const t = v.trim().toLowerCase()
130
+ if (!t) return null
131
+ const n = Number(t)
132
+ if (Number.isFinite(n)) return Math.max(0, Math.min(86400, Math.trunc(n)))
133
+ const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/)
134
+ if (!m || (!m[1] && !m[2] && !m[3])) return null
135
+ const total = (m[1] ? parseInt(m[1]) * 3600 : 0) + (m[2] ? parseInt(m[2]) * 60 : 0) + (m[3] ? parseInt(m[3]) : 0)
136
+ return Math.max(0, Math.min(86400, total))
137
+ }
138
+ const resolveFrom = (obj: Record<string, any>): number | null => {
139
+ const dur = parseDur(obj.heartbeatInterval)
140
+ if (dur !== null) return dur
141
+ const sec = parseDur(obj.heartbeatIntervalSec)
142
+ if (sec !== null) return sec
143
+ return null
144
+ }
145
+ // Global defaults
146
+ let sec = resolveFrom(appSettings as Record<string, any>) ?? 1800
147
+ let enabled = sec > 0
148
+ let explicitOptIn = false
149
+ // Agent layer
150
+ if (agent) {
151
+ if (agent.heartbeatEnabled === false) enabled = false
152
+ if (agent.heartbeatEnabled === true) { enabled = true; explicitOptIn = true }
153
+ sec = resolveFrom(agent as Record<string, any>) ?? sec
154
+ }
155
+ // Session layer — only applies for non-agent chats (agent chats save directly to agent)
156
+ if (!agent) {
157
+ if (session.heartbeatEnabled === false) enabled = false
158
+ if (session.heartbeatEnabled === true) { enabled = true; explicitOptIn = true }
159
+ sec = resolveFrom(session as Record<string, any>) ?? sec
160
+ }
161
+ return {
162
+ heartbeatEnabled: enabled && sec > 0,
163
+ heartbeatIntervalSec: sec,
164
+ heartbeatExplicitOptIn: explicitOptIn,
165
+ }
166
+ }, [appSettings, agent, session])
167
+ const heartbeatWillRun = heartbeatEnabled && (loopIsOngoing || heartbeatExplicitOptIn)
110
168
  const isMainSession = session.name === '__main__'
111
169
  const missionState = session.mainLoopState || {}
112
170
  const missionPaused = missionState.paused === true
@@ -119,23 +177,40 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
119
177
  if (!heartbeatSupported || heartbeatSaving) return
120
178
  setHeartbeatSaving(true)
121
179
  try {
122
- await api('PUT', `/sessions/${session.id}`, { heartbeatEnabled: !heartbeatEnabled })
123
- await loadSessions()
180
+ const next = !heartbeatEnabled
181
+ if (session.agentId) {
182
+ await api('PUT', `/agents/${session.agentId}`, { heartbeatEnabled: next })
183
+ // Clear any stale session-level override so the agent value wins
184
+ await api('PUT', `/sessions/${session.id}`, { heartbeatEnabled: null })
185
+ await Promise.all([loadAgents(), loadSessions()])
186
+ } else {
187
+ await api('PUT', `/sessions/${session.id}`, { heartbeatEnabled: next })
188
+ await loadSessions()
189
+ }
124
190
  } finally {
125
191
  setHeartbeatSaving(false)
126
192
  }
127
193
  }
128
194
 
129
- const handleCycleHeartbeatInterval = async () => {
195
+ const handleSelectHeartbeatInterval = async (sec: number) => {
130
196
  if (!heartbeatSupported || heartbeatSaving) return
131
- const presets = [30, 60, 120, 300, 600]
132
- const current = heartbeatIntervalSec
133
- const idx = presets.indexOf(current)
134
- const next = idx === -1 ? 120 : presets[(idx + 1) % presets.length]
197
+ setHbDropdownOpen(false)
135
198
  setHeartbeatSaving(true)
136
199
  try {
137
- await api('PUT', `/sessions/${session.id}`, { heartbeatIntervalSec: next, heartbeatEnabled: true })
138
- await loadSessions()
200
+ if (session.agentId) {
201
+ // Save to agent with both formats so the cascade resolves correctly
202
+ await api('PUT', `/agents/${session.agentId}`, {
203
+ heartbeatInterval: formatDuration(sec),
204
+ heartbeatIntervalSec: sec,
205
+ heartbeatEnabled: true,
206
+ })
207
+ // Clear stale session-level overrides
208
+ await api('PUT', `/sessions/${session.id}`, { heartbeatIntervalSec: null, heartbeatEnabled: null })
209
+ await Promise.all([loadAgents(), loadSessions()])
210
+ } else {
211
+ await api('PUT', `/sessions/${session.id}`, { heartbeatIntervalSec: sec, heartbeatEnabled: true })
212
+ await loadSessions()
213
+ }
139
214
  } finally {
140
215
  setHeartbeatSaving(false)
141
216
  }
@@ -193,6 +268,15 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
193
268
  void postMainLoopAction('clear_events')
194
269
  }
195
270
 
271
+ useEffect(() => {
272
+ if (!hbDropdownOpen) return
273
+ const handler = (e: MouseEvent) => {
274
+ if (hbDropdownRef.current && !hbDropdownRef.current.contains(e.target as Node)) setHbDropdownOpen(false)
275
+ }
276
+ document.addEventListener('mousedown', handler)
277
+ return () => document.removeEventListener('mousedown', handler)
278
+ }, [hbDropdownOpen])
279
+
196
280
  useEffect(() => {
197
281
  if (session.name.startsWith('connector:')) {
198
282
  void loadConnectors()
@@ -298,7 +382,16 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
298
382
  <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
299
383
  </svg>
300
384
  </IconButton>
301
- <IconButton onClick={(e) => { e.stopPropagation(); onMenuToggle() }} aria-label="Session menu">
385
+ {voiceSupported && onVoiceToggle && (
386
+ <IconButton onClick={onVoiceToggle} active={voiceActive} aria-label="Toggle voice conversation">
387
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
388
+ <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
389
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
390
+ <line x1="12" x2="12" y1="19" y2="22" />
391
+ </svg>
392
+ </IconButton>
393
+ )}
394
+ <IconButton onClick={(e) => { e.stopPropagation(); onMenuToggle() }} aria-label="Chat menu">
302
395
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
303
396
  <circle cx="12" cy="6" r="1" />
304
397
  <circle cx="12" cy="12" r="1" />
@@ -320,25 +413,44 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
320
413
  onClick={handleToggleHeartbeat}
321
414
  disabled={heartbeatSaving}
322
415
  className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] transition-colors cursor-pointer border-none
323
- ${heartbeatEnabled ? 'bg-emerald-500/10 hover:bg-emerald-500/15 text-emerald-400' : 'bg-white/[0.04] hover:bg-white/[0.07] text-text-3'}`}
324
- title={loopIsOngoing ? 'Toggle heartbeat for this session' : 'Global loop mode is bounded; heartbeats are paused'}
416
+ ${heartbeatWillRun ? 'bg-emerald-500/10 hover:bg-emerald-500/15 text-emerald-400' : 'bg-white/[0.04] hover:bg-white/[0.07] text-text-3'}`}
417
+ title={heartbeatWillRun ? 'Toggle heartbeat' : !heartbeatEnabled ? 'Heartbeat disabled — click to enable' : 'Heartbeat enabled but paused (bounded loop mode, no explicit opt-in)'}
325
418
  >
326
- <span className={`w-1.5 h-1.5 rounded-full ${heartbeatEnabled ? 'bg-emerald-400' : 'bg-text-3/40'}`} />
419
+ <span className={`w-1.5 h-1.5 rounded-full ${heartbeatWillRun ? 'bg-emerald-400' : 'bg-text-3/40'}`} />
327
420
  <span className="text-[11px] font-600">
328
- HB {heartbeatEnabled ? 'On' : 'Off'}
421
+ HB {heartbeatWillRun ? 'On' : 'Off'}
329
422
  </span>
330
- {!loopIsOngoing && (
423
+ {heartbeatEnabled && !loopIsOngoing && !heartbeatExplicitOptIn && (
331
424
  <span className="text-[10px] text-text-3/50">(bounded)</span>
332
425
  )}
333
426
  </button>
334
- <button
335
- onClick={handleCycleHeartbeatInterval}
336
- disabled={heartbeatSaving}
337
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-white/[0.04] hover:bg-white/[0.07] text-text-3 transition-colors cursor-pointer border-none"
338
- title="Cycle heartbeat interval for this session"
339
- >
340
- <span className="text-[11px] font-600">{heartbeatIntervalSec}s</span>
341
- </button>
427
+ <div className="relative" ref={hbDropdownRef}>
428
+ <button
429
+ onClick={() => setHbDropdownOpen((o) => !o)}
430
+ disabled={heartbeatSaving}
431
+ className="flex items-center gap-1 px-2.5 py-1 rounded-[8px] bg-white/[0.04] hover:bg-white/[0.07] text-text-3 transition-colors cursor-pointer border-none"
432
+ title="Set heartbeat interval"
433
+ >
434
+ <span className="text-[11px] font-600">{formatDuration(heartbeatIntervalSec)}</span>
435
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-text-3/50">
436
+ <polyline points="6 9 12 15 18 9" />
437
+ </svg>
438
+ </button>
439
+ {hbDropdownOpen && (
440
+ <div className="absolute top-full left-0 mt-1 py-1 rounded-[10px] border border-white/[0.06] bg-bg/95 backdrop-blur-md shadow-lg z-50 min-w-[80px]">
441
+ {[30, 60, 120, 300, 600, 1800, 3600].map((sec) => (
442
+ <button
443
+ key={sec}
444
+ onClick={() => handleSelectHeartbeatInterval(sec)}
445
+ className={`w-full text-left px-3 py-1.5 text-[11px] font-600 transition-colors cursor-pointer border-none
446
+ ${sec === heartbeatIntervalSec ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:bg-white/[0.06]'}`}
447
+ >
448
+ {formatDuration(sec)}
449
+ </button>
450
+ ))}
451
+ </div>
452
+ )}
453
+ </div>
342
454
  </>
343
455
  )}
344
456
  {isMainSession && (
@@ -467,7 +579,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
467
579
  <button
468
580
  onClick={onStopBrowser}
469
581
  className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-[#3B82F6]/10 hover:bg-[#F43F5E]/15 transition-colors cursor-pointer group"
470
- title="Stop browser session"
582
+ title="Stop browser"
471
583
  >
472
584
  <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-[#3B82F6] group-hover:text-[#F43F5E]">
473
585
  <rect x="3" y="3" width="18" height="14" rx="2" />
@@ -3,54 +3,16 @@
3
3
  import { useState, useRef, useEffect } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
+ import { AVAILABLE_TOOLS, PLATFORM_TOOLS, TOOL_LABELS } from '@/lib/tool-definitions'
7
+ import type { ToolDefinition } from '@/lib/tool-definitions'
6
8
  import type { Session } from '@/types'
7
9
 
8
- const TOOL_GROUPS: { label: string; tools: Record<string, string> }[] = [
9
- {
10
- label: 'Tools',
11
- tools: {
12
- shell: 'Shell',
13
- files: 'Files',
14
- edit_file: 'Edit File',
15
- process: 'Process',
16
- web_search: 'Web Search',
17
- web_fetch: 'Web Fetch',
18
- browser: 'Browser',
19
- memory: 'Memory',
20
- },
21
- },
22
- {
23
- label: 'Delegation',
24
- tools: {
25
- claude_code: 'Claude Code',
26
- codex_cli: 'Codex CLI',
27
- opencode_cli: 'OpenCode CLI',
28
- },
29
- },
30
- {
31
- label: 'Platform',
32
- tools: {
33
- orchestrator: 'Orchestrator',
34
- manage_agents: 'Agents',
35
- manage_tasks: 'Tasks',
36
- manage_schedules: 'Schedules',
37
- manage_skills: 'Skills',
38
- manage_documents: 'Documents',
39
- manage_webhooks: 'Webhooks',
40
- manage_connectors: 'Connectors',
41
- manage_sessions: 'Sessions',
42
- manage_secrets: 'Secrets',
43
- },
44
- },
10
+ const TOOL_GROUPS: { label: string; tools: ToolDefinition[] }[] = [
11
+ { label: 'Tools', tools: AVAILABLE_TOOLS },
12
+ { label: 'Platform', tools: PLATFORM_TOOLS },
45
13
  ]
46
14
 
47
- // Flat lookup for display names
48
- const ALL_TOOLS: Record<string, string> = {}
49
- for (const g of TOOL_GROUPS) Object.assign(ALL_TOOLS, g.tools)
50
-
51
- const TOOL_HINTS: Record<string, string> = {
52
- orchestrator: 'Can delegate tasks to other agents',
53
- }
15
+ const TOTAL_TOOL_COUNT = AVAILABLE_TOOLS.length + PLATFORM_TOOLS.length
54
16
 
55
17
  interface Props {
56
18
  session: Session
@@ -87,7 +49,7 @@ export function ChatToolToggles({ session }: Props) {
87
49
  }
88
50
 
89
51
  const enabledCount = sessionTools.length
90
- const totalCount = Object.keys(ALL_TOOLS).length
52
+ const totalCount = TOTAL_TOOL_COUNT
91
53
 
92
54
  return (
93
55
  <div className="relative" ref={ref}>
@@ -111,12 +73,12 @@ export function ChatToolToggles({ session }: Props) {
111
73
  {TOOL_GROUPS.map((group, gi) => (
112
74
  <div key={group.label} className={`px-3 pb-1 ${gi === 0 ? 'pt-3' : 'pt-1 border-t border-white/[0.04]'}`}>
113
75
  <p className="text-[10px] font-600 text-text-3/60 uppercase tracking-wider mb-2">{group.label}</p>
114
- {Object.entries(group.tools).map(([toolId, label]) => {
115
- const enabled = sessionTools.includes(toolId)
76
+ {group.tools.map((tool) => {
77
+ const enabled = sessionTools.includes(tool.id)
116
78
  return (
117
- <label key={toolId} className="flex items-center gap-2.5 py-1.5 cursor-pointer">
79
+ <label key={tool.id} className="flex items-center gap-2.5 py-1.5 cursor-pointer">
118
80
  <div
119
- onClick={() => toggleTool(toolId)}
81
+ onClick={() => toggleTool(tool.id)}
120
82
  className={`w-8 h-[18px] rounded-full transition-all duration-200 relative cursor-pointer shrink-0
121
83
  ${enabled ? 'bg-[#6366F1]' : 'bg-white/[0.12]'}`}
122
84
  >
@@ -124,10 +86,7 @@ export function ChatToolToggles({ session }: Props) {
124
86
  ${enabled ? 'left-[16px]' : 'left-[2px]'}`} />
125
87
  </div>
126
88
  <span className={`text-[12px] ${enabled ? 'text-text-2' : 'text-text-3/70'}`}>
127
- {label}
128
- {TOOL_HINTS[toolId] && (
129
- <span className="ml-2 text-[10px] text-text-3/70 font-400">{TOOL_HINTS[toolId]}</span>
130
- )}
89
+ {tool.label}
131
90
  </span>
132
91
  </label>
133
92
  )