@swarmclawai/swarmclaw 0.7.7 → 0.8.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 (281) hide show
  1. package/README.md +12 -14
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -17,8 +17,9 @@ import { isStructuredMarkdown } from './markdown-utils'
17
17
  import { FilePathChip, FILE_PATH_RE, DIR_PATH_RE } from './file-path-chip'
18
18
  import { TransferAgentPicker } from './transfer-agent-picker'
19
19
  import { DelegationBanner, DelegationSourceBanner, TaskCompletionCard, parseTaskCompletion } from './delegation-banner'
20
- import { ConnectorPlatformIcon, CONNECTOR_PLATFORM_META } from '@/components/shared/connector-platform-icon'
20
+ import { ConnectorPlatformIcon, getConnectorPlatformLabel } from '@/components/shared/connector-platform-icon'
21
21
  import { copyTextToClipboard } from '@/lib/clipboard'
22
+ import { formatMessageTimestamp } from '@/lib/chat-display'
22
23
 
23
24
  /** Parse delegation-source metadata prefix from system messages */
24
25
  const DELEGATION_SOURCE_RE = /^\[delegation-source:([^:]*):([^:]*):([^\]]*)\]/
@@ -33,21 +34,16 @@ function tryParseJson(s: string): Record<string, unknown> | null {
33
34
  try { return JSON.parse(s) } catch { return null }
34
35
  }
35
36
 
