@swarmclawai/swarmclaw 0.2.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 (319) hide show
  1. package/README.md +577 -0
  2. package/bin/server-cmd.js +359 -0
  3. package/bin/swarmclaw.js +29 -0
  4. package/bin/swarmclaw.mjs +1504 -0
  5. package/next.config.ts +33 -0
  6. package/package.json +112 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/branding/swarmclaw-org-avatar.png +0 -0
  9. package/public/branding/swarmclaw-org-avatar.svg +58 -0
  10. package/public/file.svg +1 -0
  11. package/public/globe.svg +1 -0
  12. package/public/next.svg +1 -0
  13. package/public/screenshots/agents.png +0 -0
  14. package/public/screenshots/connectors.png +0 -0
  15. package/public/screenshots/dashboard.png +0 -0
  16. package/public/screenshots/new-session-openclaw.png +0 -0
  17. package/public/screenshots/providers.png +0 -0
  18. package/public/screenshots/schedules.png +0 -0
  19. package/public/screenshots/tasks.png +0 -0
  20. package/public/vercel.svg +1 -0
  21. package/public/window.svg +1 -0
  22. package/src/app/api/agents/[id]/route.ts +30 -0
  23. package/src/app/api/agents/[id]/thread/route.ts +66 -0
  24. package/src/app/api/agents/generate/route.ts +42 -0
  25. package/src/app/api/agents/route.ts +33 -0
  26. package/src/app/api/auth/route.ts +25 -0
  27. package/src/app/api/claude-skills/route.ts +42 -0
  28. package/src/app/api/clawhub/install/route.ts +39 -0
  29. package/src/app/api/clawhub/search/route.ts +11 -0
  30. package/src/app/api/connectors/[id]/route.ts +79 -0
  31. package/src/app/api/connectors/route.ts +60 -0
  32. package/src/app/api/credentials/[id]/route.ts +14 -0
  33. package/src/app/api/credentials/route.ts +31 -0
  34. package/src/app/api/daemon/health-check/route.ts +11 -0
  35. package/src/app/api/daemon/route.ts +22 -0
  36. package/src/app/api/dirs/pick/route.ts +60 -0
  37. package/src/app/api/dirs/route.ts +29 -0
  38. package/src/app/api/documents/[id]/route.ts +47 -0
  39. package/src/app/api/documents/route.ts +93 -0
  40. package/src/app/api/files/serve/route.ts +69 -0
  41. package/src/app/api/generate/info/route.ts +12 -0
  42. package/src/app/api/generate/route.ts +106 -0
  43. package/src/app/api/ip/route.ts +6 -0
  44. package/src/app/api/knowledge/[id]/route.ts +61 -0
  45. package/src/app/api/knowledge/route.ts +48 -0
  46. package/src/app/api/knowledge/upload/route.ts +86 -0
  47. package/src/app/api/logs/route.ts +65 -0
  48. package/src/app/api/mcp-servers/[id]/route.ts +32 -0
  49. package/src/app/api/mcp-servers/[id]/test/route.ts +23 -0
  50. package/src/app/api/mcp-servers/[id]/tools/route.ts +32 -0
  51. package/src/app/api/mcp-servers/route.ts +27 -0
  52. package/src/app/api/memory/[id]/route.ts +126 -0
  53. package/src/app/api/memory/maintenance/route.ts +63 -0
  54. package/src/app/api/memory/route.ts +111 -0
  55. package/src/app/api/memory-images/[filename]/route.ts +36 -0
  56. package/src/app/api/orchestrator/run/route.ts +43 -0
  57. package/src/app/api/plugins/install/route.ts +58 -0
  58. package/src/app/api/plugins/marketplace/route.ts +33 -0
  59. package/src/app/api/plugins/route.ts +21 -0
  60. package/src/app/api/preview-server/route.ts +339 -0
  61. package/src/app/api/providers/[id]/models/route.ts +29 -0
  62. package/src/app/api/providers/[id]/route.ts +34 -0
  63. package/src/app/api/providers/configs/route.ts +7 -0
  64. package/src/app/api/providers/ollama/route.ts +30 -0
  65. package/src/app/api/providers/openclaw/health/route.ts +23 -0
  66. package/src/app/api/providers/route.ts +28 -0
  67. package/src/app/api/runs/[id]/route.ts +9 -0
  68. package/src/app/api/runs/route.ts +13 -0
  69. package/src/app/api/schedules/[id]/route.ts +28 -0
  70. package/src/app/api/schedules/[id]/run/route.ts +104 -0
  71. package/src/app/api/schedules/route.ts +78 -0
  72. package/src/app/api/secrets/[id]/route.ts +29 -0
  73. package/src/app/api/secrets/route.ts +42 -0
  74. package/src/app/api/sessions/[id]/browser/route.ts +13 -0
  75. package/src/app/api/sessions/[id]/chat/route.ts +96 -0
  76. package/src/app/api/sessions/[id]/clear/route.ts +19 -0
  77. package/src/app/api/sessions/[id]/deploy/route.ts +34 -0
  78. package/src/app/api/sessions/[id]/devserver/route.ts +69 -0
  79. package/src/app/api/sessions/[id]/mailbox/route.ts +70 -0
  80. package/src/app/api/sessions/[id]/main-loop/route.ts +94 -0
  81. package/src/app/api/sessions/[id]/messages/route.ts +9 -0
  82. package/src/app/api/sessions/[id]/retry/route.ts +28 -0
  83. package/src/app/api/sessions/[id]/route.ts +103 -0
  84. package/src/app/api/sessions/[id]/stop/route.ts +13 -0
  85. package/src/app/api/sessions/heartbeat/route.ts +26 -0
  86. package/src/app/api/sessions/route.ts +85 -0
  87. package/src/app/api/settings/route.ts +58 -0
  88. package/src/app/api/setup/check-provider/route.ts +326 -0
  89. package/src/app/api/setup/doctor/route.ts +250 -0
  90. package/src/app/api/skills/[id]/route.ts +40 -0
  91. package/src/app/api/skills/import/route.ts +69 -0
  92. package/src/app/api/skills/route.ts +28 -0
  93. package/src/app/api/tasks/[id]/route.ts +102 -0
  94. package/src/app/api/tasks/route.ts +115 -0
  95. package/src/app/api/tts/route.ts +40 -0
  96. package/src/app/api/upload/route.ts +18 -0
  97. package/src/app/api/uploads/[filename]/route.ts +59 -0
  98. package/src/app/api/usage/route.ts +35 -0
  99. package/src/app/api/version/route.ts +81 -0
  100. package/src/app/api/version/update/route.ts +95 -0
  101. package/src/app/api/webhooks/[id]/history/route.ts +13 -0
  102. package/src/app/api/webhooks/[id]/route.ts +204 -0
  103. package/src/app/api/webhooks/route.ts +37 -0
  104. package/src/app/favicon.ico +0 -0
  105. package/src/app/globals.css +370 -0
  106. package/src/app/layout.tsx +52 -0
  107. package/src/app/page.tsx +172 -0
  108. package/src/cli/index.js +1232 -0
  109. package/src/cli/index.test.js +281 -0
  110. package/src/cli/index.ts +1158 -0
  111. package/src/cli/spec.js +284 -0
  112. package/src/components/agents/agent-card.tsx +219 -0
  113. package/src/components/agents/agent-chat-list.tsx +165 -0
  114. package/src/components/agents/agent-list.tsx +110 -0
  115. package/src/components/agents/agent-sheet.tsx +1220 -0
  116. package/src/components/auth/access-key-gate.tsx +248 -0
  117. package/src/components/auth/setup-wizard.tsx +940 -0
  118. package/src/components/auth/user-picker.tsx +88 -0
  119. package/src/components/chat/chat-area.tsx +406 -0
  120. package/src/components/chat/chat-header.tsx +491 -0
  121. package/src/components/chat/chat-tool-toggles.tsx +161 -0
  122. package/src/components/chat/code-block.tsx +146 -0
  123. package/src/components/chat/dev-server-bar.tsx +39 -0
  124. package/src/components/chat/message-bubble.tsx +486 -0
  125. package/src/components/chat/message-list.tsx +299 -0
  126. package/src/components/chat/session-debug-panel.tsx +196 -0
  127. package/src/components/chat/streaming-bubble.tsx +85 -0
  128. package/src/components/chat/thinking-indicator.tsx +26 -0
  129. package/src/components/chat/tool-call-bubble.tsx +438 -0
  130. package/src/components/chat/tool-request-banner.tsx +103 -0
  131. package/src/components/connectors/connector-list.tsx +196 -0
  132. package/src/components/connectors/connector-sheet.tsx +804 -0
  133. package/src/components/input/chat-input.tsx +235 -0
  134. package/src/components/knowledge/knowledge-list.tsx +206 -0
  135. package/src/components/knowledge/knowledge-sheet.tsx +316 -0
  136. package/src/components/layout/app-layout.tsx +1016 -0
  137. package/src/components/layout/daemon-indicator.tsx +56 -0
  138. package/src/components/layout/mobile-header.tsx +31 -0
  139. package/src/components/layout/network-banner.tsx +17 -0
  140. package/src/components/layout/update-banner.tsx +130 -0
  141. package/src/components/logs/log-list.tsx +358 -0
  142. package/src/components/mcp-servers/mcp-server-list.tsx +122 -0
  143. package/src/components/mcp-servers/mcp-server-sheet.tsx +243 -0
  144. package/src/components/memory/memory-card.tsx +63 -0
  145. package/src/components/memory/memory-detail.tsx +339 -0
  146. package/src/components/memory/memory-list.tsx +198 -0
  147. package/src/components/memory/memory-sheet.tsx +70 -0
  148. package/src/components/plugins/plugin-list.tsx +60 -0
  149. package/src/components/plugins/plugin-sheet.tsx +311 -0
  150. package/src/components/providers/provider-list.tsx +96 -0
  151. package/src/components/providers/provider-sheet.tsx +542 -0
  152. package/src/components/runs/run-list.tsx +231 -0
  153. package/src/components/schedules/schedule-card.tsx +63 -0
  154. package/src/components/schedules/schedule-list.tsx +76 -0
  155. package/src/components/schedules/schedule-sheet.tsx +336 -0
  156. package/src/components/secrets/secret-sheet.tsx +180 -0
  157. package/src/components/secrets/secrets-list.tsx +91 -0
  158. package/src/components/sessions/new-session-sheet.tsx +478 -0
  159. package/src/components/sessions/session-card.tsx +144 -0
  160. package/src/components/sessions/session-list.tsx +202 -0
  161. package/src/components/shared/ai-gen-block.tsx +77 -0
  162. package/src/components/shared/avatar.tsx +48 -0
  163. package/src/components/shared/bottom-sheet.tsx +30 -0
  164. package/src/components/shared/confirm-dialog.tsx +47 -0
  165. package/src/components/shared/connector-platform-icon.tsx +113 -0
  166. package/src/components/shared/dir-browser.tsx +285 -0
  167. package/src/components/shared/dropdown.tsx +55 -0
  168. package/src/components/shared/icon-button.tsx +25 -0
  169. package/src/components/shared/settings/plugin-manager.tsx +207 -0
  170. package/src/components/shared/settings/section-capability-policy.tsx +93 -0
  171. package/src/components/shared/settings/section-embedding.tsx +99 -0
  172. package/src/components/shared/settings/section-heartbeat.tsx +168 -0
  173. package/src/components/shared/settings/section-memory.tsx +77 -0
  174. package/src/components/shared/settings/section-orchestrator.tsx +108 -0
  175. package/src/components/shared/settings/section-providers.tsx +181 -0
  176. package/src/components/shared/settings/section-runtime-loop.tsx +183 -0
  177. package/src/components/shared/settings/section-secrets.tsx +132 -0
  178. package/src/components/shared/settings/section-user-preferences.tsx +24 -0
  179. package/src/components/shared/settings/section-voice.tsx +53 -0
  180. package/src/components/shared/settings/settings-sheet.tsx +88 -0
  181. package/src/components/shared/settings/types.ts +7 -0
  182. package/src/components/shared/settings/utils.ts +13 -0
  183. package/src/components/shared/settings-sheet.tsx +1 -0
  184. package/src/components/shared/skeleton.tsx +19 -0
  185. package/src/components/shared/usage-badge.tsx +28 -0
  186. package/src/components/skills/clawhub-browser.tsx +225 -0
  187. package/src/components/skills/skill-list.tsx +70 -0
  188. package/src/components/skills/skill-sheet.tsx +254 -0
  189. package/src/components/tasks/task-board.tsx +96 -0
  190. package/src/components/tasks/task-card.tsx +179 -0
  191. package/src/components/tasks/task-column.tsx +73 -0
  192. package/src/components/tasks/task-list.tsx +118 -0
  193. package/src/components/tasks/task-sheet.tsx +415 -0
  194. package/src/components/ui/avatar.tsx +109 -0
  195. package/src/components/ui/badge.tsx +48 -0
  196. package/src/components/ui/button.tsx +64 -0
  197. package/src/components/ui/card.tsx +92 -0
  198. package/src/components/ui/dialog.tsx +158 -0
  199. package/src/components/ui/dropdown-menu.tsx +257 -0
  200. package/src/components/ui/input.tsx +21 -0
  201. package/src/components/ui/scroll-area.tsx +58 -0
  202. package/src/components/ui/select.tsx +190 -0
  203. package/src/components/ui/separator.tsx +28 -0
  204. package/src/components/ui/sheet.tsx +143 -0
  205. package/src/components/ui/sonner.tsx +22 -0
  206. package/src/components/ui/textarea.tsx +18 -0
  207. package/src/components/ui/tooltip.tsx +56 -0
  208. package/src/components/usage/usage-list.tsx +105 -0
  209. package/src/components/webhooks/webhook-list.tsx +166 -0
  210. package/src/components/webhooks/webhook-sheet.tsx +402 -0
  211. package/src/hooks/use-auto-resize.ts +20 -0
  212. package/src/hooks/use-media-query.ts +21 -0
  213. package/src/hooks/use-speech-recognition.ts +83 -0
  214. package/src/instrumentation.ts +8 -0
  215. package/src/lib/agents.ts +13 -0
  216. package/src/lib/api-client.ts +100 -0
  217. package/src/lib/chat.ts +60 -0
  218. package/src/lib/memory.ts +42 -0
  219. package/src/lib/openclaw-endpoint.test.ts +48 -0
  220. package/src/lib/openclaw-endpoint.ts +67 -0
  221. package/src/lib/provider-config.ts +13 -0
  222. package/src/lib/providers/anthropic.ts +135 -0
  223. package/src/lib/providers/claude-cli.ts +202 -0
  224. package/src/lib/providers/codex-cli.ts +260 -0
  225. package/src/lib/providers/index.ts +351 -0
  226. package/src/lib/providers/ollama.ts +131 -0
  227. package/src/lib/providers/openai.ts +164 -0
  228. package/src/lib/providers/openclaw.ts +330 -0
  229. package/src/lib/providers/opencode-cli.ts +164 -0
  230. package/src/lib/runtime-loop.ts +15 -0
  231. package/src/lib/schedule-dedupe.test.ts +84 -0
  232. package/src/lib/schedule-dedupe.ts +174 -0
  233. package/src/lib/schedule-name.ts +62 -0
  234. package/src/lib/schedules.ts +16 -0
  235. package/src/lib/server/agent-registry.ts +70 -0
  236. package/src/lib/server/api-routes.test.ts +362 -0
  237. package/src/lib/server/autonomy-contract.ts +200 -0
  238. package/src/lib/server/build-llm.ts +155 -0
  239. package/src/lib/server/capability-router.test.ts +21 -0
  240. package/src/lib/server/capability-router.ts +172 -0
  241. package/src/lib/server/chat-execution.ts +894 -0
  242. package/src/lib/server/clawhub-client.test.ts +161 -0
  243. package/src/lib/server/clawhub-client.ts +26 -0
  244. package/src/lib/server/connectors/connector-routing.test.ts +243 -0
  245. package/src/lib/server/connectors/discord.ts +116 -0
  246. package/src/lib/server/connectors/googlechat.ts +66 -0
  247. package/src/lib/server/connectors/manager.ts +559 -0
  248. package/src/lib/server/connectors/matrix.ts +78 -0
  249. package/src/lib/server/connectors/media.ts +149 -0
  250. package/src/lib/server/connectors/openclaw.test.ts +375 -0
  251. package/src/lib/server/connectors/openclaw.ts +1132 -0
  252. package/src/lib/server/connectors/signal.ts +183 -0
  253. package/src/lib/server/connectors/slack.ts +258 -0
  254. package/src/lib/server/connectors/teams.ts +94 -0
  255. package/src/lib/server/connectors/telegram.ts +221 -0
  256. package/src/lib/server/connectors/types.ts +62 -0
  257. package/src/lib/server/connectors/whatsapp.ts +349 -0
  258. package/src/lib/server/context-manager.ts +232 -0
  259. package/src/lib/server/cost.ts +31 -0
  260. package/src/lib/server/daemon-state.ts +354 -0
  261. package/src/lib/server/data-dir.ts +3 -0
  262. package/src/lib/server/embeddings.ts +111 -0
  263. package/src/lib/server/execution-log.ts +257 -0
  264. package/src/lib/server/gateway/protocol.test.ts +54 -0
  265. package/src/lib/server/gateway/protocol.ts +114 -0
  266. package/src/lib/server/heartbeat-service.ts +366 -0
  267. package/src/lib/server/knowledge-db.test.ts +441 -0
  268. package/src/lib/server/logger.ts +47 -0
  269. package/src/lib/server/main-agent-loop.ts +1017 -0
  270. package/src/lib/server/mcp-client.test.ts +342 -0
  271. package/src/lib/server/mcp-client.ts +130 -0
  272. package/src/lib/server/memory-db.ts +1078 -0
  273. package/src/lib/server/memory-graph.test.ts +153 -0
  274. package/src/lib/server/memory-graph.ts +138 -0
  275. package/src/lib/server/openclaw-health.ts +245 -0
  276. package/src/lib/server/orchestrator-lg.ts +431 -0
  277. package/src/lib/server/orchestrator.ts +364 -0
  278. package/src/lib/server/playwright-proxy.mjs +70 -0
  279. package/src/lib/server/plugins.ts +229 -0
  280. package/src/lib/server/process-manager.ts +327 -0
  281. package/src/lib/server/provider-health.ts +113 -0
  282. package/src/lib/server/queue.ts +859 -0
  283. package/src/lib/server/runtime-settings.ts +119 -0
  284. package/src/lib/server/scheduler.ts +196 -0
  285. package/src/lib/server/session-mailbox.ts +129 -0
  286. package/src/lib/server/session-run-manager.ts +512 -0
  287. package/src/lib/server/session-tools/connector.ts +124 -0
  288. package/src/lib/server/session-tools/context-mgmt.ts +103 -0
  289. package/src/lib/server/session-tools/context.ts +114 -0
  290. package/src/lib/server/session-tools/crud.ts +673 -0
  291. package/src/lib/server/session-tools/delegate.ts +708 -0
  292. package/src/lib/server/session-tools/file.ts +264 -0
  293. package/src/lib/server/session-tools/index.ts +164 -0
  294. package/src/lib/server/session-tools/memory.ts +230 -0
  295. package/src/lib/server/session-tools/session-info.ts +422 -0
  296. package/src/lib/server/session-tools/session-tools-wiring.test.ts +166 -0
  297. package/src/lib/server/session-tools/shell.ts +171 -0
  298. package/src/lib/server/session-tools/web.ts +408 -0
  299. package/src/lib/server/session-tools.ts +9 -0
  300. package/src/lib/server/skills-normalize.ts +130 -0
  301. package/src/lib/server/storage-mcp.test.ts +161 -0
  302. package/src/lib/server/storage.ts +670 -0
  303. package/src/lib/server/stream-agent-chat.ts +571 -0
  304. package/src/lib/server/task-reports.ts +122 -0
  305. package/src/lib/server/task-result.ts +161 -0
  306. package/src/lib/server/task-validation.test.ts +27 -0
  307. package/src/lib/server/task-validation.ts +90 -0
  308. package/src/lib/server/tool-capability-policy.test.ts +58 -0
  309. package/src/lib/server/tool-capability-policy.ts +262 -0
  310. package/src/lib/sessions.ts +68 -0
  311. package/src/lib/tasks.ts +20 -0
  312. package/src/lib/tts.ts +42 -0
  313. package/src/lib/upload.ts +10 -0
  314. package/src/lib/utils.ts +6 -0
  315. package/src/proxy.ts +43 -0
  316. package/src/stores/use-app-store.ts +468 -0
  317. package/src/stores/use-chat-store.ts +323 -0
  318. package/src/types/index.ts +621 -0
  319. package/tsconfig.json +34 -0
