@swarmclawai/swarmclaw 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +21 -4
  2. package/bin/server-cmd.js +28 -19
  3. package/next.config.ts +13 -0
  4. package/package.json +3 -1
  5. package/src/app/api/agents/[id]/route.ts +39 -22
  6. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  7. package/src/app/api/agents/route.ts +3 -2
  8. package/src/app/api/agents/trash/route.ts +44 -0
  9. package/src/app/api/clawhub/install/route.ts +2 -2
  10. package/src/app/api/connectors/[id]/route.ts +17 -7
  11. package/src/app/api/connectors/[id]/webhook/route.ts +103 -0
  12. package/src/app/api/connectors/route.ts +6 -3
  13. package/src/app/api/credentials/[id]/route.ts +2 -1
  14. package/src/app/api/credentials/route.ts +2 -2
  15. package/src/app/api/documents/route.ts +2 -2
  16. package/src/app/api/files/serve/route.ts +8 -0
  17. package/src/app/api/knowledge/[id]/route.ts +5 -4
  18. package/src/app/api/knowledge/upload/route.ts +2 -2
  19. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  20. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  21. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  22. package/src/app/api/mcp-servers/route.ts +2 -2
  23. package/src/app/api/memory/[id]/route.ts +9 -8
  24. package/src/app/api/memory/route.ts +2 -2
  25. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  26. package/src/app/api/openclaw/agent-files/route.ts +57 -0
  27. package/src/app/api/openclaw/approvals/route.ts +46 -0
  28. package/src/app/api/openclaw/config-sync/route.ts +33 -0
  29. package/src/app/api/openclaw/cron/route.ts +52 -0
  30. package/src/app/api/openclaw/directory/route.ts +27 -0
  31. package/src/app/api/openclaw/discover/route.ts +62 -0
  32. package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
  33. package/src/app/api/openclaw/exec-config/route.ts +41 -0
  34. package/src/app/api/openclaw/gateway/route.ts +72 -0
  35. package/src/app/api/openclaw/history/route.ts +109 -0
  36. package/src/app/api/openclaw/media/route.ts +53 -0
  37. package/src/app/api/openclaw/models/route.ts +12 -0
  38. package/src/app/api/openclaw/permissions/route.ts +39 -0
  39. package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
  40. package/src/app/api/openclaw/skills/install/route.ts +32 -0
  41. package/src/app/api/openclaw/skills/remove/route.ts +24 -0
  42. package/src/app/api/openclaw/skills/route.ts +82 -0
  43. package/src/app/api/openclaw/sync/route.ts +31 -0
  44. package/src/app/api/orchestrator/run/route.ts +2 -2
  45. package/src/app/api/projects/[id]/route.ts +55 -0
  46. package/src/app/api/projects/route.ts +27 -0
  47. package/src/app/api/providers/[id]/models/route.ts +2 -1
  48. package/src/app/api/providers/[id]/route.ts +13 -15
  49. package/src/app/api/providers/route.ts +2 -2
  50. package/src/app/api/schedules/[id]/route.ts +16 -18
  51. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  52. package/src/app/api/schedules/route.ts +2 -2
  53. package/src/app/api/secrets/[id]/route.ts +16 -17
  54. package/src/app/api/secrets/route.ts +2 -2
  55. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  56. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  57. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  58. package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
  59. package/src/app/api/sessions/[id]/fork/route.ts +44 -0
  60. package/src/app/api/sessions/[id]/messages/route.ts +20 -2
  61. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  62. package/src/app/api/sessions/[id]/route.ts +14 -4
  63. package/src/app/api/sessions/route.ts +8 -4
  64. package/src/app/api/skills/[id]/route.ts +23 -21
  65. package/src/app/api/skills/import/route.ts +2 -2
  66. package/src/app/api/skills/route.ts +2 -2
  67. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  68. package/src/app/api/tasks/[id]/route.ts +6 -5
  69. package/src/app/api/tasks/route.ts +2 -2
  70. package/src/app/api/tts/stream/route.ts +48 -0
  71. package/src/app/api/upload/route.ts +2 -2
  72. package/src/app/api/uploads/[filename]/route.ts +4 -1
  73. package/src/app/api/webhooks/[id]/route.ts +29 -31
  74. package/src/app/api/webhooks/route.ts +2 -2
  75. package/src/app/globals.css +14 -0
  76. package/src/app/layout.tsx +5 -20
  77. package/src/app/page.tsx +3 -24
  78. package/src/cli/index.js +60 -0
  79. package/src/cli/index.ts +1 -1
  80. package/src/cli/spec.js +42 -0
  81. package/src/components/agents/agent-avatar.tsx +45 -0
  82. package/src/components/agents/agent-card.tsx +19 -5
  83. package/src/components/agents/agent-chat-list.tsx +31 -24
  84. package/src/components/agents/agent-files-editor.tsx +185 -0
  85. package/src/components/agents/agent-list.tsx +84 -3
  86. package/src/components/agents/agent-sheet.tsx +147 -14
  87. package/src/components/agents/cron-job-form.tsx +137 -0
  88. package/src/components/agents/exec-config-panel.tsx +147 -0
  89. package/src/components/agents/inspector-panel.tsx +310 -0
  90. package/src/components/agents/openclaw-skills-panel.tsx +230 -0
  91. package/src/components/agents/permission-preset-selector.tsx +79 -0
  92. package/src/components/agents/personality-builder.tsx +111 -0
  93. package/src/components/agents/sandbox-env-panel.tsx +72 -0
  94. package/src/components/agents/skill-install-dialog.tsx +102 -0
  95. package/src/components/agents/trash-list.tsx +109 -0
  96. package/src/components/chat/chat-area.tsx +41 -6
  97. package/src/components/chat/chat-header.tsx +305 -29
  98. package/src/components/chat/chat-preview-panel.tsx +113 -0
  99. package/src/components/chat/exec-approval-card.tsx +89 -0
  100. package/src/components/chat/message-bubble.tsx +218 -36
  101. package/src/components/chat/message-list.tsx +135 -31
  102. package/src/components/chat/streaming-bubble.tsx +59 -10
  103. package/src/components/chat/suggestions-bar.tsx +74 -0
  104. package/src/components/chat/thinking-indicator.tsx +20 -6
  105. package/src/components/chat/tool-call-bubble.tsx +98 -19
  106. package/src/components/chat/tool-request-banner.tsx +20 -2
  107. package/src/components/chat/trace-block.tsx +103 -0
  108. package/src/components/chat/voice-overlay.tsx +80 -0
  109. package/src/components/connectors/connector-list.tsx +6 -2
  110. package/src/components/connectors/connector-sheet.tsx +31 -7
  111. package/src/components/layout/app-layout.tsx +47 -25
  112. package/src/components/projects/project-list.tsx +123 -0
  113. package/src/components/projects/project-sheet.tsx +135 -0
  114. package/src/components/schedules/schedule-list.tsx +3 -1
  115. package/src/components/sessions/new-session-sheet.tsx +6 -6
  116. package/src/components/sessions/session-card.tsx +1 -1
  117. package/src/components/sessions/session-list.tsx +7 -7
  118. package/src/components/settings/gateway-connection-panel.tsx +278 -0
  119. package/src/components/shared/avatar.tsx +13 -2
  120. package/src/components/shared/connector-platform-icon.tsx +4 -0
  121. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  122. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  123. package/src/components/shared/settings/section-web-search.tsx +56 -0
  124. package/src/components/shared/settings/settings-page.tsx +74 -0
  125. package/src/components/skills/skill-list.tsx +2 -1
  126. package/src/components/tasks/task-board.tsx +1 -1
  127. package/src/components/tasks/task-list.tsx +5 -2
  128. package/src/components/tasks/task-sheet.tsx +12 -12
  129. package/src/hooks/use-continuous-speech.ts +181 -0
  130. package/src/hooks/use-openclaw-gateway.ts +63 -0
  131. package/src/hooks/use-view-router.ts +52 -0
  132. package/src/hooks/use-voice-conversation.ts +80 -0
  133. package/src/lib/id.ts +6 -0
  134. package/src/lib/notification-sounds.ts +58 -0
  135. package/src/lib/personality-parser.ts +97 -0
  136. package/src/lib/projects.ts +13 -0
  137. package/src/lib/provider-sets.ts +5 -0
  138. package/src/lib/providers/anthropic.ts +14 -1
  139. package/src/lib/providers/index.ts +6 -0
  140. package/src/lib/providers/ollama.ts +9 -1
  141. package/src/lib/providers/openai.ts +9 -1
  142. package/src/lib/providers/openclaw.ts +28 -2
  143. package/src/lib/runtime-loop.ts +2 -2
  144. package/src/lib/server/api-routes.test.ts +5 -6
  145. package/src/lib/server/build-llm.ts +17 -4
  146. package/src/lib/server/chat-execution.ts +82 -6
  147. package/src/lib/server/collection-helpers.ts +54 -0
  148. package/src/lib/server/connectors/bluebubbles.test.ts +217 -0
  149. package/src/lib/server/connectors/bluebubbles.ts +360 -0
  150. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  151. package/src/lib/server/connectors/googlechat.ts +51 -8
  152. package/src/lib/server/connectors/manager.ts +424 -13
  153. package/src/lib/server/connectors/media.ts +2 -2
  154. package/src/lib/server/connectors/openclaw.ts +65 -0
  155. package/src/lib/server/connectors/pairing.test.ts +99 -0
  156. package/src/lib/server/connectors/pairing.ts +256 -0
  157. package/src/lib/server/connectors/signal.ts +1 -0
  158. package/src/lib/server/connectors/teams.ts +5 -5
  159. package/src/lib/server/connectors/types.ts +10 -0
  160. package/src/lib/server/daemon-state.ts +11 -0
  161. package/src/lib/server/execution-log.ts +3 -3
  162. package/src/lib/server/heartbeat-service.ts +1 -1
  163. package/src/lib/server/knowledge-db.test.ts +2 -33
  164. package/src/lib/server/main-agent-loop.ts +8 -9
  165. package/src/lib/server/main-session.ts +21 -0
  166. package/src/lib/server/memory-db.ts +6 -6
  167. package/src/lib/server/openclaw-approvals.ts +105 -0
  168. package/src/lib/server/openclaw-config-sync.ts +107 -0
  169. package/src/lib/server/openclaw-exec-config.ts +52 -0
  170. package/src/lib/server/openclaw-gateway.ts +291 -0
  171. package/src/lib/server/openclaw-history-merge.ts +36 -0
  172. package/src/lib/server/openclaw-models.ts +56 -0
  173. package/src/lib/server/openclaw-permission-presets.ts +64 -0
  174. package/src/lib/server/openclaw-sync.ts +497 -0
  175. package/src/lib/server/orchestrator-lg.ts +30 -9
  176. package/src/lib/server/orchestrator.ts +4 -4
  177. package/src/lib/server/process-manager.ts +2 -2
  178. package/src/lib/server/queue.ts +24 -11
  179. package/src/lib/server/scheduler.ts +2 -2
  180. package/src/lib/server/session-mailbox.ts +2 -2
  181. package/src/lib/server/session-run-manager.ts +2 -2
  182. package/src/lib/server/session-tools/connector.ts +53 -6
  183. package/src/lib/server/session-tools/crud.ts +3 -3
  184. package/src/lib/server/session-tools/delegate.ts +22 -6
  185. package/src/lib/server/session-tools/file.ts +192 -19
  186. package/src/lib/server/session-tools/index.ts +4 -2
  187. package/src/lib/server/session-tools/memory.ts +2 -2
  188. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  189. package/src/lib/server/session-tools/sandbox.ts +33 -0
  190. package/src/lib/server/session-tools/search-providers.ts +277 -0
  191. package/src/lib/server/session-tools/session-info.ts +2 -2
  192. package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
  193. package/src/lib/server/session-tools/shell.ts +1 -1
  194. package/src/lib/server/session-tools/web.ts +53 -72
  195. package/src/lib/server/storage.ts +74 -11
  196. package/src/lib/server/stream-agent-chat.ts +53 -4
  197. package/src/lib/server/suggestions.ts +20 -0
  198. package/src/lib/server/task-result.test.ts +44 -0
  199. package/src/lib/server/task-result.ts +14 -0
  200. package/src/lib/server/ws-hub.ts +14 -0
  201. package/src/lib/tool-definitions.ts +5 -3
  202. package/src/lib/tts-stream.ts +130 -0
  203. package/src/lib/view-routes.ts +28 -0
  204. package/src/proxy.ts +3 -0
  205. package/src/stores/use-app-store.ts +80 -1
  206. package/src/stores/use-approval-store.ts +78 -0
  207. package/src/stores/use-chat-store.ts +162 -6
  208. package/src/types/index.ts +154 -3
  209. package/tsconfig.json +13 -4
