@swarmclawai/swarmclaw 0.7.8 → 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 (251) hide show
  1. package/README.md +12 -15
  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 +22 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +26 -1
  17. package/src/app/api/external-agents/route.test.ts +165 -0
  18. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  19. package/src/app/api/gateways/[id]/route.ts +2 -0
  20. package/src/app/api/gateways/health-route.test.ts +135 -0
  21. package/src/app/api/gateways/route.ts +2 -0
  22. package/src/app/api/mcp-servers/route.test.ts +130 -0
  23. package/src/app/api/openclaw/deploy/route.ts +38 -5
  24. package/src/app/api/plugins/install/route.ts +46 -6
  25. package/src/app/api/plugins/marketplace/route.ts +48 -15
  26. package/src/app/api/preview-server/route.ts +26 -11
  27. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  28. package/src/app/api/schedules/route.test.ts +86 -0
  29. package/src/app/api/schedules/route.ts +6 -1
  30. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  31. package/src/app/api/setup/check-provider/route.ts +40 -10
  32. package/src/app/api/skills/[id]/route.ts +12 -0
  33. package/src/app/api/skills/import/route.ts +14 -12
  34. package/src/app/api/skills/route.ts +13 -1
  35. package/src/app/api/tasks/[id]/route.ts +10 -1
  36. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  37. package/src/app/api/tasks/import/github/route.ts +337 -0
  38. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  39. package/src/app/api/wallets/[id]/route.ts +79 -33
  40. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  41. package/src/app/api/wallets/route.ts +78 -61
  42. package/src/app/api/webhooks/[id]/route.ts +33 -6
  43. package/src/app/api/webhooks/route.test.ts +272 -0
  44. package/src/cli/index.js +1 -0
  45. package/src/cli/spec.js +1 -0
  46. package/src/components/agents/agent-card.tsx +9 -2
  47. package/src/components/agents/agent-chat-list.tsx +18 -2
  48. package/src/components/agents/agent-list.tsx +1 -0
  49. package/src/components/agents/agent-sheet.tsx +73 -24
  50. package/src/components/agents/inspector-panel.tsx +41 -0
  51. package/src/components/canvas/canvas-panel.tsx +236 -65
  52. package/src/components/chat/chat-card.tsx +36 -13
  53. package/src/components/chat/chat-header.tsx +44 -16
  54. package/src/components/chat/chat-list.tsx +28 -4
  55. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  56. package/src/components/chat/message-bubble.tsx +208 -145
  57. package/src/components/chat/message-list.tsx +48 -19
  58. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  59. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  60. package/src/components/connectors/connector-health.tsx +1 -1
  61. package/src/components/connectors/connector-list.tsx +7 -2
  62. package/src/components/connectors/connector-sheet.tsx +337 -148
  63. package/src/components/gateways/gateway-sheet.tsx +2 -2
  64. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  65. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  66. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  67. package/src/components/plugins/plugin-list.tsx +45 -9
  68. package/src/components/plugins/plugin-sheet.tsx +55 -7
  69. package/src/components/providers/provider-list.tsx +2 -1
  70. package/src/components/providers/provider-sheet.tsx +21 -2
  71. package/src/components/schedules/schedule-card.tsx +25 -1
  72. package/src/components/schedules/schedule-sheet.tsx +44 -2
  73. package/src/components/secrets/secret-sheet.tsx +21 -2
  74. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  75. package/src/components/shared/bottom-sheet.tsx +13 -3
  76. package/src/components/shared/command-palette.tsx +8 -1
  77. package/src/components/shared/confirm-dialog.tsx +19 -4
  78. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  79. package/src/components/shared/connector-platform-icon.tsx +39 -6
  80. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  81. package/src/components/shared/settings/section-capability-policy.tsx +7 -3
  82. package/src/components/skills/skill-list.tsx +25 -0
  83. package/src/components/skills/skill-sheet.tsx +84 -12
  84. package/src/components/tasks/approvals-panel.tsx +191 -95
  85. package/src/components/tasks/task-board.tsx +273 -2
  86. package/src/components/tasks/task-card.tsx +38 -9
  87. package/src/components/ui/dialog.tsx +2 -2
  88. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  89. package/src/components/wallets/wallet-panel.tsx +435 -90
  90. package/src/components/wallets/wallet-section.tsx +198 -48
  91. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  92. package/src/lib/approval-display.ts +20 -0
  93. package/src/lib/canvas-content.ts +198 -0
  94. package/src/lib/chat-artifact-summary.ts +165 -0
  95. package/src/lib/chat-display.test.ts +91 -0
  96. package/src/lib/chat-display.ts +58 -0
  97. package/src/lib/chat-streaming-state.test.ts +47 -1
  98. package/src/lib/chat-streaming-state.ts +42 -0
  99. package/src/lib/ollama-model.ts +10 -0
  100. package/src/lib/openclaw-endpoint.test.ts +8 -0
  101. package/src/lib/openclaw-endpoint.ts +6 -1
  102. package/src/lib/plugin-install-cors.ts +46 -0
  103. package/src/lib/plugin-sources.test.ts +43 -0
  104. package/src/lib/plugin-sources.ts +77 -0
  105. package/src/lib/providers/ollama.ts +16 -6
  106. package/src/lib/providers/openclaw.test.ts +54 -0
  107. package/src/lib/providers/openclaw.ts +127 -11
  108. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  109. package/src/lib/schedule-dedupe.test.ts +66 -1
  110. package/src/lib/schedule-dedupe.ts +169 -12
  111. package/src/lib/schedule-origin.test.ts +20 -0
  112. package/src/lib/schedule-origin.ts +15 -0
  113. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  114. package/src/lib/server/agent-availability.ts +16 -0
  115. package/src/lib/server/agent-runtime-config.ts +12 -4
  116. package/src/lib/server/agent-thread-session.test.ts +51 -0
  117. package/src/lib/server/agent-thread-session.ts +7 -0
  118. package/src/lib/server/approval-match.ts +205 -0
  119. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  120. package/src/lib/server/approvals.ts +214 -1
  121. package/src/lib/server/assistant-control.test.ts +29 -0
  122. package/src/lib/server/assistant-control.ts +23 -0
  123. package/src/lib/server/build-llm.test.ts +79 -0
  124. package/src/lib/server/build-llm.ts +14 -4
  125. package/src/lib/server/canvas-content.test.ts +32 -0
  126. package/src/lib/server/canvas-content.ts +6 -0
  127. package/src/lib/server/capability-router.test.ts +11 -0
  128. package/src/lib/server/capability-router.ts +26 -1
  129. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  130. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  131. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  132. package/src/lib/server/chat-execution.ts +353 -72
  133. package/src/lib/server/clawhub-client.test.ts +14 -8
  134. package/src/lib/server/connectors/manager.test.ts +1147 -0
  135. package/src/lib/server/connectors/manager.ts +362 -63
  136. package/src/lib/server/connectors/pairing.ts +26 -5
  137. package/src/lib/server/connectors/types.ts +2 -0
  138. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  139. package/src/lib/server/connectors/whatsapp.ts +271 -47
  140. package/src/lib/server/context-manager.ts +6 -1
  141. package/src/lib/server/daemon-state.ts +1 -1
  142. package/src/lib/server/data-dir.test.ts +37 -0
  143. package/src/lib/server/data-dir.ts +20 -1
  144. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  145. package/src/lib/server/devserver-launch.test.ts +60 -0
  146. package/src/lib/server/devserver-launch.ts +85 -0
  147. package/src/lib/server/elevenlabs.test.ts +189 -1
  148. package/src/lib/server/elevenlabs.ts +147 -43
  149. package/src/lib/server/ethereum.ts +590 -0
  150. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  151. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  152. package/src/lib/server/eval/agent-regression.ts +383 -11
  153. package/src/lib/server/evm-swap.ts +475 -0
  154. package/src/lib/server/execution-log.ts +1 -0
  155. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  156. package/src/lib/server/heartbeat-service.ts +15 -10
  157. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  158. package/src/lib/server/heartbeat-wake.ts +338 -57
  159. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  160. package/src/lib/server/mcp-client.test.ts +16 -0
  161. package/src/lib/server/mcp-client.ts +25 -0
  162. package/src/lib/server/memory-integration.test.ts +719 -0
  163. package/src/lib/server/memory-policy.test.ts +43 -0
  164. package/src/lib/server/memory-policy.ts +132 -0
  165. package/src/lib/server/memory-tiers.test.ts +60 -0
  166. package/src/lib/server/memory-tiers.ts +16 -0
  167. package/src/lib/server/ollama-runtime.ts +58 -0
  168. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  169. package/src/lib/server/openclaw-deploy.ts +557 -81
  170. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  171. package/src/lib/server/openclaw-gateway.ts +10 -4
  172. package/src/lib/server/openclaw-health.test.ts +35 -0
  173. package/src/lib/server/openclaw-health.ts +215 -47
  174. package/src/lib/server/orchestrator-lg.ts +2 -2
  175. package/src/lib/server/plugins-advanced.test.ts +351 -0
  176. package/src/lib/server/plugins.ts +205 -5
  177. package/src/lib/server/queue-advanced.test.ts +528 -0
  178. package/src/lib/server/queue-followups.test.ts +262 -0
  179. package/src/lib/server/queue-reconcile.test.ts +128 -0
  180. package/src/lib/server/queue.ts +293 -61
  181. package/src/lib/server/scheduler.ts +29 -1
  182. package/src/lib/server/session-note.test.ts +36 -0
  183. package/src/lib/server/session-note.ts +42 -0
  184. package/src/lib/server/session-run-manager.ts +52 -4
  185. package/src/lib/server/session-tools/canvas.ts +14 -12
  186. package/src/lib/server/session-tools/connector.test.ts +138 -0
  187. package/src/lib/server/session-tools/connector.ts +348 -61
  188. package/src/lib/server/session-tools/context.ts +12 -3
  189. package/src/lib/server/session-tools/crud.ts +221 -10
  190. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  191. package/src/lib/server/session-tools/delegate.ts +64 -8
  192. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  193. package/src/lib/server/session-tools/discovery.ts +80 -12
  194. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  195. package/src/lib/server/session-tools/file.ts +43 -4
  196. package/src/lib/server/session-tools/human-loop.ts +35 -5
  197. package/src/lib/server/session-tools/index.ts +44 -9
  198. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  199. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  200. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  201. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  202. package/src/lib/server/session-tools/memory.test.ts +93 -0
  203. package/src/lib/server/session-tools/memory.ts +546 -79
  204. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  205. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  206. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  207. package/src/lib/server/session-tools/schedule.ts +6 -1
  208. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  209. package/src/lib/server/session-tools/shell.ts +22 -3
  210. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  211. package/src/lib/server/session-tools/wallet.ts +1374 -139
  212. package/src/lib/server/session-tools/web-inputs.test.ts +162 -1
  213. package/src/lib/server/session-tools/web.ts +468 -64
  214. package/src/lib/server/skill-discovery.ts +128 -0
  215. package/src/lib/server/skill-eligibility.test.ts +84 -0
  216. package/src/lib/server/skill-eligibility.ts +95 -0
  217. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  218. package/src/lib/server/skill-prompt-budget.ts +125 -0
  219. package/src/lib/server/skills-normalize.test.ts +54 -0
  220. package/src/lib/server/skills-normalize.ts +372 -26
  221. package/src/lib/server/solana.ts +214 -29
  222. package/src/lib/server/storage.ts +65 -36
  223. package/src/lib/server/stream-agent-chat.test.ts +419 -9
  224. package/src/lib/server/stream-agent-chat.ts +887 -83
  225. package/src/lib/server/system-events.ts +1 -1
  226. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  227. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  228. package/src/lib/server/tool-loop-detection.ts +260 -0
  229. package/src/lib/server/tool-planning.ts +4 -2
  230. package/src/lib/server/wallet-execution.test.ts +198 -0
  231. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  232. package/src/lib/server/wallet-portfolio.ts +724 -0
  233. package/src/lib/server/wallet-service.test.ts +57 -0
  234. package/src/lib/server/wallet-service.ts +213 -0
  235. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  236. package/src/lib/server/watch-jobs.ts +17 -2
  237. package/src/lib/server/workspace-context.ts +111 -0
  238. package/src/lib/skill-save-payload.test.ts +39 -0
  239. package/src/lib/skill-save-payload.ts +37 -0
  240. package/src/lib/tasks.ts +28 -0
  241. package/src/lib/tool-event-summary.test.ts +30 -0
  242. package/src/lib/tool-event-summary.ts +37 -0
  243. package/src/lib/validation/schemas.ts +1 -0
  244. package/src/lib/wallet-transactions.test.ts +75 -0
  245. package/src/lib/wallet-transactions.ts +43 -0
  246. package/src/lib/wallet.test.ts +17 -0
  247. package/src/lib/wallet.ts +183 -0
  248. package/src/proxy.test.ts +31 -0
  249. package/src/proxy.ts +34 -2
  250. package/src/stores/use-chat-store.ts +15 -1
  251. package/src/types/index.ts +210 -14
