@swarmclawai/swarmclaw 0.4.0 → 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 (144) hide show
  1. package/README.md +13 -2
  2. package/next.config.ts +8 -0
  3. package/package.json +2 -1
  4. package/src/app/api/agents/[id]/route.ts +20 -21
  5. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  6. package/src/app/api/agents/route.ts +3 -2
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/connectors/[id]/route.ts +10 -3
  9. package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
  10. package/src/app/api/connectors/route.ts +6 -3
  11. package/src/app/api/credentials/[id]/route.ts +2 -1
  12. package/src/app/api/credentials/route.ts +2 -2
  13. package/src/app/api/documents/route.ts +2 -2
  14. package/src/app/api/files/serve/route.ts +8 -0
  15. package/src/app/api/knowledge/[id]/route.ts +5 -4
  16. package/src/app/api/knowledge/upload/route.ts +2 -2
  17. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  18. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  19. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  20. package/src/app/api/mcp-servers/route.ts +2 -2
  21. package/src/app/api/memory/[id]/route.ts +9 -8
  22. package/src/app/api/memory/route.ts +2 -2
  23. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  24. package/src/app/api/openclaw/directory/route.ts +26 -0
  25. package/src/app/api/openclaw/discover/route.ts +61 -0
  26. package/src/app/api/openclaw/sync/route.ts +30 -0
  27. package/src/app/api/orchestrator/run/route.ts +2 -2
  28. package/src/app/api/projects/[id]/route.ts +55 -0
  29. package/src/app/api/projects/route.ts +27 -0
  30. package/src/app/api/providers/[id]/models/route.ts +2 -1
  31. package/src/app/api/providers/[id]/route.ts +13 -15
  32. package/src/app/api/providers/route.ts +2 -2
  33. package/src/app/api/schedules/[id]/route.ts +16 -18
  34. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  35. package/src/app/api/schedules/route.ts +2 -2
  36. package/src/app/api/secrets/[id]/route.ts +16 -17
  37. package/src/app/api/secrets/route.ts +2 -2
  38. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  39. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  40. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  41. package/src/app/api/sessions/[id]/messages/route.ts +2 -1
  42. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  43. package/src/app/api/sessions/[id]/route.ts +2 -1
  44. package/src/app/api/sessions/route.ts +2 -2
  45. package/src/app/api/skills/[id]/route.ts +23 -21
  46. package/src/app/api/skills/import/route.ts +2 -2
  47. package/src/app/api/skills/route.ts +2 -2
  48. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  49. package/src/app/api/tasks/[id]/route.ts +6 -5
  50. package/src/app/api/tasks/route.ts +2 -2
  51. package/src/app/api/tts/stream/route.ts +48 -0
  52. package/src/app/api/upload/route.ts +2 -2
  53. package/src/app/api/uploads/[filename]/route.ts +4 -1
  54. package/src/app/api/webhooks/[id]/route.ts +29 -31
  55. package/src/app/api/webhooks/route.ts +2 -2
  56. package/src/app/page.tsx +3 -24
  57. package/src/cli/index.js +28 -0
  58. package/src/cli/index.ts +1 -1
  59. package/src/cli/spec.js +2 -0
  60. package/src/components/agents/agent-list.tsx +3 -1
  61. package/src/components/agents/agent-sheet.tsx +116 -14
  62. package/src/components/chat/chat-area.tsx +27 -4
  63. package/src/components/chat/chat-header.tsx +141 -29
  64. package/src/components/chat/tool-call-bubble.tsx +9 -3
  65. package/src/components/chat/voice-overlay.tsx +80 -0
  66. package/src/components/connectors/connector-list.tsx +6 -2
  67. package/src/components/connectors/connector-sheet.tsx +31 -7
  68. package/src/components/layout/app-layout.tsx +47 -25
  69. package/src/components/projects/project-list.tsx +122 -0
  70. package/src/components/projects/project-sheet.tsx +135 -0
  71. package/src/components/schedules/schedule-list.tsx +3 -1
  72. package/src/components/sessions/new-session-sheet.tsx +6 -6
  73. package/src/components/sessions/session-card.tsx +1 -1
  74. package/src/components/sessions/session-list.tsx +7 -7
  75. package/src/components/shared/connector-platform-icon.tsx +4 -0
  76. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  77. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  78. package/src/components/shared/settings/section-web-search.tsx +56 -0
  79. package/src/components/shared/settings/settings-page.tsx +73 -0
  80. package/src/components/skills/skill-list.tsx +2 -1
  81. package/src/components/tasks/task-list.tsx +5 -2
  82. package/src/hooks/use-continuous-speech.ts +144 -0
  83. package/src/hooks/use-view-router.ts +52 -0
  84. package/src/hooks/use-voice-conversation.ts +80 -0
  85. package/src/lib/id.ts +6 -0
  86. package/src/lib/projects.ts +13 -0
  87. package/src/lib/provider-sets.ts +5 -0
  88. package/src/lib/providers/anthropic.ts +14 -1
  89. package/src/lib/providers/index.ts +6 -0
  90. package/src/lib/providers/ollama.ts +9 -1
  91. package/src/lib/providers/openai.ts +9 -1
  92. package/src/lib/providers/openclaw.ts +11 -0
  93. package/src/lib/server/api-routes.test.ts +5 -6
  94. package/src/lib/server/build-llm.ts +17 -4
  95. package/src/lib/server/chat-execution.ts +38 -4
  96. package/src/lib/server/collection-helpers.ts +54 -0
  97. package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
  98. package/src/lib/server/connectors/bluebubbles.ts +357 -0
  99. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  100. package/src/lib/server/connectors/googlechat.ts +46 -7
  101. package/src/lib/server/connectors/manager.ts +392 -3
  102. package/src/lib/server/connectors/media.ts +2 -2
  103. package/src/lib/server/connectors/openclaw.ts +64 -0
  104. package/src/lib/server/connectors/pairing.test.ts +99 -0
  105. package/src/lib/server/connectors/pairing.ts +256 -0
  106. package/src/lib/server/connectors/signal.ts +1 -0
  107. package/src/lib/server/connectors/teams.ts +5 -5
  108. package/src/lib/server/connectors/types.ts +10 -0
  109. package/src/lib/server/execution-log.ts +3 -3
  110. package/src/lib/server/heartbeat-service.ts +1 -1
  111. package/src/lib/server/knowledge-db.test.ts +2 -33
  112. package/src/lib/server/main-agent-loop.ts +6 -6
  113. package/src/lib/server/memory-db.ts +6 -6
  114. package/src/lib/server/openclaw-approvals.ts +105 -0
  115. package/src/lib/server/openclaw-sync.ts +496 -0
  116. package/src/lib/server/orchestrator-lg.ts +30 -9
  117. package/src/lib/server/orchestrator.ts +4 -4
  118. package/src/lib/server/process-manager.ts +2 -2
  119. package/src/lib/server/queue.ts +22 -10
  120. package/src/lib/server/scheduler.ts +2 -2
  121. package/src/lib/server/session-mailbox.ts +2 -2
  122. package/src/lib/server/session-run-manager.ts +2 -2
  123. package/src/lib/server/session-tools/connector.ts +51 -4
  124. package/src/lib/server/session-tools/crud.ts +3 -3
  125. package/src/lib/server/session-tools/delegate.ts +3 -3
  126. package/src/lib/server/session-tools/file.ts +176 -3
  127. package/src/lib/server/session-tools/index.ts +2 -0
  128. package/src/lib/server/session-tools/memory.ts +2 -2
  129. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  130. package/src/lib/server/session-tools/sandbox.ts +33 -0
  131. package/src/lib/server/session-tools/search-providers.ts +270 -0
  132. package/src/lib/server/session-tools/session-info.ts +2 -2
  133. package/src/lib/server/session-tools/web.ts +47 -66
  134. package/src/lib/server/storage.ts +12 -0
  135. package/src/lib/server/stream-agent-chat.ts +29 -0
  136. package/src/lib/server/task-result.test.ts +44 -0
  137. package/src/lib/server/task-result.ts +14 -0
  138. package/src/lib/tool-definitions.ts +5 -3
  139. package/src/lib/tts-stream.ts +130 -0
  140. package/src/lib/view-routes.ts +28 -0
  141. package/src/proxy.ts +3 -0
  142. package/src/stores/use-app-store.ts +28 -1
  143. package/src/stores/use-chat-store.ts +9 -1
  144. package/src/types/index.ts +27 -2