@@ -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'
@@ -13,11 +13,22 @@ import {
13
13
  CONNECTOR_PLATFORM_META,
14
14
  getSessionConnector,
15
15
  } from '@/components/shared/connector-platform-icon'
16
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
16
17
 
17
18
  function shortPath(p: string): string {
18
19
  return (p || '').replace(/^\/Users\/\w+/, '~')
19
20
  }
20
21
 
22
+ function formatDuration(sec: number): string {
23
+ if (sec >= 3600) {
24
+ const h = Math.floor(sec / 3600)
25
+ const m = Math.floor((sec % 3600) / 60)
26
+ return m > 0 ? `${h}h${m}m` : `${h}h`
27
+ }
28
+ if (sec >= 60) return `${Math.floor(sec / 60)}m`
29
+ return `${sec}s`
30
+ }
31
+
21
32
  const PROVIDER_LABELS: Record<string, string> = {
22
33
  'claude-cli': 'CLI',
23
34
  openai: 'OpenAI',
@@ -34,14 +45,20 @@ interface Props {
34
45
  mobile?: boolean
35
46
  browserActive?: boolean
36
47
  onStopBrowser?: () => void
48
+ onVoiceToggle?: () => void
49
+ voiceActive?: boolean
50
+ voiceSupported?: boolean
37
51
  }
38
52
 
39
- export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser }: Props) {
53
+ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported }: Props) {
40
54
  const ttsEnabled = useChatStore((s) => s.ttsEnabled)
41
55
  const toggleTts = useChatStore((s) => s.toggleTts)
56
+ const soundEnabled = useChatStore((s) => s.soundEnabled)
57
+ const toggleSound = useChatStore((s) => s.toggleSound)
42
58
  const debugOpen = useChatStore((s) => s.debugOpen)
43
59
  const setDebugOpen = useChatStore((s) => s.setDebugOpen)
44
60
  const lastUsage = useChatStore((s) => s.lastUsage)
61
+ const agentStatus = useChatStore((s) => s.agentStatus)
45
62
  const agents = useAppStore((s) => s.agents)
46
63
  const tasks = useAppStore((s) => s.tasks)
47
64
  const setActiveView = useAppStore((s) => s.setActiveView)
@@ -49,18 +66,26 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
49
66
  const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
50
67
  const appSettings = useAppStore((s) => s.appSettings)
51
68
  const loadSessions = useAppStore((s) => s.loadSessions)
69
+ const loadAgents = useAppStore((s) => s.loadAgents)
70
+ const inspectorOpen = useAppStore((s) => s.inspectorOpen)
71
+ const setInspectorOpen = useAppStore((s) => s.setInspectorOpen)
52
72
  const connectors = useAppStore((s) => s.connectors)
53
73
  const loadConnectors = useAppStore((s) => s.loadConnectors)
54
74
  const providerLabel = PROVIDER_LABELS[session.provider] || session.provider
55
75
  const agent = session.agentId ? agents[session.agentId] : null
56
76
  const connector = getSessionConnector(session, connectors)
57
77
  const connectorMeta = connector ? CONNECTOR_PLATFORM_META[connector.platform] : null
78
+ const connectorPresence = connector?.presence
58
79
  const modelName = session.model || agent?.model || ''
59
80
  const [copied, setCopied] = useState(false)
60
81
  const [heartbeatSaving, setHeartbeatSaving] = useState(false)
82
+ const [hbDropdownOpen, setHbDropdownOpen] = useState(false)
83
+ const hbDropdownRef = useRef<HTMLDivElement>(null)
61
84
  const [mainLoopSaving, setMainLoopSaving] = useState(false)
62
85
  const [mainLoopError, setMainLoopError] = useState('')
63
86
  const [mainLoopNotice, setMainLoopNotice] = useState('')
87
+ const [syncingHistory, setSyncingHistory] = useState(false)
88
+ const [syncResult, setSyncResult] = useState('')
64
89
 
65
90
  // Find linked task for this session
66
91
  const linkedTask = useMemo(() => {
@@ -102,11 +127,53 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
102
127
  setTimeout(() => setCopied(false), 2000)
103
128
  }
104
129
 
105
- const heartbeatEnabled = session.heartbeatEnabled !== false
106
130
  const heartbeatSupported = (session.tools?.length ?? 0) > 0
107
131
  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
132
+ const { heartbeatEnabled, heartbeatIntervalSec, heartbeatExplicitOptIn } = useMemo(() => {
133
+ // Resolve through the same cascade as the backend: settings → agent → session
134
+ const parseDur = (v: unknown): number | null => {
135
+ if (v === null || v === undefined) return null
136
+ if (typeof v === 'number') return Number.isFinite(v) ? Math.max(0, Math.min(86400, Math.trunc(v))) : null
137
+ if (typeof v !== 'string') return null
138
+ const t = v.trim().toLowerCase()
139
+ if (!t) return null
140
+ const n = Number(t)
141
+ if (Number.isFinite(n)) return Math.max(0, Math.min(86400, Math.trunc(n)))
142
+ const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/)
143
+ if (!m || (!m[1] && !m[2] && !m[3])) return null
144
+ const total = (m[1] ? parseInt(m[1]) * 3600 : 0) + (m[2] ? parseInt(m[2]) * 60 : 0) + (m[3] ? parseInt(m[3]) : 0)
145
+ return Math.max(0, Math.min(86400, total))
146
+ }
147
+ const resolveFrom = (obj: { heartbeatInterval?: string | number | null; heartbeatIntervalSec?: number | null }): number | null => {
148
+ const dur = parseDur(obj.heartbeatInterval)
149
+ if (dur !== null) return dur
150
+ const sec = parseDur(obj.heartbeatIntervalSec)
151
+ if (sec !== null) return sec
152
+ return null
153
+ }
154
+ // Global defaults
155
+ let sec = resolveFrom(appSettings) ?? 1800
156
+ let enabled = sec > 0
157
+ let explicitOptIn = false
158
+ // Agent layer
159
+ if (agent) {
160
+ if (agent.heartbeatEnabled === false) enabled = false
161
+ if (agent.heartbeatEnabled === true) { enabled = true; explicitOptIn = true }
162
+ sec = resolveFrom(agent) ?? sec
163
+ }
164
+ // Session layer — only applies for non-agent chats (agent chats save directly to agent)
165
+ if (!agent) {
166
+ if (session.heartbeatEnabled === false) enabled = false
167
+ if (session.heartbeatEnabled === true) { enabled = true; explicitOptIn = true }
168
+ sec = resolveFrom(session) ?? sec
169
+ }
170
+ return {
171
+ heartbeatEnabled: enabled && sec > 0,
172
+ heartbeatIntervalSec: sec,
173
+ heartbeatExplicitOptIn: explicitOptIn,
174
+ }
175
+ }, [appSettings, agent, session])
176
+ const heartbeatWillRun = heartbeatEnabled && (loopIsOngoing || heartbeatExplicitOptIn)
110
177
  const isMainSession = session.name === '__main__'
111
178
  const missionState = session.mainLoopState || {}
112
179
  const missionPaused = missionState.paused === true
@@ -119,23 +186,40 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
119
186
  if (!heartbeatSupported || heartbeatSaving) return
120
187
  setHeartbeatSaving(true)
121
188
  try {
122
- await api('PUT', `/sessions/${session.id}`, { heartbeatEnabled: !heartbeatEnabled })
123
- await loadSessions()
189
+ const next = !heartbeatEnabled
190
+ if (session.agentId) {
191
+ await api('PUT', `/agents/${session.agentId}`, { heartbeatEnabled: next })
192
+ // Clear any stale session-level override so the agent value wins
193
+ await api('PUT', `/sessions/${session.id}`, { heartbeatEnabled: null })
194
+ await Promise.all([loadAgents(), loadSessions()])
195
+ } else {
196
+ await api('PUT', `/sessions/${session.id}`, { heartbeatEnabled: next })
197
+ await loadSessions()
198
+ }
124
199
  } finally {
125
200
  setHeartbeatSaving(false)
126
201
  }
127
202
  }