36
- function fmtTime(ts: number): string {
37
- return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
38
- }
39
-
40
- function relativeTime(ts: number): string {
41
- const now = Date.now()
42
- const diff = now - ts
43
- if (diff < 60_000) return 'just now'
44
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
45
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
46
- const d = new Date(ts)
47
- const today = new Date()
48
- if (d.toDateString() === today.toDateString()) return fmtTime(ts)
49
- if (diff < 604_800_000) return d.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' })
50
- return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
37
+ function connectorThreadMeta(message: Message, isUser: boolean): string | null {
38
+ const source = message.source
39
+ if (!source) return null
40
+ const connectorName = source.connectorName?.trim() || getConnectorPlatformLabel(source.platform)
41
+ if (isUser) {
42
+ const sender = source.senderName?.trim() || source.senderId?.trim() || source.channelId?.trim()
43
+ return sender ? `${connectorName} · ${sender}` : connectorName
44
+ }
45
+ const recipient = source.senderName?.trim() || source.senderId?.trim() || source.channelId?.trim()
46
+ return recipient ? `${connectorName} · to ${recipient}` : connectorName
51
47
  }
52
48
 
53
49
  interface HeartbeatMeta {
@@ -92,11 +88,7 @@ const STATUS_COLORS: Record<string, string> = {
92
88
  blocked: '#EF4444',
93
89
  }
94
90
 
95
- function isGeneratedBrowserScreenshot(url: string): boolean {
96
- const match = url.match(/\/api\/uploads\/([^/?#]+)/)
97
- if (!match?.[1]) return false
98
- return /^(browser|screenshot)-\d+\./i.test(match[1])
99
- }
91
+ const emptyToolEvents: NonNullable<Message['toolEvents']> = []
100
92
 
101
93
  // AttachmentChip, parseAttachmentUrl, regex constants, and FILE_TYPE_COLORS
102
94
  // are now imported from @/components/shared/attachment-chip
@@ -180,6 +172,15 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
180
172
  } catch { /* ignore */ }
181
173
  return null
182
174
  }, [message.text, isUser])
175
+
176
+ const walletActionRequest = useMemo(() => {
177
+ if (isUser) return null
178
+ try {
179
+ const data = JSON.parse(message.text)
180
+ if (data.type === 'plugin_wallet_action_request') return data
181
+ } catch { /* ignore */ }
182
+ return null
183
+ }, [message.text, isUser])
183
184
  const currentUser = useAppStore((s) => s.currentUser)
184
185
  const [copied, setCopied] = useState(false)
185
186
  const [heartbeatExpanded, setHeartbeatExpanded] = useState(false)
@@ -187,43 +188,48 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
187
188
  const [editing, setEditing] = useState(false)
188
189
  const [editText, setEditText] = useState('')
189
190
  const [transferPickerOpen, setTransferPickerOpen] = useState(false)
190
- const toolEvents = message.toolEvents || []
191
- const hasToolEvents = !isUser && toolEvents.length > 0
192
- const visibleToolEvents = toolEventsExpanded ? [...toolEvents].reverse() : toolEvents.slice(-1)
193
- const isStructured = !isUser && !isHeartbeat && isStructuredMarkdown(message.text)
191
+ const toolEvents = message.toolEvents ?? emptyToolEvents
192
+ // Separate send_file events they render as inline attachments, not in the tool accordion
193
+ const nonSendFileEvents = useMemo(() => toolEvents.filter((ev) => ev.name !== 'send_file' || ev.error), [toolEvents])
194
+ const hasToolEvents = !isUser && nonSendFileEvents.length > 0
195
+ const visibleToolEvents = toolEventsExpanded ? [...nonSendFileEvents].reverse() : nonSendFileEvents.slice(-1)
194
196
 
195
- // When collapsed, collect media from hidden tool events so files are always visible
196
- const hiddenMedia = useMemo(() => {
197
- if (toolEventsExpanded || toolEvents.length <= 1) return null
198
- // Collect URLs from the visible (last) tool event to avoid showing duplicates
199
- const lastOutput = toolEvents[toolEvents.length - 1]?.output || ''
200
- const visibleMedia = extractMedia(lastOutput)
201
- const hasNamedVisibleImage = visibleMedia.images.some((url) => !isGeneratedBrowserScreenshot(url))
202
- const seen = new Set<string>([
203
- ...visibleMedia.images,
204
- ...visibleMedia.videos,
205
- ...visibleMedia.pdfs.map((p) => p.url),
206
- ...visibleMedia.files.map((f) => f.url),
207
- ])
208
- const images: string[] = []
209
- const videos: string[] = []
197
+ // Extract ALL media from ALL tool events for inline display after the message text.
198
+ // Covers send_file, browser screenshots, file tool outputs — everything.
199
+ const allToolMedia = useMemo(() => {
200
+ const images: { name: string; url: string }[] = []
201
+ const videos: { name: string; url: string }[] = []
210
202
  const pdfs: { name: string; url: string }[] = []
211
203
  const files: { name: string; url: string }[] = []
212
- for (const ev of toolEvents.slice(0, -1)) {
213
- if (!ev.output) continue
204
+ const seen = new Set<string>()
205
+
206
+ for (const ev of toolEvents) {
207
+ if (ev.error || !ev.output) continue
214
208
  const m = extractMedia(ev.output)
215
209
  for (const url of m.images) {
216
- if (hasNamedVisibleImage && isGeneratedBrowserScreenshot(url)) continue
217
- if (!seen.has(url)) { seen.add(url); images.push(url) }
210
+ if (!seen.has(url)) { seen.add(url); images.push({ name: url.split('/').pop() || 'Image', url }) }
211
+ }
212
+ for (const url of m.videos) {
213
+ if (!seen.has(url)) { seen.add(url); videos.push({ name: url.split('/').pop() || 'Video', url }) }
214
+ }
215
+ for (const p of m.pdfs) {
216
+ if (!seen.has(p.url)) { seen.add(p.url); pdfs.push(p) }
217
+ }
218
+ for (const f of m.files) {
219
+ // Reclassify image-extension files as images (send_file uses [label](url) not ![](url))
220
+ if (/\.(png|jpe?g|gif|webp|svg|avif)$/i.test(f.url)) {
221
+ if (!seen.has(f.url)) { seen.add(f.url); images.push(f) }
222
+ } else {
223
+ if (!seen.has(f.url)) { seen.add(f.url); files.push(f) }
224
+ }
218
225
  }
219
- for (const url of m.videos) { if (!seen.has(url)) { seen.add(url); videos.push(url) } }
220
- for (const p of m.pdfs) { if (!seen.has(p.url)) { seen.add(p.url); pdfs.push(p) } }
221
- for (const f of m.files) { if (!seen.has(f.url)) { seen.add(f.url); files.push(f) } }
222
226
  }
227
+
223
228
  if (!images.length && !videos.length && !pdfs.length && !files.length) return null
224
229
  return { images, videos, pdfs, files }
225
- // eslint-disable-next-line react-hooks/exhaustive-deps
226
- }, [message.toolEvents, toolEventsExpanded])
230
+ // eslint-disable-next-line react-hooks/exhaustive-deps
231
+ }, [message.toolEvents])
232
+ const isStructured = !isUser && !isHeartbeat && isStructuredMarkdown(message.text)
227
233
 
228
234
  // Collect all media URLs already rendered via tool events to avoid duplicates in markdown
229
235
  const toolEventMediaUrls = useMemo(() => {
@@ -256,6 +262,8 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
256
262
  })
257
263
  }, [message.text])
258
264
 
265
+ const connectorMeta = connectorThreadMeta(message, isUser)
266
+
259
267
  return (
260
268
  <div
261
269
  className={`group ${isUser ? 'flex flex-col items-end' : 'flex flex-col items-start relative pl-[44px]'}`}
@@ -270,37 +278,46 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
270
278
  </div>
271
279
  )}