@@ -9,8 +9,42 @@ import { toast } from 'sonner'
9
9
  import { ModelCombobox } from '@/components/shared/model-combobox'
10
10
  import type { ProviderType, ClaudeSkill } from '@/types'
11
11
  import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
12
+ import { NATIVE_CAPABILITY_PROVIDER_IDS, NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
12
13
 
13
- const NATIVE_CAPABILITY_PROVIDER_IDS = new Set<ProviderType>(['claude-cli', 'codex-cli', 'opencode-cli', 'openclaw'])
14
+ const HB_PRESETS = [30, 60, 120, 300, 600, 1800, 3600] as const
15
+
16
+ function formatHbDuration(sec: number): string {
17
+ if (sec >= 3600) {
18
+ const h = Math.floor(sec / 3600)
19
+ const m = Math.floor((sec % 3600) / 60)
20
+ return m > 0 ? `${h}h${m}m` : `${h}h`
21
+ }
22
+ if (sec >= 60) return `${Math.floor(sec / 60)}m`
23
+ return `${sec}s`
24
+ }
25
+
26
+ /** Parse a stored heartbeatInterval string or heartbeatIntervalSec number to a select-friendly string of seconds */
27
+ function parseDurationToSec(interval: string | number | null | undefined, intervalSec: number | null | undefined): string {
28
+ if (intervalSec != null && Number.isFinite(intervalSec) && intervalSec > 0) {
29
+ // Snap to nearest preset if close, otherwise use raw value
30
+ const closest = HB_PRESETS.find((p) => p === Math.round(intervalSec))
31
+ if (closest) return String(closest)
32
+ }
33
+ if (typeof interval === 'number' && Number.isFinite(interval) && interval > 0) {
34
+ return String(Math.round(interval))
35
+ }
36
+ if (interval != null && typeof interval === 'string' && interval.trim()) {
37
+ const t = interval.trim().toLowerCase()
38
+ const n = Number(t)
39
+ if (Number.isFinite(n) && n > 0) return String(Math.round(n))
40
+ const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/)
41
+ if (m && (m[1] || m[2] || m[3])) {
42
+ const total = (m[1] ? parseInt(m[1]) * 3600 : 0) + (m[2] ? parseInt(m[2]) * 60 : 0) + (m[3] ? parseInt(m[3]) : 0)
43
+ if (total > 0) return String(total)
44
+ }
45
+ }
46
+ return '' // default
47
+ }
14
48
 