128
203
 
129
- const handleCycleHeartbeatInterval = async () => {
204
+ const handleSelectHeartbeatInterval = async (sec: number) => {
130
205
  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]
206
+ setHbDropdownOpen(false)
135
207
  setHeartbeatSaving(true)
136
208
  try {
137
- await api('PUT', `/sessions/${session.id}`, { heartbeatIntervalSec: next, heartbeatEnabled: true })
138
- await loadSessions()
209
+ if (session.agentId) {
210
+ // Save to agent with both formats so the cascade resolves correctly
211
+ await api('PUT', `/agents/${session.agentId}`, {
212
+ heartbeatInterval: formatDuration(sec),
213
+ heartbeatIntervalSec: sec,
214
+ heartbeatEnabled: true,
215
+ })
216
+ // Clear stale session-level overrides
217
+ await api('PUT', `/sessions/${session.id}`, { heartbeatIntervalSec: null, heartbeatEnabled: null })
218
+ await Promise.all([loadAgents(), loadSessions()])
219
+ } else {
220
+ await api('PUT', `/sessions/${session.id}`, { heartbeatIntervalSec: sec, heartbeatEnabled: true })
221
+ await loadSessions()
222
+ }
139
223
  } finally {
140
224
  setHeartbeatSaving(false)
141
225
  }
@@ -193,6 +277,52 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
193
277
  void postMainLoopAction('clear_events')
194
278
  }