@@ -9,6 +9,7 @@ import { toast } from 'sonner'
9
9
  import { Skeleton } from '@/components/shared/skeleton'
10
10
  import { EmptyState } from '@/components/shared/empty-state'
11
11
  import { Dropdown, DropdownItem } from '@/components/shared/dropdown'
12
+ import { ConfirmDialog } from '@/components/shared/confirm-dialog'
12
13
 
13
14
  interface Props {
14
15
  inSidebar?: boolean
@@ -38,6 +39,8 @@ export function ChatList({ inSidebar, onSelect }: Props) {
38
39
  const [sortMode, setSortMode] = useState<SortMode>('lastActive')
39
40
  const [loaded, setLoaded] = useState(Object.keys(sessions).length > 0)
40
41
  const [bulkMenuOpen, setBulkMenuOpen] = useState(false)
42
+ const [confirmClearIds, setConfirmClearIds] = useState<string[] | null>(null)
43
+ const [clearing, setClearing] = useState(false)
41
44
 
42
45
  useEffect(() => {
43
46
  if (Object.keys(sessions).length > 0 && !loaded) setLoaded(true)
@@ -116,6 +119,18 @@ export function ChatList({ inSidebar, onSelect }: Props) {
116
119
  onSelect?.()
117
120
  }
118
121
 
122
+ const handleClearFiltered = async () => {
123
+ if (!confirmClearIds || confirmClearIds.length === 0) return
124
+ setClearing(true)
125
+ try {
126
+ await clearSessions(confirmClearIds)
127
+ toast.success(`${confirmClearIds.length} chat${confirmClearIds.length === 1 ? '' : 's'} deleted`)
128
+ setConfirmClearIds(null)
129
+ } finally {
130
+ setClearing(false)
131
+ }
132
+ }
133
+
119
134
  // Truly empty — no sessions at all for this user
120
135
  if (!allUserSessions.length) {
121
136
  // Show skeleton cards while data is loading
@@ -178,11 +193,9 @@ export function ChatList({ inSidebar, onSelect }: Props) {
178
193
  </svg>
179
194
  </button>
180
195
  <Dropdown open={bulkMenuOpen} onClose={() => setBulkMenuOpen(false)}>
181
- <DropdownItem onClick={async () => {
196
+ <DropdownItem onClick={() => {
182
197
  setBulkMenuOpen(false)
183
- if (!window.confirm(`Delete ${filtered.length} chat${filtered.length === 1 ? '' : 's'}?`)) return
184
- await clearSessions(filtered.map((s) => s.id))
185
- toast.success(`${filtered.length} chat${filtered.length === 1 ? '' : 's'} deleted`)
198
+ setConfirmClearIds(filtered.map((s) => s.id))
186
199
  }}>
187
200
  Clear filtered chats
188
201
  </DropdownItem>
@@ -249,6 +262,17 @@ export function ChatList({ inSidebar, onSelect }: Props) {
249
262
  </p>
250
263
  </div>
251
264
  )}
265
+ <ConfirmDialog
266
+ open={!!confirmClearIds}
267
+ title="Clear Filtered Chats?"
268
+ message={confirmClearIds ? `Delete ${confirmClearIds.length} chat${confirmClearIds.length === 1 ? '' : 's'} from the current view?` : 'Delete filtered chats?'}
269
+ confirmLabel={clearing ? 'Deleting...' : 'Delete'}
270
+ confirmDisabled={clearing}
271
+ cancelDisabled={clearing}
272
+ danger
273
+ onConfirm={() => { void handleClearFiltered() }}
274
+ onCancel={() => { if (!clearing) setConfirmClearIds(null) }}
275
+ />
252
276
  </div>
253
277
  )
254
278
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useEffect, useState } from 'react'
4
4
  import { api } from '@/lib/api-client'
5
+ import { ConfirmDialog } from '@/components/shared/confirm-dialog'
5
6
  import { useAppStore } from '@/stores/use-app-store'
6
7
  import { toast } from 'sonner'
7
8
 
@@ -21,6 +22,7 @@ export function CheckpointTimeline({ sessionId }: Props) {
21
22
  const [checkpoints, setCheckpoints] = useState<Checkpoint[]>([])
22
23
  const [loading, setLoading] = useState(true)
23
24
  const [restoringId, setRestoringId] = useState<string | null>(null)
25
+ const [confirmRestore, setConfirmRestore] = useState<Checkpoint | null>(null)
24
26
  const loadSessions = useAppStore((s) => s.loadSessions)
25
27
 
26
28
  const load = async () => {
@@ -40,9 +42,9 @@ export function CheckpointTimeline({ sessionId }: Props) {
40
42
  // eslint-disable-next-line react-hooks/exhaustive-deps
41
43
  }, [sessionId])
42
44
 
43
- const handleRestore = async (checkpoint: Checkpoint) => {
44
- if (!confirm('Restore session to this point? This will delete all subsequent history.')) return
45
-
45
+ const handleRestore = async () => {
46
+ if (!confirmRestore) return
47
+ const checkpoint = confirmRestore
46
48
  setRestoringId(checkpoint.checkpointId)
47
49
  try {
48
50
  await api('POST', `/chats/${sessionId}/restore`, {
@@ -52,6 +54,7 @@ export function CheckpointTimeline({ sessionId }: Props) {
52
54
  toast.success('Session restored successfully')
53
55
  await loadSessions()
54
56
  await load()
57
+ setConfirmRestore(null)
55
58
  } catch (err) {
56
59
  toast.error('Failed to restore session')
57
60
  console.error(err)
@@ -74,39 +77,52 @@ export function CheckpointTimeline({ sessionId }: Props) {
74
77
  }
75
78
 
76
79
  return (
77
- <div className="flex flex-col gap-3 p-5">
78
- {checkpoints.map((cp, i) => (
79
- <div
80
- key={cp.checkpointId}
81
- className="group relative flex flex-col gap-2 p-3 rounded-[12px] border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] transition-all"
82
- >
83
- <div className="flex items-center justify-between">
84
- <div className="flex flex-col">
85
- <span className="text-[11px] font-700 text-accent-bright uppercase tracking-wider">
86
- {i === 0 ? 'Current State' : `Point ${checkpoints.length - i}`}
87
- </span>
88
- <span className="text-[10px] text-text-3 font-mono">
89
- {new Date(cp.createdAt).toLocaleString()}
90
- </span>
80
+ <>
81
+ <div className="flex flex-col gap-3 p-5">
82
+ {checkpoints.map((cp, i) => (
83
+ <div
84
+ key={cp.checkpointId}
85
+ className="group relative flex flex-col gap-2 p-3 rounded-[12px] border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] transition-all"
86
+ >
87
+ <div className="flex items-center justify-between">
88
+ <div className="flex flex-col">
89
+ <span className="text-[11px] font-700 text-accent-bright uppercase tracking-wider">
90
+ {i === 0 ? 'Current State' : `Point ${checkpoints.length - i}`}
91
+ </span>
92
+ <span className="text-[10px] text-text-3 font-mono">
93
+ {new Date(cp.createdAt).toLocaleString()}
94
+ </span>
95
+ </div>
96
+ {i > 0 && (
97
+ <button
98
+ onClick={() => setConfirmRestore(cp)}
99
+ disabled={!!restoringId}
100
+ className="px-3 py-1 rounded-[6px] bg-accent-soft text-accent-bright text-[11px] font-600 border-none cursor-pointer hover:brightness-110 disabled:opacity-50"
101
+ >
102
+ {restoringId === cp.checkpointId ? 'Restoring...' : 'Restore here'}
103
+ </button>
104
+ )}
91
105
  </div>
92
- {i > 0 && (
93
- <button
94
- onClick={() => handleRestore(cp)}
95
- disabled={!!restoringId}
96
- className="px-3 py-1 rounded-[6px] bg-accent-soft text-accent-bright text-[11px] font-600 border-none cursor-pointer hover:brightness-110 disabled:opacity-50"
97
- >
98
- {restoringId === cp.checkpointId ? 'Restoring...' : 'Restore here'}
99
- </button>
106
+
107
+ {cp.values && Array.isArray(cp.values.messages) && cp.values.messages.length > 0 && (
108
+ <div className="mt-1 p-2 rounded-[8px] bg-black/20 text-[11px] text-text-3 line-clamp-2 italic">
109
+ Last message: {String((cp.values.messages[cp.values.messages.length - 1] as Record<string, unknown>)?.content ?? 'Empty state')}
110
+ </div>
100
111
  )}
101
112
  </div>
102
-
103
- {cp.values && Array.isArray(cp.values.messages) && cp.values.messages.length > 0 && (
104
- <div className="mt-1 p-2 rounded-[8px] bg-black/20 text-[11px] text-text-3 line-clamp-2 italic">
105
- Last message: {String((cp.values.messages[cp.values.messages.length - 1] as Record<string, unknown>)?.content ?? 'Empty state')}
106
- </div>
107
- )}
108
- </div>
109
- ))}
110
- </div>
113
+ ))}
114
+ </div>
115
+ <ConfirmDialog
116
+ open={!!confirmRestore}
117
+ title="Restore Session?"
118
+ message="Restore session to this point? This will delete all subsequent history."
119
+ confirmLabel={restoringId ? 'Restoring...' : 'Restore'}
120
+ confirmDisabled={!!restoringId}
121
+ cancelDisabled={!!restoringId}
122
+ danger
123
+ onConfirm={() => { void handleRestore() }}
124
+ onCancel={() => { if (!restoringId) setConfirmRestore(null) }}
125
+ />
126
+ </>
111
127
  )
112
128
  }
@@ -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 || ''}