@@ -0,0 +1,146 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useState, type ReactNode } from 'react'
4
+
5
+ function extractText(node: ReactNode): string {
6
+ if (typeof node === 'string') return node
7
+ if (typeof node === 'number') return String(node)
8
+ if (!node) return ''
9
+ if (Array.isArray(node)) return node.map(extractText).join('')
10
+ if (typeof node === 'object' && 'props' in node) {
11
+ return extractText((node as any).props.children)
12
+ }
13
+ return ''
14
+ }
15
+
16
+ interface Props {
17
+ children: ReactNode
18
+ className?: string
19
+ }
20
+
21
+ const PREVIEWABLE = new Set(['html', 'htm', 'svg'])
22
+
23
+ export function CodeBlock({ children, className }: Props) {
24
+ const [copied, setCopied] = useState(false)
25
+ const [previewing, setPreviewing] = useState(false)
26
+ const language = className?.replace(/hljs\s*/g, '').replace(/language-/g, '').trim() || ''
27
+ const canPreview = PREVIEWABLE.has(language)
28
+
29
+ const getText = useCallback(() => extractText(children), [children])
30
+
31
+ const handleCopy = useCallback(() => {
32
+ navigator.clipboard.writeText(getText()).then(() => {
33
+ setCopied(true)
34
+ setTimeout(() => setCopied(false), 2000)
35
+ })
36
+ }, [getText])
37
+
38
+ const handlePreview = useCallback(() => {
39
+ setPreviewing((v) => !v)
40
+ }, [])
41
+
42
+ const handleOpenTab = useCallback(() => {
43
+ const text = getText()
44
+ const blob = new Blob([text], { type: language === 'svg' ? 'image/svg+xml' : 'text/html' })
45
+ window.open(URL.createObjectURL(blob), '_blank')
46
+ }, [getText, language])
47
+
48
+ const handleSave = useCallback(() => {
49
+ const text = getText()
50
+ const ext = language || 'txt'
51
+ const blob = new Blob([text], { type: 'text/plain' })
52
+ const a = document.createElement('a')
53
+ a.href = URL.createObjectURL(blob)
54
+ a.download = `code.${ext}`
55
+ a.click()
56
+ }, [getText, language])
57
+
58
+ return (
59
+ <div className="relative group/code">
60
+ <div className="flex items-center justify-between px-4 py-2 bg-black/30 border-b border-white/[0.03]">
61
+ <span className="text-[10px] font-600 uppercase tracking-[0.08em] text-text-3 font-mono">{language}</span>
62
+ <div className="flex items-center gap-1">
63
+ {canPreview && (
64
+ <>
65
+ <button
66
+ onClick={handlePreview}
67
+ className={`flex items-center gap-1.5 text-[10px] font-600 bg-transparent border-none cursor-pointer
68
+ transition-all duration-200 px-2 py-0.5 rounded-[6px]
69
+ ${previewing ? 'text-accent-bright' : 'text-text-3/50 hover:text-text-2 hover:bg-white/[0.04]'}`}
70
+ style={{ fontFamily: 'inherit' }}
71
+ >
72
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
73
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
74
+ <circle cx="12" cy="12" r="3" />
75
+ </svg>
76
+ {previewing ? 'Code' : 'Preview'}
77
+ </button>
78
+ <button
79
+ onClick={handleOpenTab}
80
+ className="flex items-center gap-1.5 text-[10px] font-600 bg-transparent border-none cursor-pointer
81
+ transition-all duration-200 px-2 py-0.5 rounded-[6px] text-text-3/50 hover:text-text-2 hover:bg-white/[0.04]"
82
+ style={{ fontFamily: 'inherit' }}
83
+ title="Open in new tab"
84
+ >
85
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
86
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
87
+ <polyline points="15 3 21 3 21 9" />
88
+ <line x1="10" y1="14" x2="21" y2="3" />
89
+ </svg>
90
+ Open
91
+ </button>
92
+ </>
93
+ )}
94
+ <button
95
+ onClick={handleSave}
96
+ className="flex items-center gap-1.5 text-[10px] font-600 bg-transparent border-none cursor-pointer
97
+ transition-all duration-200 px-2 py-0.5 rounded-[6px] text-text-3/50 hover:text-text-2 hover:bg-white/[0.04]"
98
+ style={{ fontFamily: 'inherit' }}
99
+ >
100
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
101
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
102
+ <polyline points="7 10 12 15 17 10" />
103
+ <line x1="12" y1="15" x2="12" y2="3" />
104
+ </svg>
105
+ Save
106
+ </button>
107
+ <button
108
+ onClick={handleCopy}
109
+ className={`flex items-center gap-1.5 text-[10px] font-600 bg-transparent border-none cursor-pointer
110
+ transition-all duration-200 px-2 py-0.5 rounded-[6px]
111
+ ${copied
112
+ ? 'text-success'
113
+ : 'text-text-3/50 hover:text-text-2 hover:bg-white/[0.04]'}`}
114
+ style={{ fontFamily: 'inherit' }}
115
+ >
116
+ {copied ? (
117
+ <>
118
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><polyline points="20 6 9 17 4 12" /></svg>
119
+ Copied
120
+ </>
121
+ ) : (
122
+ <>
123
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
124
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
125
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
126
+ </svg>
127
+ Copy
128
+ </>
129
+ )}
130
+ </button>
131
+ </div>
132
+ </div>
133
+ {canPreview && previewing ? (
134
+ <iframe
135
+ srcDoc={getText()}
136
+ sandbox="allow-scripts"
137
+ className="w-full border-none bg-white rounded-b-[8px]"
138
+ style={{ minHeight: 300, maxHeight: 600 }}
139
+ title="Code preview"
140
+ />
141
+ ) : (
142
+ <code className={className}>{children}</code>
143
+ )}
144
+ </div>
145
+ )
146
+ }
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+
3
+ import type { DevServerStatus } from '@/types'
4
+
5
+ interface Props {
6
+ status: DevServerStatus | null
7
+ onStop: () => void
8
+ }
9
+
10
+ export function DevServerBar({ status, onStop }: Props) {
11
+ if (!status) return null
12
+
13
+ return (
14
+ <div className="flex items-center gap-2.5 px-4 py-2 bg-success/[0.04] border-b border-white/[0.04] shrink-0">
15
+ <span className="w-[5px] h-[5px] rounded-full bg-success shrink-0"
16
+ style={{ animation: 'pulse 2s ease infinite' }} />
17
+ {status.url ? (
18
+ <a
19
+ href={status.url}
20
+ target="_blank"
21
+ rel="noreferrer"
22
+ className="text-success font-mono text-[11px] flex-1 no-underline hover:underline"
23
+ >
24
+ {status.url}
25
+ </a>
26
+ ) : (
27
+ <span className="text-success font-mono text-[11px] flex-1">Starting...</span>
28
+ )}
29
+ <button
30
+ onClick={onStop}
31
+ className="px-2.5 py-1 rounded-[8px] border border-danger/15 bg-transparent
32
+ text-danger text-[11px] font-600 cursor-pointer hover:bg-danger-soft transition-all duration-200"
33
+ style={{ fontFamily: 'inherit' }}
34
+ >
35
+ Stop
36
+ </button>
37
+ </div>
38
+ )
39
+ }
@@ -0,0 +1,486 @@
1
+ 'use client'
2
+
3
+ import { memo, useState, useCallback, useEffect } from 'react'
4
+ import ReactMarkdown from 'react-markdown'
5
+ import remarkGfm from 'remark-gfm'
6
+ import rehypeHighlight from 'rehype-highlight'
7
+ import type { Message } from '@/types'
8
+ import { useAppStore } from '@/stores/use-app-store'
9
+ import { AiAvatar } from '@/components/shared/avatar'
10
+ import { CodeBlock } from './code-block'
11
+ import { ToolCallBubble } from './tool-call-bubble'
12
+ import { ToolRequestBanner } from './tool-request-banner'
13
+ import { api } from '@/lib/api-client'
14
+
15
+ const FILE_PATH_RE = /^(\/[\w./-]+\.\w{1,10})$/
16
+ const DIR_PATH_RE = /^(\/[\w./-]+)\/?$/
17
+ const PREVIEWABLE_EXT = /\.(html?|svg|css|js|jsx|ts|tsx|json|md|txt|py|sh)$/i
18
+ const SERVEABLE_EXT = /\.(html?|svg|css|js|jsx|ts|tsx)$/i
19
+
20
+ function FilePathChip({ filePath }: { filePath: string }) {
21
+ const canPreview = PREVIEWABLE_EXT.test(filePath)
22
+ const canServe = SERVEABLE_EXT.test(filePath)
23
+ const serveUrl = `/api/files/serve?path=${encodeURIComponent(filePath)}`
24
+
25
+ const [serverState, setServerState] = useState<{
26
+ running: boolean; url?: string; loading: boolean; type?: string; framework?: string
27
+ }>({ running: false, loading: false })
28
+
29
+ // Check if a server is already running for this path on mount
30
+ useEffect(() => {
31
+ if (!canServe) return
32
+ api<{ running: boolean; url?: string; type?: string }>('POST', '/preview-server', { action: 'status', path: filePath })
33
+ .then((res) => { if (res.running) setServerState({ running: true, url: res.url, type: res.type, loading: false }) })
34
+ .catch((err) => console.error('Dev server check failed:', err))
35
+ }, [filePath, canServe])
36
+
37
+ const handleStartServer = async () => {
38
+ setServerState((s) => ({ ...s, loading: true }))
39
+ try {
40
+ const res = await api<{ running: boolean; url?: string; type?: string; framework?: string }>('POST', '/preview-server', { action: 'start', path: filePath })
41
+ setServerState({ running: res.running, url: res.url, type: res.type, framework: res.framework, loading: false })
42
+ } catch {
43
+ setServerState((s) => ({ ...s, loading: false }))
44
+ }
45
+ }
46
+
47
+ const handleStopServer = async () => {
48
+ setServerState((s) => ({ ...s, loading: true }))
49
+ try {
50
+ await api('POST', '/preview-server', { action: 'stop', path: filePath })
51
+ setServerState({ running: false, loading: false })
52
+ } catch {
53
+ setServerState((s) => ({ ...s, loading: false }))
54
+ }
55
+ }
56
+
57
+ const frameworkLabel = serverState.framework
58
+ ? serverState.framework.charAt(0).toUpperCase() + serverState.framework.slice(1)
59
+ : null
60
+
61
+ return (
62
+ <span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-[8px] bg-white/[0.06] border border-white/[0.08] font-mono text-[13px]">
63
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/50 shrink-0">
64
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
65
+ <polyline points="14 2 14 8 20 8" />
66
+ </svg>
67
+ <span className="text-sky-400">{filePath}</span>
68
+ {canPreview && !serverState.running && (
69
+ <a
70
+ href={serveUrl}
71
+ target="_blank"
72
+ rel="noopener noreferrer"
73
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-white/[0.06] hover:bg-white/[0.10] text-[10px] font-600 text-text-3 hover:text-text-2 no-underline transition-colors cursor-pointer"
74
+ title="Open file"
75
+ >
76
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
77
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
78
+ <polyline points="15 3 21 3 21 9" />
79
+ <line x1="10" y1="14" x2="21" y2="3" />
80
+ </svg>
81
+ Open
82
+ </a>
83
+ )}
84
+ {canServe && !serverState.running && (
85
+ <button
86
+ onClick={handleStartServer}
87
+ disabled={serverState.loading}
88
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-400 text-[10px] font-600 border-none cursor-pointer transition-colors disabled:opacity-50"
89
+ title="Start preview server — auto-detects npm projects (React, Next, Vite, etc.) and runs the dev command"
90
+ >
91
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
92
+ <polygon points="5 3 19 12 5 21 5 3" />
93
+ </svg>
94
+ {serverState.loading ? 'Starting...' : 'Serve'}
95
+ </button>
96
+ )}
97
+ {canServe && serverState.running && (
98
+ <>
99
+ {frameworkLabel && (
100
+ <span className="px-1.5 py-0.5 rounded-[4px] bg-indigo-500/15 text-indigo-300 text-[9px] font-700 uppercase tracking-wider">
101
+ {frameworkLabel}
102
+ </span>
103
+ )}
104
+ {serverState.type === 'npm' && (
105
+ <span className="px-1.5 py-0.5 rounded-[4px] bg-amber-500/15 text-amber-300 text-[9px] font-700 uppercase tracking-wider">
106
+ npm
107
+ </span>
108
+ )}
109
+ <a
110
+ href={serverState.url}
111
+ target="_blank"
112
+ rel="noopener noreferrer"
113
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-400 text-[10px] font-600 no-underline transition-colors"
114
+ title="Open preview server"
115
+ >
116
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" style={{ animation: 'pulse 2s ease infinite' }} />
117
+ {serverState.url}
118
+ </a>
119
+ <button
120
+ onClick={handleStopServer}
121
+ disabled={serverState.loading}
122
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-red-500/15 hover:bg-red-500/25 text-red-400 text-[10px] font-600 border-none cursor-pointer transition-colors disabled:opacity-50"
123
+ title="Stop preview server"
124
+ >
125
+ <svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor">
126
+ <rect x="4" y="4" width="16" height="16" rx="2" />
127
+ </svg>
128
+ Stop
129
+ </button>
130
+ </>
131
+ )}
132
+ </span>
133
+ )
134
+ }
135
+
136
+ function fmtTime(ts: number): string {
137
+ return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
138
+ }
139
+
140
+ function relativeTime(ts: number): string {
141
+ const now = Date.now()
142
+ const diff = now - ts
143
+ if (diff < 60_000) return 'just now'
144
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
145
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
146
+ const d = new Date(ts)
147
+ const today = new Date()
148
+ if (d.toDateString() === today.toDateString()) return fmtTime(ts)
149
+ if (diff < 604_800_000) return d.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' })
150
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
151
+ }
152
+
153
+ function heartbeatSummary(text: string): string {
154
+ const clean = (text || '')
155
+ .replace(/\bHEARTBEAT_OK\b/gi, '')
156
+ .replace(/\*\*(.*?)\*\*/g, '$1')
157
+ .replace(/\*(.*?)\*/g, '$1')
158
+ .replace(/`([^`]+)`/g, '$1')
159
+ .replace(/\[(.*?)\]\([^)]+\)/g, '$1')
160
+ .replace(/\bHeartbeat Response\s*:\s*/gi, '')
161
+ .replace(/\bCurrent (State|Status)\s*:\s*/gi, '')
162
+ .replace(/\bRecent Progress\s*:\s*/gi, '')
163
+ .replace(/\bNext (Step|Immediate Step)\s*:\s*/gi, '')
164
+ .replace(/\bStatus\s*:\s*/gi, '')
165
+ .replace(/\s+/g, ' ')
166
+ .trim()
167
+ if (!clean) return 'No new status update.'
168
+ return clean.length > 180 ? `${clean.slice(0, 180)}...` : clean
169
+ }
170
+
171
+ interface Props {
172
+ message: Message
173
+ assistantName?: string
174
+ isLast?: boolean
175
+ onRetry?: () => void
176
+ }
177
+
178
+ export const MessageBubble = memo(function MessageBubble({ message, assistantName, isLast, onRetry }: Props) {
179
+ const isUser = message.role === 'user'
180
+ const isHeartbeat = !isUser && (message.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(message.text || ''))
181
+ const currentUser = useAppStore((s) => s.currentUser)
182
+ const [copied, setCopied] = useState(false)
183
+ const [heartbeatExpanded, setHeartbeatExpanded] = useState(false)
184
+ const [toolEventsExpanded, setToolEventsExpanded] = useState(false)
185
+ const toolEvents = message.toolEvents || []
186
+ const hasToolEvents = !isUser && toolEvents.length > 0
187
+ const visibleToolEvents = toolEventsExpanded ? [...toolEvents].reverse() : toolEvents.slice(-1)
188
+
189
+ const handleCopy = useCallback(() => {
190
+ navigator.clipboard.writeText(message.text).then(() => {
191
+ setCopied(true)
192
+ setTimeout(() => setCopied(false), 2000)
193
+ })
194
+ }, [message.text])
195
+
196
+ return (
197
+ <div
198
+ className={`group ${isUser ? 'flex flex-col items-end' : 'flex flex-col items-start'}`}
199
+ style={{ animation: `${isUser ? 'msg-in-right' : 'msg-in-left'} 0.35s cubic-bezier(0.16, 1, 0.3, 1)` }}
200
+ >
201
+ {/* Sender label + timestamp */}
202
+ <div className={`flex items-center gap-2.5 mb-2 px-1 ${isUser ? 'flex-row-reverse' : ''}`}>
203
+ {!isUser && <AiAvatar size="sm" />}
204
+ <span className={`text-[12px] font-600 ${isUser ? 'text-accent-bright/70' : 'text-text-3'}`}>
205
+ {isUser ? (currentUser ? currentUser.charAt(0).toUpperCase() + currentUser.slice(1) : 'You') : (assistantName || 'Claude')}
206
+ </span>
207
+ <span className="text-[11px] text-text-3/70 font-mono" title={message.time ? new Date(message.time).toLocaleString() : ''}>
208
+ {message.time ? relativeTime(message.time) : ''}
209
+ </span>
210
+ </div>
211
+
212
+ {/* Tool call events (assistant messages only) */}
213
+ {hasToolEvents && (
214
+ <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
215
+ {toolEvents.length > 1 && (
216
+ <button
217
+ type="button"
218
+ onClick={() => setToolEventsExpanded((v) => !v)}
219
+ 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"
220
+ >
221
+ {toolEventsExpanded ? 'Show latest only' : `Show all tool calls (${toolEvents.length})`}
222
+ </button>
223
+ )}
224
+ <div className={`${toolEventsExpanded ? 'max-h-[320px] overflow-y-auto pr-1 flex flex-col gap-2' : 'flex flex-col gap-2'}`}>
225
+ {visibleToolEvents.map((event, i) => (
226
+ <ToolCallBubble
227
+ key={`${message.time}-tool-${toolEventsExpanded ? `all-${i}` : `latest-${toolEvents.length - 1}`}`}
228
+ event={{
229
+ id: `${message.time}-${toolEventsExpanded ? i : toolEvents.length - 1}`,
230
+ name: event.name,
231
+ input: event.input,
232
+ output: event.output,
233
+ status: event.error ? 'error' : 'done',
234
+ }}
235
+ />
236
+ ))}
237
+ </div>
238
+ </div>
239
+ )}
240
+
241
+ {/* Message bubble */}
242
+ <div className={`max-w-[85%] md:max-w-[72%] ${isUser ? 'bubble-user px-5 py-3.5' : isHeartbeat ? 'bubble-ai px-4 py-3' : 'bubble-ai px-5 py-3.5'}`}>
243
+ {(message.imagePath || message.imageUrl) && (() => {
244
+ const url = message.imageUrl || `/api/uploads/${message.imagePath?.split('/').pop()}`
245
+ const rawName = message.imagePath?.split('/').pop() || message.imageUrl?.split('/').pop() || 'file'
246
+ const filename = rawName.replace(/^[a-f0-9]+-/, '').split('?')[0]
247
+ const isImage = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/i.test(filename)
248
+ if (isImage) {
249
+ return (
250
+ <img src={url} alt="Attached" className="max-w-[240px] rounded-[12px] mb-3 border border-white/10"
251
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
252
+ )
253
+ }
254
+ const isPreviewable = /\.(html?|svg)$/i.test(filename)
255
+ return (
256
+ <div className="flex items-center gap-3 px-4 py-3 mb-3 rounded-[12px] border border-white/10 bg-white/[0.03]">
257
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3 shrink-0">
258
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
259
+ <polyline points="14 2 14 8 20 8" />
260
+ </svg>
261
+ <span className="text-[13px] text-text-2 font-500 truncate flex-1">{filename}</span>
262
+ {isPreviewable && (
263
+ <a href={url} target="_blank" rel="noopener noreferrer"
264
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-accent-soft hover:bg-accent-soft/80 text-accent-bright text-[11px] font-600 no-underline transition-colors shrink-0"
265
+ title="Preview in new tab">
266
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
267
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
268
+ <circle cx="12" cy="12" r="3" />
269
+ </svg>
270
+ Preview
271
+ </a>
272
+ )}
273
+ <a href={url} download={filename}
274
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-white/[0.06] hover:bg-white/[0.10] text-text-3 text-[11px] font-600 no-underline transition-colors shrink-0">
275
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
276
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
277
+ <polyline points="7 10 12 15 17 10" />
278
+ <line x1="12" y1="15" x2="12" y2="3" />
279
+ </svg>
280
+ Download
281
+ </a>
282
+ </div>
283
+ )
284
+ })()}
285
+
286
+ {isHeartbeat ? (
287
+ <div className="flex flex-col gap-2">
288
+ <button
289
+ type="button"
290
+ onClick={() => setHeartbeatExpanded((v) => !v)}
291
+ className="w-full rounded-[12px] px-3.5 py-3 border border-white/[0.10] bg-white/[0.02] text-left hover:bg-white/[0.04] transition-colors cursor-pointer"
292
+ >
293
+ <div className="flex items-center justify-between gap-3">
294
+ <div className="flex items-center gap-2">
295
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
296
+ <span className="text-[11px] uppercase tracking-[0.08em] text-text-2 font-600">Heartbeat</span>
297
+ </div>
298
+ <span className="text-[11px] text-text-3">{heartbeatExpanded ? 'Collapse' : 'Expand'}</span>
299
+ </div>
300
+ <p className="text-[13px] text-text-2/90 leading-[1.5] mt-1.5">{heartbeatSummary(message.text)}</p>
301
+ </button>
302
+ {heartbeatExpanded && (
303
+ <div className="msg-content text-[14px] leading-[1.7] text-text break-words px-3 py-2 rounded-[10px] border border-white/[0.08] bg-black/20">
304
+ <ReactMarkdown
305
+ remarkPlugins={[remarkGfm]}
306
+ rehypePlugins={[rehypeHighlight]}
307
+ components={{
308
+ pre({ children }) {
309
+ return <pre>{children}</pre>
310
+ },
311
+ code({ className, children }) {
312
+ const isBlock = className?.startsWith('language-') || className?.startsWith('hljs')
313
+ if (isBlock) return <CodeBlock className={className}>{children}</CodeBlock>
314
+ return <code className={className}>{children}</code>
315
+ },
316
+ }}
317
+ >
318
+ {message.text}
319
+ </ReactMarkdown>
320
+ </div>
321
+ )}
322
+ </div>
323
+ ) : (
324
+ <div className={`msg-content text-[15px] break-words ${isUser ? 'leading-[1.6] text-white/95' : 'leading-[1.7] text-text'}`}>
325
+ <ReactMarkdown
326
+ remarkPlugins={[remarkGfm]}
327
+ rehypePlugins={[rehypeHighlight]}
328
+ components={{
329
+ pre({ children }) {
330
+ return <pre>{children}</pre>
331
+ },
332
+ code({ className, children }) {
333
+ const isBlock = className?.startsWith('language-') || className?.startsWith('hljs')
334
+ if (isBlock) {
335
+ return <CodeBlock className={className}>{children}</CodeBlock>
336
+ }
337
+ // Detect file/dir paths in inline code and make them interactive
338
+ const text = typeof children === 'string' ? children : ''
339
+ if (text && (FILE_PATH_RE.test(text) || (DIR_PATH_RE.test(text) && text.split('/').length > 2))) {
340
+ return <FilePathChip filePath={text.replace(/\/$/, '')} />
341
+ }
342
+ return <code className={className}>{children}</code>
343
+ },
344
+ img({ src, alt }) {
345
+ if (!src || typeof src !== 'string') return null
346
+ const isVideo = /\.(mp4|webm|mov|avi)$/i.test(src)
347
+ if (isVideo) {
348
+ return (
349
+ <video src={src} controls className="max-w-full rounded-[10px] border border-white/10 my-2" />
350
+ )
351
+ }
352
+ return (
353
+ <a href={src} download target="_blank" rel="noopener noreferrer" className="block my-2">
354
+ <img src={src} alt={alt || 'File'} className="max-w-full rounded-[10px] border border-white/10 hover:border-white/25 transition-colors cursor-pointer" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
355
+ </a>
356
+ )
357
+ },
358
+ a({ href, children }) {
359
+ if (!href) return <>{children}</>
360
+ // Internal app links: #task:<id> and #schedule:<id>
361
+ const taskMatch = href.match(/^#task:(.+)$/)
362
+ if (taskMatch) {
363
+ return (
364
+ <button
365
+ type="button"
366
+ onClick={async () => {
367
+ const store = useAppStore.getState()
368
+ await store.loadTasks(true)
369
+ store.setEditingTaskId(taskMatch[1])
370
+ store.setTaskSheetOpen(true)
371
+ }}
372
+ className="inline-flex items-center gap-1 text-purple-400 hover:text-purple-300 underline cursor-pointer bg-transparent border-none p-0 font-inherit text-inherit"
373
+ >
374
+ {children}
375
+ </button>
376
+ )
377
+ }
378
+ const schedMatch = href.match(/^#schedule:(.+)$/)
379
+ if (schedMatch) {
380
+ return (
381
+ <button
382
+ type="button"
383
+ onClick={async () => {
384
+ const store = useAppStore.getState()
385
+ await store.loadSchedules()
386
+ store.setEditingScheduleId(schedMatch[1])
387
+ store.setScheduleSheetOpen(true)
388
+ }}
389
+ className="inline-flex items-center gap-1 text-amber-400 hover:text-amber-300 underline cursor-pointer bg-transparent border-none p-0 font-inherit text-inherit"
390
+ >
391
+ {children}
392
+ </button>
393
+ )
394
+ }
395
+ const isUpload = href.startsWith('/api/uploads/')
396
+ if (isUpload) {
397
+ const uploadIsHtml = /\.(html?|svg)$/i.test(href.split('?')[0])
398
+ return (
399
+ <span className="inline-flex items-center gap-1.5">
400
+ <a href={href} download className="inline-flex items-center gap-1.5 text-sky-400 hover:text-sky-300 underline">
401
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0">
402
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
403
+ <polyline points="7 10 12 15 17 10" />
404
+ <line x1="12" y1="15" x2="12" y2="3" />
405
+ </svg>
406
+ {children}
407
+ </a>
408
+ {uploadIsHtml && (
409
+ <a href={href} target="_blank" rel="noopener noreferrer"
410
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-accent-soft hover:bg-accent-soft/80 text-accent-bright text-[10px] font-600 no-underline transition-colors"
411
+ title="Preview in new tab">
412
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
413
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
414
+ <circle cx="12" cy="12" r="3" />
415
+ </svg>
416
+ Preview
417
+ </a>
418
+ )}
419
+ </span>
420
+ )
421
+ }
422
+ // YouTube embed
423
+ const ytMatch = href.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/)
424
+ if (ytMatch) {
425
+ return (
426
+ <div className="my-2">
427
+ <iframe
428
+ src={`https://www.youtube-nocookie.com/embed/${ytMatch[1]}`}
429
+ className="w-full aspect-video rounded-[10px] border border-white/10"
430
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
431
+ allowFullScreen
432
+ title="YouTube video"
433
+ />
434
+ </div>
435
+ )
436
+ }
437
+ return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
438
+ },
439
+ }}
440
+ >
441
+ {message.text}
442
+ </ReactMarkdown>
443
+ </div>
444
+ )}
445
+ </div>
446
+
447
+ {/* Tool access request banners */}
448
+ {!isUser && <ToolRequestBanner
449
+ text={message.text || ''}
450
+ toolOutputs={toolEvents.map((e) => e.output || '').filter(Boolean)}
451
+ />}
452
+
453
+ {/* Action buttons */}
454
+ <div className={`flex items-center gap-1 mt-1.5 px-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ${isUser ? 'justify-end' : ''}`}>
455
+ <button
456
+ onClick={handleCopy}
457
+ aria-label="Copy message"
458
+ className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
459
+ text-[11px] font-500 text-text-3 cursor-pointer hover:text-text-2 hover:bg-white/[0.04] transition-all"
460
+ style={{ fontFamily: 'inherit' }}
461
+ >
462
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
463
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
464
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
465
+ </svg>
466
+ {copied ? 'Copied' : 'Copy'}
467
+ </button>
468
+ {!isUser && isLast && onRetry && (
469
+ <button
470
+ onClick={onRetry}
471
+ aria-label="Retry message"
472
+ className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
473
+ text-[11px] font-500 text-text-3 cursor-pointer hover:text-text-2 hover:bg-white/[0.04] transition-all"
474
+ style={{ fontFamily: 'inherit' }}
475
+ >
476
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
477
+ <polyline points="23 4 23 10 17 10" />
478
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
479
+ </svg>
480
+ Retry
481
+ </button>
482
+ )}
483
+ </div>
484
+ </div>
485
+ )
486
+ })