195
279
 
280
+ const isOpenClawAgent = agent?.provider === 'openclaw'
281
+ // Derive OpenClaw session key: agent sessions use "agent:<name>:main" convention
282
+ const openclawSessionKey = isOpenClawAgent && agent
283
+ ? `agent:${agent.name.toLowerCase().replace(/\s+/g, '-')}:main`
284
+ : null
285
+
286
+ const handleSyncHistory = async () => {
287
+ if (!openclawSessionKey || syncingHistory) return
288
+ setSyncingHistory(true)
289
+ setSyncResult('')
290
+ try {
291
+ const preview = await api<{ sessionKey: string; epoch: number; messages: Array<{ role: string; content: string; ts: number }> }>(
292
+ 'GET', `/openclaw/history?sessionKey=${encodeURIComponent(openclawSessionKey)}`,
293
+ )
294
+ if (!preview?.messages?.length) {
295
+ setSyncResult('No new messages found.')
296
+ return
297
+ }
298
+ const result = await api<{ ok: boolean; merged: number }>(
299
+ 'POST', '/openclaw/history',
300
+ { sessionKey: openclawSessionKey, epoch: preview.epoch, localSessionId: session.id },
301
+ )
302
+ setSyncResult(result.merged > 0 ? `Synced ${result.merged} message${result.merged !== 1 ? 's' : ''}.` : 'Already up to date.')
303
+ if (result.merged > 0) await loadSessions()
304
+ } catch (err: unknown) {
305
+ setSyncResult(err instanceof Error ? err.message : 'Sync failed.')
306
+ } finally {
307
+ setSyncingHistory(false)
308
+ }
309
+ }
310
+
311
+ useEffect(() => {
312
+ if (!syncResult) return
313
+ const timer = setTimeout(() => setSyncResult(''), 3000)
314
+ return () => clearTimeout(timer)
315
+ }, [syncResult])
316
+
317
+ useEffect(() => {
318
+ if (!hbDropdownOpen) return
319
+ const handler = (e: MouseEvent) => {
320
+ if (hbDropdownRef.current && !hbDropdownRef.current.contains(e.target as Node)) setHbDropdownOpen(false)
321
+ }
322
+ document.addEventListener('mousedown', handler)
323
+ return () => document.removeEventListener('mousedown', handler)
324
+ }, [hbDropdownOpen])
325
+
196
326
  useEffect(() => {
197
327
  if (session.name.startsWith('connector:')) {
198
328
  void loadConnectors()
@@ -223,6 +353,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
223
353
  )}
224
354
  <div className="flex-1 min-w-0">
225
355
  <div className="flex items-center gap-2.5">
356
+ {agent && <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />}
226
357
  <span className="font-display text-[16px] font-600 block truncate tracking-[-0.02em]">{
227
358
  session.name === '__main__' ? 'Main Chat'
228
359
  : session.name.startsWith('agent-thread:') ? (agent?.name || session.name)
@@ -242,6 +373,27 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
242
373
  {connectorMeta.label}
243
374
  </span>
244
375
  )}