272
280
  {/* Sender label + timestamp */}
273
- <div className={`flex items-center gap-2.5 mb-2 px-1 ${isUser ? 'flex-row-reverse' : ''}`}>
274
- <span className={`text-[12px] font-600 flex items-center gap-1.5 ${isUser ? 'text-accent-bright/70' : 'text-text-3'}`}>
275
- {message.source && (
276
- <ConnectorPlatformIcon platform={message.source.platform} size={12} />
277
- )}
278
- {isUser
279
- ? (message.source?.senderName
280
- ? `${message.source.senderName} via ${CONNECTOR_PLATFORM_META[message.source.platform]?.label || message.source.platform}`
281
- : (currentUser ? currentUser.charAt(0).toUpperCase() + currentUser.slice(1) : 'You'))
282
- : (assistantName || 'Claude')}
283
- </span>
284
- <span className="text-[11px] text-text-3/70 font-mono" title={message.time ? new Date(message.time).toLocaleString() : ''}>
285
- {message.time ? relativeTime(message.time) : ''}
286
- </span>
281
+ <div className={`flex flex-col gap-0.5 mb-2 px-1 ${isUser ? 'items-end' : 'items-start'}`}>
282
+ <div className={`flex items-center gap-2.5 ${isUser ? 'flex-row-reverse' : ''}`}>
283
+ <span className={`text-[12px] font-600 flex items-center gap-1.5 ${isUser ? 'text-accent-bright/70' : 'text-text-3'}`}>
284
+ {message.source && (
285
+ <ConnectorPlatformIcon platform={message.source.platform} size={12} />
286
+ )}
287
+ {isUser
288
+ ? (message.source?.senderName
289
+ ? `${message.source.senderName} via ${getConnectorPlatformLabel(message.source.platform)}`
290
+ : (currentUser ? currentUser.charAt(0).toUpperCase() + currentUser.slice(1) : 'You'))
291
+ : (message.source
292
+ ? `${assistantName || 'Claude'} via ${getConnectorPlatformLabel(message.source.platform)}`
293
+ : (assistantName || 'Claude'))}
294
+ </span>
295
+ <span className="text-[11px] text-text-3/70 font-mono" title={message.time ? new Date(message.time).toLocaleString() : ''}>
296
+ {message.time ? formatMessageTimestamp(message) : ''}
297
+ </span>
298
+ </div>
299
+ {connectorMeta && (
300
+ <div className={`text-[10px] font-mono text-text-3/55 ${isUser ? 'text-right' : ''}`}>
301
+ {connectorMeta}
302
+ </div>
303
+ )}
287
304
  </div>
288
305
 
289
306
  {/* Tool call events (assistant messages only) */}