15
49
  export function AgentSheet() {
16
50
  const open = useAppStore((s) => s.agentSheetOpen)
@@ -19,6 +53,8 @@ export function AgentSheet() {
19
53
  const setEditingId = useAppStore((s) => s.setEditingAgentId)
20
54
  const agents = useAppStore((s) => s.agents)
21
55
  const loadAgents = useAppStore((s) => s.loadAgents)
56
+ const projects = useAppStore((s) => s.projects)
57
+ const loadProjects = useAppStore((s) => s.loadProjects)
22
58
  const providers = useAppStore((s) => s.providers)
23
59
  const loadProviders = useAppStore((s) => s.loadProviders)
24
60
  const credentials = useAppStore((s) => s.credentials)
@@ -60,9 +96,12 @@ export function AgentSheet() {
60
96
  const [capInput, setCapInput] = useState('')
61
97
  const [ollamaMode, setOllamaMode] = useState<'local' | 'cloud'>('local')
62
98
  const [openclawEnabled, setOpenclawEnabled] = useState(false)
99
+ const [projectId, setProjectId] = useState<string | undefined>(undefined)
100
+ const [thinkingLevel, setThinkingLevel] = useState<'' | 'minimal' | 'low' | 'medium' | 'high'>('')
63
101
  const [heartbeatEnabled, setHeartbeatEnabled] = useState(false)
64
- const [heartbeatInterval, setHeartbeatInterval] = useState('')
102
+ const [heartbeatIntervalSec, setHeartbeatIntervalSec] = useState('') // '' = default (30m)
65
103
  const [heartbeatModel, setHeartbeatModel] = useState('')
104
+ const [heartbeatPrompt, setHeartbeatPrompt] = useState('')
66
105
  const [addingKey, setAddingKey] = useState(false)
67
106
  const [newKeyName, setNewKeyName] = useState('')
68
107
  const [newKeyValue, setNewKeyValue] = useState('')
@@ -105,6 +144,7 @@ export function AgentSheet() {
105
144
  loadProviders()
106
145
  loadCredentials()
107
146
  loadSkills()
147
+ loadProjects()
108
148
  loadClaudeSkills()
109
149
  setTestStatus('idle')
110
150
  setTestMessage('')
@@ -130,9 +170,12 @@ export function AgentSheet() {
130
170
  setCapInput('')
131
171
  setOllamaMode(editing.credentialId && editing.provider === 'ollama' ? 'cloud' : 'local')
132
172
  setOpenclawEnabled(editing.provider === 'openclaw')
173
+ setProjectId(editing.projectId)
174
+ setThinkingLevel(editing.thinkingLevel || '')
133
175
  setHeartbeatEnabled(editing.heartbeatEnabled || false)
134
- setHeartbeatInterval(editing.heartbeatInterval != null ? String(editing.heartbeatInterval) : '')
176
+ setHeartbeatIntervalSec(parseDurationToSec(editing.heartbeatInterval, editing.heartbeatIntervalSec))
135
177
  setHeartbeatModel(editing.heartbeatModel || '')
178
+ setHeartbeatPrompt(editing.heartbeatPrompt || '')
136
179
  } else {
137
180
  setName('')
138
181
  setDescription('')
@@ -154,9 +197,12 @@ export function AgentSheet() {
154
197
  setCapInput('')
155
198
  setOllamaMode('local')
156
199
  setOpenclawEnabled(false)
200
+ setProjectId(undefined)
201
+ setThinkingLevel('')
157
202
  setHeartbeatEnabled(false)
158
- setHeartbeatInterval('')
203
+ setHeartbeatIntervalSec('')
159
204
  setHeartbeatModel('')
205
+ setHeartbeatPrompt('')
160
206
  }
161
207
  }
162
208
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -244,9 +290,13 @@ export function AgentSheet() {
244
290
  fallbackCredentialIds,
245
291
  platformAssignScope,
246
292
  capabilities,
293
+ projectId: projectId || undefined,
294
+ thinkingLevel: thinkingLevel || undefined,
247
295
  heartbeatEnabled,
248
- heartbeatInterval: heartbeatInterval.trim() || null,
296
+ heartbeatInterval: heartbeatIntervalSec ? formatHbDuration(Number(heartbeatIntervalSec)) : null,
297
+ heartbeatIntervalSec: heartbeatIntervalSec ? Number(heartbeatIntervalSec) : null,
249
298
  heartbeatModel: heartbeatModel.trim() || null,
299
+ heartbeatPrompt: heartbeatPrompt.trim() || null,
250
300
  }
251
301
  if (editing) {
252
302
  await updateAgent(editing.id, data)
@@ -330,8 +380,7 @@ export function AgentSheet() {
330
380
 
331
381
  // Whether this provider needs a connection test before saving.
332
382
  // Only CLI providers (no remote connection) skip the test.
333
- const CLI_ONLY_PROVIDERS: Set<ProviderType> = new Set(['claude-cli', 'codex-cli', 'opencode-cli'])
334
- const needsTest = !providerNeedsKey && !CLI_ONLY_PROVIDERS.has(provider)
383
+ const needsTest = !providerNeedsKey && !NON_LANGGRAPH_PROVIDER_IDS.has(provider)
335
384
 
336
385
  const [saving, setSaving] = useState(false)
337
386
 
@@ -451,6 +500,46 @@ export function AgentSheet() {
451
500
  <p className="text-[11px] text-text-3/70 mt-1.5">Press Enter or comma to add. Other agents see these when deciding delegation.</p>
452
501
  </div>}
453
502
 
503
+ {/* Project */}
504
+ {Object.keys(projects).length > 0 && (
505
+ <div className="mb-8">
506
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
507
+ Project <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>
508
+ </label>
509
+ <select
510
+ value={projectId || ''}
511
+ onChange={(e) => setProjectId(e.target.value || undefined)}
512
+ className={inputClass}
513
+ style={{ fontFamily: 'inherit' }}
514
+ >
515
+ <option value="">None</option>
516
+ {Object.values(projects).map((p) => (
517
+ <option key={p.id} value={p.id}>{p.name}</option>
518
+ ))}
519
+ </select>
520
+ </div>
521
+ )}
522
+
523
+ {/* Thinking Level */}
524
+ <div className="mb-8">
525
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
526
+ Thinking Level <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>
527
+ </label>
528
+ <select
529
+ value={thinkingLevel}
530
+ onChange={(e) => setThinkingLevel(e.target.value as typeof thinkingLevel)}
531
+ className={inputClass}
532
+ style={{ fontFamily: 'inherit' }}
533
+ >
534
+ <option value="">None (default)</option>
535
+ <option value="minimal">Minimal — Direct and concise</option>
536
+ <option value="low">Low — Brief reasoning</option>
537
+ <option value="medium">Medium — Moderate analysis</option>
538
+ <option value="high">High — Deep, thorough reasoning</option>
539
+ </select>
540
+ <p className="text-[11px] text-text-3/70 mt-1.5">Controls reasoning depth. Anthropic models use extended thinking; OpenAI o-series uses reasoning_effort. Others get system prompt guidance.</p>
541
+ </div>
542
+
454
543
  {/* Heartbeat Configuration */}
455
544
  <div className="mb-8">
456
545
  <div className="flex items-center justify-between mb-3">
@@ -467,14 +556,16 @@ export function AgentSheet() {
467
556
  <div className="space-y-4 mt-3">
468
557
  <div>
469
558
  <label className="block text-[12px] text-text-3/70 mb-1.5">Interval</label>
470
- <input
471
- type="text"
472
- value={heartbeatInterval}
473
- onChange={(e) => setHeartbeatInterval(e.target.value)}
474
- placeholder="30s, 5m, 1h (default: 30m)"
559
+ <select
560
+ value={heartbeatIntervalSec}
561
+ onChange={(e) => setHeartbeatIntervalSec(e.target.value)}
475
562
  className={inputClass}
476
- style={{ fontFamily: 'inherit' }}
477
- />
563
+ >
564
+ <option value="">Default (30m)</option>
565
+ {HB_PRESETS.map((sec) => (
566
+ <option key={sec} value={String(sec)}>{formatHbDuration(sec)}</option>
567
+ ))}
568
+ </select>
478
569
  </div>
479
570
  <div>
480
571
  <label className="block text-[12px] text-text-3/70 mb-1.5">Model override <span className="text-text-3/50">(optional, cheaper model)</span></label>
@@ -487,6 +578,17 @@ export function AgentSheet() {
487
578
  style={{ fontFamily: 'inherit' }}
488
579
  />
489
580
  </div>
581
+ <div>
582
+ <label className="block text-[12px] text-text-3/70 mb-1.5">Instructions <span className="text-text-3/50">(what to do each tick)</span></label>
583
+ <textarea
584
+ value={heartbeatPrompt}
585
+ onChange={(e) => setHeartbeatPrompt(e.target.value)}
586
+ placeholder="Describe what this agent should do during heartbeat ticks..."
587
+ rows={4}
588
+ className={`${inputClass} resize-y min-h-[100px]`}
589
+ style={{ fontFamily: 'inherit' }}
590
+ />
591
+ </div>
490
592
  </div>
491
593
  )}
492
594
  <p className="text-[11px] text-text-3/70 mt-1.5">Periodic check-in runs on idle sessions using this agent. Processes pending events and monitors status.</p>
@@ -12,13 +12,15 @@ import { ChatHeader } from './chat-header'
12
12
  import { DevServerBar } from './dev-server-bar'
13
13
  import { MessageList } from './message-list'
14
14
  import { SessionDebugPanel } from './session-debug-panel'
15
+ import { VoiceOverlay } from './voice-overlay'
16
+ import { useVoiceConversation } from '@/hooks/use-voice-conversation'
15
17
  import { ChatInput } from '@/components/input/chat-input'
16
18
  import { Dropdown, DropdownItem } from '@/components/shared/dropdown'
17
19
  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
18
20
  import { speak } from '@/lib/tts'
19
21
 
20
22
  const PROMPT_SUGGESTIONS = [
21
- { 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' },
22
24
  { text: 'Help me set up a new connector', icon: 'link', gradient: 'from-[#EC4899]/10 to-[#F472B6]/5' },
23
25
  { text: 'Create a new agent for me', icon: 'bot', gradient: 'from-[#34D399]/10 to-[#6EE7B7]/5' },
24
26
  { text: 'Schedule a recurring task', icon: 'check', gradient: 'from-[#F59E0B]/10 to-[#FBBF24]/5' },
@@ -43,6 +45,12 @@ export function ChatArea() {
43
45
  const setEditingAgentId = useAppStore((s) => s.setEditingAgentId)
44
46
  const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
45
47
 
48
+ const voice = useVoiceConversation()
49
+ const handleVoiceToggle = useCallback(() => {
50
+ if (voice.active) voice.stop()
51
+ else voice.start()
52
+ }, [voice])
53
+
46
54
  const [menuOpen, setMenuOpen] = useState(false)
47
55
  const [confirmDelete, setConfirmDelete] = useState(false)
48
56
  const [confirmClear, setConfirmClear] = useState(false)
@@ -251,6 +259,9 @@ export function ChatArea() {
251
259
  onBack={handleBack}
252
260
  browserActive={browserActive}
253
261
  onStopBrowser={handleStopBrowser}
262
+ voiceActive={voice.active}
263
+ voiceSupported={voice.supported}
264
+ onVoiceToggle={handleVoiceToggle}
254
265
  />
255
266
  )}
256
267
  {!isDesktop && (
@@ -262,6 +273,9 @@ export function ChatArea() {
262
273
  mobile
263
274
  browserActive={browserActive}
264
275
  onStopBrowser={handleStopBrowser}
276
+ voiceActive={voice.active}
277
+ voiceSupported={voice.supported}
278
+ onVoiceToggle={handleVoiceToggle}
265
279
  />
266
280
  )}
267
281
  <DevServerBar status={devServerStatus} onStop={handleStopDevServer} />
@@ -320,6 +334,15 @@ export function ChatArea() {
320
334
  <MessageList messages={messages} streaming={streamingForThisSession} />
321
335
  )}
322
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
+
323
346
  <SessionDebugPanel
324
347
  messages={messages}
325
348
  open={debugOpen}
@@ -348,7 +371,7 @@ export function ChatArea() {
348
371
  )}
349
372
  {!isMainChat && (
350
373
  <DropdownItem danger onClick={() => { setMenuOpen(false); setConfirmDelete(true) }}>
351
- Delete Session
374
+ Delete Chat
352
375
  </DropdownItem>
353
376
  )}
354
377
  </Dropdown>
@@ -356,7 +379,7 @@ export function ChatArea() {
356
379
  <ConfirmDialog
357
380
  open={confirmClear}
358
381
  title="Clear History"
359
- 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."
360
383
  confirmLabel="Clear"
361
384
  danger
362
385
  onConfirm={handleClear}
@@ -364,7 +387,7 @@ export function ChatArea() {
364
387
  />
365
388
  <ConfirmDialog
366
389
  open={confirmDelete}
367
- title="Delete Session"
390
+ title="Delete Chat"
368
391
  message={`Delete "${session.name}"? This cannot be undone.`}
369
392
  confirmLabel="Delete"
370
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" />
@@ -14,6 +14,8 @@ const TOOL_COLORS: Record<string, string> = {
14
14
  delete_file: '#EF4444',
15
15
  edit_file: '#10B981',
16
16
  send_file: '#10B981',
17
+ create_document: '#10B981',
18
+ create_spreadsheet: '#10B981',
17
19
  web_search: '#3B82F6',
18
20
  web_fetch: '#3B82F6',
19
21
  delegate_to_agent: '#6366F1',
@@ -60,6 +62,8 @@ export const TOOL_LABELS: Record<string, string> = {
60
62
  delete_file: 'Delete File',
61
63
  edit_file: 'Edit File',
62
64
  send_file: 'Send File',
65
+ create_document: 'Create Document',
66
+ create_spreadsheet: 'Create Spreadsheet',
63
67
  web_search: 'Web Search',
64
68
  web_fetch: 'Web Fetch',
65
69
  claude_code: 'Claude Code',
@@ -79,7 +83,7 @@ export const TOOL_LABELS: Record<string, string> = {
79
83
  manage_documents: 'Documents',
80
84
  manage_webhooks: 'Webhooks',
81
85
  manage_connectors: 'Connectors',
82
- manage_sessions: 'Sessions',
86
+ manage_sessions: 'Chats',
83
87
  memory: 'Memory',
84
88
  browser: 'Browser',
85
89
  }
@@ -94,6 +98,8 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
94
98
  delete_file: 'Delete files or directories (when explicitly enabled)',
95
99
  edit_file: 'Edit existing files with find-and-replace',
96
100
  send_file: 'Send files to the user (images, PDFs, videos, documents, etc.)',
101
+ create_document: 'Render markdown content into PDF, HTML, or image',
102
+ create_spreadsheet: 'Create Excel or CSV files from structured data',
97
103
  web_search: 'Search the web for information',
98
104
  web_fetch: 'Fetch and read web page content',
99
105
  claude_code: 'Enable delegation to Claude Code CLI',
@@ -103,7 +109,7 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
103
109
  delegate_to_claude_code: 'Delegate complex coding tasks to Claude Code',
104
110
  delegate_to_codex_cli: 'Delegate complex coding tasks to Codex CLI',
105
111
  delegate_to_opencode_cli: 'Delegate complex coding tasks to OpenCode CLI',
106
- whoami_tool: 'Reveal the current session and agent identity context',
112
+ whoami_tool: 'Reveal the current agent and chat context',
107
113
  connector_message_tool: 'Send proactive outbound messages via running connectors',
108
114
  search_history_tool: 'Search chat history for relevant prior context',
109
115
  manage_tasks: 'Create, update, and manage tasks on the board',
@@ -113,7 +119,7 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
113
119
  manage_documents: 'Upload and search indexed documents',
114
120
  manage_webhooks: 'Register and manage inbound webhooks',
115
121
  manage_connectors: 'Manage chat platform connectors (Slack, Discord, etc.)',
116
- manage_sessions: 'Create and manage chat sessions',
122
+ manage_sessions: 'Create and manage agent chats',
117
123
  memory: 'Store and recall information across conversations',
118
124
  browser: 'Browse the web, take screenshots, and interact with pages',
119
125
  }