376
+ {connector && connectorPresence && (() => {
377
+ const lastAt = connectorPresence.lastMessageAt
378
+ if (!lastAt) return (
379
+ <span className="shrink-0 inline-flex items-center gap-1 text-[10px] text-text-3/50">
380
+ <span className="w-1.5 h-1.5 rounded-full bg-text-3/40" />
381
+ Inactive
382
+ </span>
383
+ )
384
+ const ago = Date.now() - lastAt
385
+ const isActive = ago < 5 * 60_000
386
+ const isRecent = ago < 30 * 60_000
387
+ const label = isActive ? 'Active' : isRecent ? `${Math.floor(ago / 60_000)}m ago` : 'Inactive'
388
+ const dotColor = isActive ? 'bg-emerald-400' : isRecent ? 'bg-amber-400' : 'bg-text-3/40'
389
+ const textColor = isActive ? 'text-emerald-400' : isRecent ? 'text-amber-300' : 'text-text-3/50'
390
+ return (
391
+ <span className={`shrink-0 inline-flex items-center gap-1 text-[10px] ${textColor}`}>
392
+ <span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
393
+ {label}
394
+ </span>
395
+ )
396
+ })()}
245
397
  {session.provider && session.provider !== 'claude-cli' && (
246
398
  <span className="shrink-0 px-2.5 py-0.5 rounded-[7px] bg-accent-soft text-accent-bright text-[10px] font-700 uppercase tracking-wider">
247
399
  {providerLabel}
@@ -267,6 +419,21 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
267
419
  <>
268
420
  <span className="text-[11px] text-text-3/60">·</span>
269
421
  <span className="text-[11px] text-text-3/50 font-mono truncate shrink-0">{modelName}</span>
422
+ {session.conversationTone && session.conversationTone !== 'neutral' && (() => {
423
+ const toneColors: Record<string, string> = {
424
+ formal: 'bg-[#3B82F6]',
425
+ casual: 'bg-emerald-400',
426
+ empathetic: 'bg-purple-400',
427
+ technical: 'bg-[#F59E0B]',
428
+ }
429
+ const color = toneColors[session.conversationTone] || ''
430
+ return color ? (
431
+ <span
432
+ className={`w-2 h-2 rounded-full shrink-0 ${color}`}
433
+ title={`Tone: ${session.conversationTone}`}
434
+ />
435
+ ) : null
436
+ })()}
270
437
  </>
271
438
  )}