290
307
  {hasToolEvents && (
291
308
  <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
292
- {toolEvents.length > 1 && (
309
+ {nonSendFileEvents.length > 1 && (
293
310
  <button
294
311
  type="button"
295
312
  onClick={() => setToolEventsExpanded((v) => !v)}
296
313
  className="self-start px-2.5 py-1 rounded-[8px] bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-text-3 border border-white/[0.06] cursor-pointer transition-colors"
297
314
  >
298
- {toolEventsExpanded ? 'Show latest only' : `Show all tool calls (${toolEvents.length})`}
315
+ {toolEventsExpanded ? 'Show latest only' : `Show all tool calls (${nonSendFileEvents.length})`}
299
316
  </button>
300
317
  )}
301
318
  <div className={`${toolEventsExpanded ? 'max-h-[320px] overflow-y-auto pr-1 flex flex-col gap-2' : 'flex flex-col gap-2'}`}>
302
319
  {visibleToolEvents.map((event, i) => {
303
- const key = `${message.time}-tool-${toolEventsExpanded ? `all-${i}` : `latest-${toolEvents.length - 1}`}`
320
+ const key = `${message.time}-tool-${toolEventsExpanded ? `all-${i}` : `latest-${nonSendFileEvents.length - 1}`}`
304
321
 
305
322
  if (event.name === 'delegate_to_agent') {
306
323
  const inp = tryParseJson(event.input || '{}')
@@ -352,83 +369,6 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
352
369
  </div>
353
370
  )}
354
371
 
355
- {/* Media from hidden tool calls (shown when collapsed so files are never buried) */}
356
- {hiddenMedia && (
357
- <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
358
- {hiddenMedia.images.map((src, i) => (
359
- <div key={`himg-${i}`} className="relative group/img">
360
- {/* eslint-disable-next-line @next/next/no-img-element */}
361
- <img
362
- src={src}
363
- alt={`Screenshot ${i + 1}`}
364
- loading="lazy"
365
- className="max-w-[400px] rounded-[10px] border border-white/10 cursor-pointer hover:border-white/25 transition-all"
366
- onClick={() => {
367
- import('@/stores/use-chat-store').then(({ useChatStore }) =>
368
- useChatStore.getState().setPreviewContent({ type: 'image', url: src, title: `Screenshot ${i + 1}` })
369
- )
370
- }}
371
- onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
372
- />
373
- <a
374
- href={src}
375
- download
376
- onClick={(e) => e.stopPropagation()}
377
- className="absolute top-2 right-2 bg-black/60 backdrop-blur-sm rounded-[8px] p-1.5 hover:bg-black/80 opacity-0 group-hover/img:opacity-100 transition-opacity"
378
- title="Download"
379
- >
380
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round">
381
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
382
- <polyline points="7 10 12 15 17 10" />
383
- <line x1="12" y1="15" x2="12" y2="3" />
384
- </svg>
385
- </a>
386
- </div>
387
- ))}
388
- {hiddenMedia.videos.map((src, i) => (
389
- <video key={`hvid-${i}`} src={src} controls playsInline preload="none" className="max-w-full rounded-[10px] border border-white/10" />
390
- ))}
391
- {hiddenMedia.pdfs.map((file, i) => (
392
- <div key={`hpdf-${i}`} className="rounded-[10px] border border-white/10 overflow-hidden">
393
- <iframe src={file.url} loading="lazy" className="w-full h-[400px] bg-white" title={file.name} />
394
- <a
395
- href={file.url}
396
- download
397
- onClick={(e) => e.stopPropagation()}
398
- className="flex items-center gap-2 px-3 py-2 bg-surface/80 border-t border-white/10 text-[12px] text-text-2 hover:text-text no-underline transition-colors"
399
- >
400
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
401
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
402
- <polyline points="7 10 12 15 17 10" />
403
- <line x1="12" y1="15" x2="12" y2="3" />
404
- </svg>
405
- {file.name}
406
- </a>
407
- </div>
408
- ))}
409
- {hiddenMedia.files.map((file, i) => (
410
- <a
411
- key={`hfile-${i}`}
412
- href={file.url}
413
- download
414
- onClick={(e) => e.stopPropagation()}
415
- className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/10 bg-surface/60 hover:bg-surface-2 transition-colors text-[13px] text-text-2 hover:text-text no-underline"
416
- >
417
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
418
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
419
- <polyline points="14 2 14 8 20 8" />
420
- </svg>
421
- {file.name}
422
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="ml-auto opacity-50">
423
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
424
- <polyline points="7 10 12 15 17 10" />
425
- <line x1="12" y1="15" x2="12" y2="3" />
426
- </svg>
427
- </a>
428
- ))}
429
- </div>
430
- )}
431
-
432
372
  {/* Thinking block (collapsible, shown for assistant messages with persisted thinking) */}
433
373
  {!isUser && message.thinking && (
434
374
  <div className="max-w-[85%] md:max-w-[72%] mb-2">
@@ -493,7 +433,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
493
433
  <div className="p-3 rounded-[12px] bg-black/40 border border-white/5 flex flex-col gap-2">
494
434
  <div className="flex justify-between items-center">
495
435
  <span className="text-[11px] text-text-3/60 font-600 uppercase">Amount</span>
496
- <span className="text-[13px] font-700 text-sky-400">{walletRequest.amountSol} SOL</span>
436
+ <span className="text-[13px] font-700 text-sky-400">{walletRequest.amountDisplay || `${walletRequest.amountSol} SOL`}</span>
497
437
  </div>
498
438
  <div className="flex flex-col gap-1">
499
439
  <span className="text-[11px] text-text-3/60 font-600 uppercase">To Address</span>
@@ -508,7 +448,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
508
448
  </div>
509
449
  <div className="flex gap-2 mt-1">
510
450
  <button
511
- onClick={() => useChatStore.getState().sendMessage(`I approve this transfer of ${walletRequest.amountSol} SOL to ${walletRequest.toAddress}. Proceed with wallet_tool and set approved=true.`)}
451
+ onClick={() => useChatStore.getState().sendMessage(`I approve this transfer of ${walletRequest.amountDisplay || `${walletRequest.amountSol} SOL`} to ${walletRequest.toAddress}. Proceed with wallet_tool and set approved=true.`)}
512
452
  className="px-4 py-2 rounded-[12px] bg-sky-500 text-black text-[13px] font-700 hover:bg-sky-400 transition-all active:scale-[0.98]"
513
453
  >
514
454
  Approve & Send
@@ -521,6 +461,52 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
521
461
  </button>
522
462
  </div>
523
463
  </div>
464
+ ) : walletActionRequest ? (
465
+ <div className="flex flex-col gap-3 p-4 rounded-[18px] bg-violet-500/[0.03] border border-violet-500/20 shadow-[0_0_20px_rgba(139,92,246,0.05)]">
466
+ <div className="flex items-center gap-2 mb-1">
467
+ <div className="w-5 h-5 rounded-full bg-violet-500/20 flex items-center justify-center text-violet-400">
468
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
469
+ <path d="M12 2v8" />
470
+ <path d="M8 6h8" />
471
+ <path d="m5 19 4-4 3 3 7-7" />
472
+ </svg>
473
+ </div>
474
+ <span className="text-[11px] font-700 uppercase tracking-wider text-violet-400/80">Wallet Action Request</span>
475
+ </div>
476
+ <p className="text-[13px] text-text-2/90 leading-relaxed">{walletActionRequest.message}</p>
477
+ <div className="p-3 rounded-[12px] bg-black/40 border border-white/5 flex flex-col gap-2">
478
+ <div className="flex justify-between items-center gap-3">
479
+ <span className="text-[11px] text-text-3/60 font-600 uppercase">Action</span>
480
+ <span className="text-[13px] font-700 text-violet-400">{walletActionRequest.action || 'wallet_action'}</span>
481
+ </div>
482
+ {(walletActionRequest.chain || walletActionRequest.network) && (
483
+ <div className="flex justify-between items-center gap-3">
484
+ <span className="text-[11px] text-text-3/60 font-600 uppercase">Chain</span>
485
+ <span className="text-[12px] text-text-2/80">{[walletActionRequest.chain, walletActionRequest.network].filter(Boolean).join(' / ')}</span>
486
+ </div>
487
+ )}
488
+ {walletActionRequest.summary && (
489
+ <div className="flex flex-col gap-1 border-t border-white/5 pt-2">
490
+ <span className="text-[11px] text-text-3/60 font-600 uppercase">Summary</span>
491
+ <span className="text-[12px] text-text-2/80 whitespace-pre-wrap break-words">{walletActionRequest.summary}</span>
492
+ </div>
493
+ )}
494
+ </div>
495
+ <div className="flex gap-2 mt-1">
496
+ <button
497
+ onClick={() => useChatStore.getState().sendMessage(`I approve this wallet action (${walletActionRequest.action || 'wallet_action'}). Proceed with wallet_tool and set approved=true.`)}
498
+ className="px-4 py-2 rounded-[12px] bg-violet-500 text-black text-[13px] font-700 hover:bg-violet-400 transition-all active:scale-[0.98]"
499
+ >
500
+ Approve Action
501
+ </button>
502
+ <button
503
+ onClick={() => useChatStore.getState().sendMessage('I do not approve this wallet action. Cancel it.')}
504
+ className="px-4 py-2 rounded-[12px] bg-white/[0.05] hover:bg-white/[0.1] text-text-2 text-[13px] font-600 transition-all border border-white/10"
505
+ >
506
+ Reject
507
+ </button>
508
+ </div>
509
+ </div>
524
510
  ) : installRequest ? (
525
511
  <div className="flex flex-col gap-3 p-4 rounded-[18px] bg-emerald-500/[0.03] border border-emerald-500/20 shadow-[0_0_20px_rgba(16,185,129,0.05)]">
526
512
  <div className="flex items-center gap-2 mb-1">
@@ -806,6 +792,83 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
806
792
  </div>
807
793
  )}
808
794
 
795
+ {/* Inline media from all tool outputs — images, videos, PDFs, files */}
796
+ {allToolMedia && (
797
+ <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mt-1 mb-2">
798
+ {allToolMedia.images.map((img, i) => (
799
+ <div key={`tm-img-${i}`} className="relative group/img">
800
+ {/* eslint-disable-next-line @next/next/no-img-element */}
801
+ <img
802
+ src={img.url}
803
+ alt={img.name}
804
+ loading="lazy"
805
+ className="max-w-[400px] rounded-[10px] border border-white/10 cursor-pointer hover:border-white/25 transition-all"
806
+ onClick={() => {
807
+ import('@/stores/use-chat-store').then(({ useChatStore }) =>
808
+ useChatStore.getState().setPreviewContent({ type: 'image', url: img.url, title: img.name })
809
+ )
810
+ }}
811
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
812
+ />
813
+ <a
814
+ href={img.url}
815
+ download
816
+ onClick={(e) => e.stopPropagation()}
817
+ className="absolute top-2 right-2 bg-black/60 backdrop-blur-sm rounded-[8px] p-1.5 hover:bg-black/80 opacity-0 group-hover/img:opacity-100 transition-opacity"
818
+ title="Download"
819
+ >
820
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round">
821
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
822
+ <polyline points="7 10 12 15 17 10" />
823
+ <line x1="12" y1="15" x2="12" y2="3" />
824
+ </svg>
825
+ </a>
826
+ </div>
827
+ ))}
828
+ {allToolMedia.videos.map((vid, i) => (
829
+ <video key={`tm-vid-${i}`} src={vid.url} controls playsInline preload="none" className="max-w-full rounded-[10px] border border-white/10" />
830
+ ))}
831
+ {allToolMedia.pdfs.map((file, i) => (
832
+ <div key={`tm-pdf-${i}`} className="rounded-[10px] border border-white/10 overflow-hidden">
833
+ <iframe src={file.url} loading="lazy" className="w-full h-[400px] bg-white" title={file.name} />
834
+ <a
835
+ href={file.url}
836
+ download
837
+ onClick={(e) => e.stopPropagation()}
838
+ className="flex items-center gap-2 px-3 py-2 bg-surface/80 border-t border-white/10 text-[12px] text-text-2 hover:text-text no-underline transition-colors"
839
+ >
840
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
841
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
842
+ <polyline points="7 10 12 15 17 10" />
843
+ <line x1="12" y1="15" x2="12" y2="3" />
844
+ </svg>
845
+ {file.name}
846
+ </a>
847
+ </div>
848
+ ))}
849
+ {allToolMedia.files.map((file, i) => (
850
+ <a
851
+ key={`tm-file-${i}`}
852
+ href={file.url}
853
+ download
854
+ onClick={(e) => e.stopPropagation()}
855
+ className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/10 bg-surface/60 hover:bg-surface-2 transition-colors text-[13px] text-text-2 hover:text-text no-underline"
856
+ >
857
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
858
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
859
+ <polyline points="14 2 14 8 20 8" />
860
+ </svg>
861
+ {file.name}
862
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="ml-auto opacity-50">
863
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
864
+ <polyline points="7 10 12 15 17 10" />
865
+ <line x1="12" y1="15" x2="12" y2="3" />
866
+ </svg>
867
+ </a>
868
+ ))}
869
+ </div>
870
+ )}
871
+
809
872
  {/* Tool access request banners */}
810
873
  {!isUser && <ToolRequestBanner
811
874
  text={message.text || ''}
@@ -7,6 +7,7 @@ import { useChatStore } from '@/stores/use-chat-store'
7
7
  import { useAppStore } from '@/stores/use-app-store'
8
8
  import { api } from '@/lib/api-client'
9
9
  import { shouldHidePersistedStreamingAssistantMessage } from '@/lib/chat-streaming-state'
10
+ import { dedupeMessagesForDisplay } from '@/lib/chat-display'
10
11
  import { AgentAvatar } from '@/components/agents/agent-avatar'
11
12
  import { MessageBubble } from './message-bubble'
12
13
  import { StreamingBubble } from './streaming-bubble'
@@ -47,6 +48,22 @@ function dateSeparator(ts: number): string {
47
48
  return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
48
49
  }
49
50
 
51
+ function getLatestAssistantToolMoment(messages: Message[]): { key: string; name: string; input: string } | null {
52
+ const last = messages[messages.length - 1]
53
+ if (!last || last.role !== 'assistant' || !last.toolEvents?.length) return null
54
+ const events = last.toolEvents
55
+ for (let i = events.length - 1; i >= 0; i--) {
56
+ if (isNotableTool(events[i].name)) {
57
+ return {
58
+ key: `${last.time}-${events[i].name}-${i}`,
59
+ name: events[i].name,
60
+ input: events[i].input || '',
61
+ }
62
+ }
63
+ }
64
+ return null
65
+ }
66
+
50
67
  interface Props {
51
68
  messages: Message[]
52
69
  streaming: boolean
@@ -90,7 +107,7 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
90
107
  const showGatewayOverlay = isOpenClaw && gatewayStatus === 'disconnected'
91
108
 
92
109
  // Moment overlay for last assistant message (heartbeat or tool events)
93
- type MomentType = { kind: 'heartbeat' } | { kind: 'tool'; name: string; input: string }
110
+ type MomentType = { kind: 'heartbeat' } | { kind: 'tool'; key: string; name: string; input: string }
94
111
  const [currentMoment, setCurrentMoment] = useState<MomentType | null>(null)
95
112
 
96
113
  const heartbeatTopic = agent?.id ? `heartbeat:agent:${agent.id}` : ''
@@ -98,23 +115,32 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
98
115
  setCurrentMoment({ kind: 'heartbeat' })
99
116
  })
100
117
 
101
- // Detect notable tool events on latest assistant message when messages change
102
118
  const prevToolKeyRef = useRef<string | null>(null)
119
+ const seededMomentSessionRef = useRef<string | null>(null)
120
+
103
121
  useEffect(() => {
104
- const last = messages[messages.length - 1]
105
- if (!last || last.role !== 'assistant' || !last.toolEvents?.length) return
106
- const events = last.toolEvents
107
- for (let i = events.length - 1; i >= 0; i--) {
108
- if (isNotableTool(events[i].name)) {
109
- const key = `${last.time}-${events[i].name}-${i}`
110
- if (key !== prevToolKeyRef.current) {
111
- prevToolKeyRef.current = key
112
- setCurrentMoment({ kind: 'tool', name: events[i].name, input: events[i].input || '' })
113
- }
114
- return
115
- }
122
+ if (!sessionId) {
123
+ seededMomentSessionRef.current = null
124
+ prevToolKeyRef.current = null
125
+ setCurrentMoment(null)
126
+ return
116
127
  }
117
- }, [messages])
128
+
129
+ if (seededMomentSessionRef.current === sessionId) return
130
+ seededMomentSessionRef.current = sessionId
131
+ prevToolKeyRef.current = getLatestAssistantToolMoment(messages)?.key || null
132
+ setCurrentMoment(null)
133
+ }, [messages, sessionId])
134
+
135
+ // Detect notable tool events on the latest assistant message after the session has been seeded.
136
+ useEffect(() => {
137
+ if (!sessionId || seededMomentSessionRef.current !== sessionId) return
138
+ const moment = getLatestAssistantToolMoment(messages)
139
+ if (!moment) return
140
+ if (moment.key === prevToolKeyRef.current) return
141
+ prevToolKeyRef.current = moment.key
142
+ setCurrentMoment({ kind: 'tool', key: moment.key, name: moment.name, input: moment.input })
143
+ }, [messages, sessionId])
118
144
 
119
145
  // Unread count tracking
120
146
  const unreadRef = useRef(0)
@@ -189,13 +215,16 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
189
215
  }
190
216
  }
191
217
 
218
+ const dedupedDisplayedMessages = dedupeMessagesForDisplay(displayedMessages)
219
+
192
220
  // Apply bookmark + connector filter
193
221
  let filteredMessages = bookmarkFilter
194
- ? displayedMessages.filter((msg) => msg.bookmarked)
195
- : displayedMessages
222
+ ? dedupedDisplayedMessages.filter((msg) => msg.bookmarked)
223
+ : dedupedDisplayedMessages
196
224
  if (connectorFilter) {
197
225
  filteredMessages = filteredMessages.filter((msg) => msg.source?.connectorId === connectorFilter)
198
226
  }
227
+ const hasVisiblePersistedStreamingMessage = filteredMessages.some((msg) => msg.role === 'assistant' && msg.streaming === true)
199
228
 
200
229
  // Search matches
201
230
  const searchMatches = useMemo(() => {
@@ -629,7 +658,7 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
629
658
  } else {
630
659
  momentOverlay = (
631
660
  <ActivityMoment
632
- key={`${currentMoment.name}-${Date.now()}`}
661
+ key={currentMoment.key}
633
662
  toolName={currentMoment.name}
634
663
  toolInput={currentMoment.input}
635
664
  onDismiss={() => setCurrentMoment(null)}
@@ -676,7 +705,7 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
676
705
  )
677
706
  })}
678
707
  <ApprovalCards agentId={agent?.id} />
679
- {streaming && !displayText && <ThinkingIndicator assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentAvatarUrl={agent?.avatarUrl} agentName={agent?.name} />}
708
+ {streaming && !displayText && !hasVisiblePersistedStreamingMessage && <ThinkingIndicator assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentAvatarUrl={agent?.avatarUrl} agentName={agent?.name} />}
680
709
  {streaming && displayText && <StreamingBubble text={displayText} assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentAvatarUrl={agent?.avatarUrl} agentName={agent?.name} />}
681
710
  {appSettings.suggestionsEnabled === true && !streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
682
711
  <SuggestionsBar lastMessage={filteredMessages[filteredMessages.length - 1]} onSend={sendMessage} />
@@ -14,7 +14,7 @@ import { AgentHoverCard } from './agent-hover-card'
14
14
  import { ChatroomToolRequestBanner } from './chatroom-tool-request-banner'
15
15
  import { isStructuredMarkdown } from '@/components/chat/markdown-utils'
16
16
  import { TransferAgentPicker } from '@/components/chat/transfer-agent-picker'
17
- import { ConnectorPlatformIcon, CONNECTOR_PLATFORM_META } from '@/components/shared/connector-platform-icon'
17
+ import { ConnectorPlatformIcon, getConnectorPlatformLabel } from '@/components/shared/connector-platform-icon'
18
18
  import type { ChatroomMessage, Chatroom, Agent } from '@/types'
19
19
 
20
20
  interface Props {
@@ -227,7 +227,7 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
227
227
  <span className="text-[13px] font-600 text-text flex items-center gap-1.5">
228
228
  {message.source && <ConnectorPlatformIcon platform={message.source.platform} size={12} />}
229
229
  {isUser && message.source?.senderName
230
- ? `${message.source.senderName} via ${CONNECTOR_PLATFORM_META[message.source.platform]?.label || message.source.platform}`
230
+ ? `${message.source.senderName} via ${getConnectorPlatformLabel(message.source.platform)}`
231
231
  : message.senderName}
232
232
  </span>
233
233
  )}