272
439
  {lastUsage && !streaming && (
@@ -276,6 +443,50 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
276
443
  </>
277
444
  )}
278
445
  </div>
446
+ {(() => {
447
+ const liveStatus = agentStatus || (missionState.status ? {
448
+ goal: missionState.goal ?? undefined,
449
+ status: missionState.status ?? undefined,
450
+ summary: missionState.summary ?? undefined,
451
+ nextAction: missionState.nextAction ?? undefined,
452
+ } : null)
453
+ if (!liveStatus) return null
454
+ const statusColors: Record<string, string> = {
455
+ idle: 'bg-text-3/40',
456
+ progress: 'bg-[#3B82F6]',
457
+ blocked: 'bg-amber-400',
458
+ ok: 'bg-emerald-400',
459
+ }
460
+ const dotColor = statusColors[liveStatus.status || ''] || 'bg-text-3/40'
461
+ return (
462
+ <div className="flex items-center gap-2 mt-0.5">
463
+ {liveStatus.goal && (
464
+ <span className="text-[10px] text-text-3/60 font-mono truncate max-w-[240px]" title={liveStatus.goal}>
465
+ {liveStatus.goal}
466
+ </span>
467
+ )}
468
+ {liveStatus.status && (
469
+ <span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[5px] text-[9px] font-700 uppercase tracking-wider ${
470
+ liveStatus.status === 'blocked' ? 'bg-amber-400/15 text-amber-300'
471
+ : liveStatus.status === 'ok' ? 'bg-emerald-400/15 text-emerald-400'
472
+ : liveStatus.status === 'progress' ? 'bg-[#3B82F6]/15 text-[#60A5FA]'
473
+ : 'bg-white/[0.04] text-text-3/60'
474
+ }`}>
475
+ <span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
476
+ {liveStatus.status}
477
+ </span>
478
+ )}
479
+ {liveStatus.nextAction && (
480
+ <>
481
+ <span className="text-[10px] text-text-3/40">→</span>
482
+ <span className="text-[10px] text-text-3/50 font-mono truncate max-w-[200px]" title={liveStatus.nextAction}>
483
+ {liveStatus.nextAction}
484
+ </span>
485
+ </>
486
+ )}
487
+ </div>
488
+ )
489
+ })()}
279
490
  </div>
280
491
  <div className="flex gap-1.5">
281
492
  {streaming && (
@@ -285,6 +496,14 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
285
496
  </svg>
286
497
  </IconButton>
287
498
  )}
499
+ {agent && (
500
+ <IconButton onClick={() => setInspectorOpen(!inspectorOpen)} active={inspectorOpen} aria-label="Toggle inspector panel">
501
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
502
+ <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
503
+ <circle cx="12" cy="12" r="3" />
504
+ </svg>
505
+ </IconButton>
506
+ )}
288
507
  <IconButton onClick={() => setDebugOpen(!debugOpen)} active={debugOpen} aria-label="Toggle debug panel">
289
508
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
290
509
  <path d="M12 20V10" />
@@ -292,13 +511,28 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
292
511
  <path d="M6 20v-4" />
293
512
  </svg>
294
513
  </IconButton>
514
+ <IconButton onClick={toggleSound} active={soundEnabled} aria-label="Toggle sound notifications">
515
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
516
+ <path d="M18 8A6 6 0 0 1 18 16" />
517
+ <path d="M13 2L8 7H4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4l5 5V2z" />
518
+ </svg>
519
+ </IconButton>
295
520
  <IconButton onClick={toggleTts} active={ttsEnabled} aria-label="Toggle text-to-speech">
296
521
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
297
522
  <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
298
523
  <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
299
524
  </svg>
300
525
  </IconButton>
301
- <IconButton onClick={(e) => { e.stopPropagation(); onMenuToggle() }} aria-label="Session menu">
526
+ {voiceSupported && onVoiceToggle && (
527
+ <IconButton onClick={onVoiceToggle} active={voiceActive} aria-label="Toggle voice conversation">
528
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
529
+ <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
530
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
531
+ <line x1="12" x2="12" y1="19" y2="22" />
532
+ </svg>
533
+ </IconButton>
534
+ )}
535
+ <IconButton onClick={(e) => { e.stopPropagation(); onMenuToggle() }} aria-label="Chat menu">
302
536
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
303
537
  <circle cx="12" cy="6" r="1" />
304
538
  <circle cx="12" cy="12" r="1" />
@@ -320,25 +554,44 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
320
554
  onClick={handleToggleHeartbeat}
321
555
  disabled={heartbeatSaving}
322
556
  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'}
557
+ ${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'}`}
558
+ title={heartbeatWillRun ? 'Toggle heartbeat' : !heartbeatEnabled ? 'Heartbeat disabled — click to enable' : 'Heartbeat enabled but paused (bounded loop mode, no explicit opt-in)'}
325
559
  >
326
- <span className={`w-1.5 h-1.5 rounded-full ${heartbeatEnabled ? 'bg-emerald-400' : 'bg-text-3/40'}`} />
560
+ <span className={`w-1.5 h-1.5 rounded-full ${heartbeatWillRun ? 'bg-emerald-400' : 'bg-text-3/40'}`} />
327
561
  <span className="text-[11px] font-600">
328
- HB {heartbeatEnabled ? 'On' : 'Off'}
562
+ HB {heartbeatWillRun ? 'On' : 'Off'}
329
563
  </span>
330
- {!loopIsOngoing && (
564
+ {heartbeatEnabled && !loopIsOngoing && !heartbeatExplicitOptIn && (
331
565
  <span className="text-[10px] text-text-3/50">(bounded)</span>
332
566
  )}
333
567
  </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>
568
+ <div className="relative" ref={hbDropdownRef}>
569
+ <button
570
+ onClick={() => setHbDropdownOpen((o) => !o)}
571
+ disabled={heartbeatSaving}
572
+ 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"
573
+ title="Set heartbeat interval"
574
+ >
575
+ <span className="text-[11px] font-600">{formatDuration(heartbeatIntervalSec)}</span>
576
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-text-3/50">
577
+ <polyline points="6 9 12 15 18 9" />
578
+ </svg>
579
+ </button>
580
+ {hbDropdownOpen && (
581
+ <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]">
582
+ {[30, 60, 120, 300, 600, 1800, 3600].map((sec) => (
583
+ <button
584
+ key={sec}
585
+ onClick={() => handleSelectHeartbeatInterval(sec)}
586
+ className={`w-full text-left px-3 py-1.5 text-[11px] font-600 transition-colors cursor-pointer border-none
587
+ ${sec === heartbeatIntervalSec ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:bg-white/[0.06]'}`}
588
+ >
589
+ {formatDuration(sec)}
590
+ </button>
591
+ ))}
592
+ </div>
593
+ )}
594
+ </div>
342
595
  </>
343
596
  )}
344
597
  {isMainSession && (
@@ -427,6 +680,29 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
427
680
  </span>
428
681
  </button>
429
682
  )}
683
+ {isOpenClawAgent && openclawSessionKey && (
684
+ <>
685
+ <button
686
+ onClick={handleSyncHistory}
687
+ disabled={syncingHistory}
688
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-indigo-500/10 hover:bg-indigo-500/15 transition-colors cursor-pointer border-none disabled:opacity-50"
689
+ title="Sync chat history from OpenClaw gateway"
690
+ >
691
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-indigo-400">
692
+ <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
693
+ <path d="M3 3v5h5" />
694
+ <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
695
+ <path d="M16 16h5v5" />
696
+ </svg>
697
+ <span className="text-[11px] font-600 text-indigo-400">
698
+ {syncingHistory ? 'Syncing...' : 'Sync History'}
699
+ </span>
700
+ </button>
701
+ {syncResult && (
702
+ <span className="text-[10px] text-emerald-300/90">{syncResult}</span>
703
+ )}
704
+ </>
705
+ )}
430
706
  {linkedTask && (
431
707
  <button
432
708
  onClick={() => setActiveView('tasks')}
@@ -467,7 +743,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
467
743
  <button
468
744
  onClick={onStopBrowser}
469
745
  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"
746
+ title="Stop browser"
471
747
  >
472
748
  <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
749
  <rect x="3" y="3" width="18" height="14" rx="2" />
@@ -0,0 +1,113 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef, useState } from 'react'
4
+ import { CodeBlock } from './code-block'
5
+
6
+ interface PreviewContent {
7
+ type: 'browser' | 'image' | 'code' | 'html'
8
+ url?: string
9
+ content?: string
10
+ title?: string
11
+ }
12
+
13
+ interface Props {
14
+ content: PreviewContent
15
+ onClose: () => void
16
+ }
17
+
18
+ export function ChatPreviewPanel({ content, onClose }: Props) {
19
+ const [width, setWidth] = useState(400)
20
+ const dragging = useRef(false)
21
+ const startX = useRef(0)
22
+ const startWidth = useRef(400)
23
+
24
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
25
+ e.preventDefault()
26
+ dragging.current = true
27
+ startX.current = e.clientX
28
+ startWidth.current = width
29
+
30
+ const handleMouseMove = (ev: MouseEvent) => {
31
+ if (!dragging.current) return
32
+ const diff = startX.current - ev.clientX
33
+ const next = Math.max(300, Math.min(window.innerWidth * 0.5, startWidth.current + diff))
34
+ setWidth(next)
35
+ }
36
+
37
+ const handleMouseUp = () => {
38
+ dragging.current = false
39
+ document.removeEventListener('mousemove', handleMouseMove)
40
+ document.removeEventListener('mouseup', handleMouseUp)
41
+ }
42
+
43
+ document.addEventListener('mousemove', handleMouseMove)
44
+ document.addEventListener('mouseup', handleMouseUp)
45
+ }, [width])
46
+
47
+ return (
48
+ <div
49
+ className="flex flex-col border-l border-white/[0.06] bg-bg shrink-0"
50
+ style={{ width, minWidth: 300, maxWidth: '50%', animation: 'fade-in 0.25s ease' }}
51
+ >
52
+ {/* Resize handle */}
53
+ <div
54
+ className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-accent-bright/20 transition-colors z-10"
55
+ style={{ position: 'relative', width: 4, minWidth: 4 }}
56
+ onMouseDown={handleMouseDown}
57
+ />
58
+
59
+ {/* Header */}
60
+ <div className="flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.06] shrink-0">
61
+ <span className="text-[12px] font-600 text-text-2 truncate flex-1">
62
+ {content.title || 'Preview'}
63
+ </span>
64
+ <button
65
+ onClick={onClose}
66
+ className="p-1 rounded-[6px] text-text-3 hover:text-text-2 hover:bg-white/[0.04] cursor-pointer border-none bg-transparent transition-colors"
67
+ aria-label="Close preview"
68
+ >
69
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
70
+ <line x1="18" y1="6" x2="6" y2="18" />
71
+ <line x1="6" y1="6" x2="18" y2="18" />
72
+ </svg>
73
+ </button>
74
+ </div>
75
+
76
+ {/* Content */}
77
+ <div className="flex-1 overflow-auto min-h-0">
78
+ {content.type === 'browser' && content.url && (
79
+ <iframe
80
+ src={content.url}
81
+ className="w-full h-full border-none"
82
+ title={content.title || 'Browser Preview'}
83
+ sandbox="allow-scripts allow-same-origin"
84
+ />
85
+ )}
86
+ {content.type === 'html' && content.content && (
87
+ <iframe
88
+ srcDoc={content.content}
89
+ className="w-full h-full border-none"
90
+ title={content.title || 'HTML Preview'}
91
+ sandbox="allow-scripts"
92
+ />
93
+ )}
94
+ {content.type === 'image' && content.url && (
95
+ <div className="p-4 flex items-center justify-center h-full">
96
+ <img
97
+ src={content.url}
98
+ alt={content.title || 'Preview'}
99
+ className="max-w-full max-h-full rounded-[8px] object-contain"
100
+ />
101
+ </div>
102
+ )}
103
+ {content.type === 'code' && content.content && (
104
+ <div className="p-2">
105
+ <CodeBlock className={`language-${content.title?.split('.').pop() || 'text'}`}>
106
+ {content.content}
107
+ </CodeBlock>
108
+ </div>
109
+ )}
110
+ </div>
111
+ </div>
112
+ )
113
+ }