@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,299 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import type { Message } from '@/types'
5
+ import { useChatStore } from '@/stores/use-chat-store'
6
+ import { useAppStore } from '@/stores/use-app-store'
7
+ import { MessageBubble } from './message-bubble'
8
+ import { StreamingBubble } from './streaming-bubble'
9
+ import { ThinkingIndicator } from './thinking-indicator'
10
+
11
+ function dateSeparator(ts: number): string {
12
+ const d = new Date(ts)
13
+ const today = new Date()
14
+ const yesterday = new Date()
15
+ yesterday.setDate(today.getDate() - 1)
16
+ if (d.toDateString() === today.toDateString()) return 'Today'
17
+ if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'
18
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
19
+ }
20
+
21
+ interface Props {
22
+ messages: Message[]
23
+ streaming: boolean
24
+ }
25
+
26
+ export function MessageList({ messages, streaming }: Props) {
27
+ const scrollRef = useRef<HTMLDivElement>(null)
28
+ const [showScrollToBottom, setShowScrollToBottom] = useState(false)
29
+ const needsSnapRef = useRef(true)
30
+ const prevSessionIdRef = useRef<string | null>(null)
31
+ const streamText = useChatStore((s) => s.streamText)
32
+ const retryLastMessage = useChatStore((s) => s.retryLastMessage)
33
+ const session = useAppStore((s) => {
34
+ const id = s.currentSessionId
35
+ return id ? s.sessions[id] : null
36
+ })
37
+ const agents = useAppStore((s) => s.agents)
38
+ const agent = session?.agentId ? agents[session.agentId] : null
39
+ const appSettings = useAppStore((s) => s.appSettings)
40
+ const assistantName = agent?.name
41
+ || (session?.provider === 'claude-cli' ? undefined : session?.model || session?.provider)
42
+ || undefined
43
+
44
+ const showOk = appSettings.heartbeatShowOk ?? false
45
+ const showAlerts = appSettings.heartbeatShowAlerts ?? true
46
+
47
+ // Unread count tracking
48
+ const unreadRef = useRef(0)
49
+ const [unreadCount, setUnreadCount] = useState(0)
50
+ const prevMsgCountRef = useRef(messages.length)
51
+
52
+ // In-thread search
53
+ const [searchOpen, setSearchOpen] = useState(false)
54
+ const [searchQuery, setSearchQuery] = useState('')
55
+ const [searchIdx, setSearchIdx] = useState(0)
56
+ const searchInputRef = useRef<HTMLInputElement>(null)
57
+
58
+ const isHeartbeatMessage = (msg: Message) =>
59
+ msg.role === 'assistant' && (msg.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(msg.text || ''))
60
+ const isHeartbeatOk = (msg: Message) =>
61
+ msg.suppressed === true || (msg.kind === 'heartbeat' && /^\s*HEARTBEAT_OK\b/i.test(msg.text || ''))
62
+
63
+ const displayedMessages: Message[] = []
64
+ for (const msg of messages) {
65
+ const isHeartbeat = isHeartbeatMessage(msg)
66
+
67
+ // Visibility filtering based on settings
68
+ if (isHeartbeat) {
69
+ if (!showAlerts) continue // Hide all heartbeat messages
70
+ if (!showOk && isHeartbeatOk(msg)) continue // Hide OK messages
71
+ }
72
+
73
+ const last = displayedMessages[displayedMessages.length - 1]
74
+ const lastIsHeartbeat = !!last && isHeartbeatMessage(last)
75
+ if (isHeartbeat && lastIsHeartbeat) {
76
+ displayedMessages[displayedMessages.length - 1] = msg
77
+ } else {
78
+ displayedMessages.push(msg)
79
+ }
80
+ }
81
+
82
+ // Search matches
83
+ const searchMatches = searchQuery.trim()
84
+ ? displayedMessages
85
+ .map((msg, i) => ({ msg, i }))
86
+ .filter(({ msg }) => msg.text.toLowerCase().includes(searchQuery.toLowerCase()))
87
+ : []
88
+
89
+ // Track whether user is at/near bottom so we know whether to auto-scroll on new content
90
+ const wasAtBottomRef = useRef(true)
91
+
92
+ const isNearBottom = useCallback((el: HTMLDivElement) => {
93
+ return el.scrollHeight - el.scrollTop - el.clientHeight < 200
94
+ }, [])
95
+
96
+ const updateScrollState = useCallback(() => {
97
+ const el = scrollRef.current
98
+ if (!el) return
99
+ const nearBottom = isNearBottom(el)
100
+ wasAtBottomRef.current = nearBottom
101
+ setShowScrollToBottom(!nearBottom)
102
+ if (nearBottom && unreadRef.current > 0) {
103
+ unreadRef.current = 0
104
+ setUnreadCount(0)
105
+ }
106
+ }, [isNearBottom])
107
+
108
+ // Track unread messages arriving while scrolled up
109
+ useEffect(() => {
110
+ const newCount = messages.length - prevMsgCountRef.current
111
+ prevMsgCountRef.current = messages.length
112
+ if (newCount > 0 && scrollRef.current && !isNearBottom(scrollRef.current)) {
113
+ unreadRef.current += newCount
114
+ setUnreadCount(unreadRef.current)
115
+ }
116
+ }, [messages.length, isNearBottom])
117
+
118
+ // Detect session switch during render (no extra useEffect, no dep-array mismatch)
119
+ const sessionId = session?.id ?? null
120
+ if (sessionId !== prevSessionIdRef.current) {
121
+ prevSessionIdRef.current = sessionId
122
+ needsSnapRef.current = true
123
+ wasAtBottomRef.current = true
124
+ }
125
+
126
+ useEffect(() => {
127
+ const el = scrollRef.current
128
+ if (!el) return
129
+ if (needsSnapRef.current && messages.length > 0) {
130
+ // First render after session switch — snap instantly, no visible scroll
131
+ needsSnapRef.current = false
132
+ el.scrollTop = el.scrollHeight
133
+ setShowScrollToBottom(false)
134
+ wasAtBottomRef.current = true
135
+ return
136
+ }
137
+ // Auto-scroll if user was at bottom before new content arrived
138
+ if (wasAtBottomRef.current) {
139
+ el.scrollTop = el.scrollHeight
140
+ }
141
+ updateScrollState()
142
+ }, [messages.length, streamText, isNearBottom, updateScrollState])
143
+
144
+ const handleScrollToBottom = useCallback(() => {
145
+ const el = scrollRef.current
146
+ if (!el) return
147
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
148
+ setShowScrollToBottom(false)
149
+ unreadRef.current = 0
150
+ setUnreadCount(0)
151
+ }, [])
152
+
153
+ useEffect(() => {
154
+ if (typeof window === 'undefined') return
155
+ const handler = () => handleScrollToBottom()
156
+ window.addEventListener('swarmclaw:scroll-bottom', handler)
157
+ return () => window.removeEventListener('swarmclaw:scroll-bottom', handler)
158
+ }, [handleScrollToBottom])
159
+
160
+ // Ctrl+F search toggle
161
+ useEffect(() => {
162
+ const handler = (e: KeyboardEvent) => {
163
+ if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
164
+ e.preventDefault()
165
+ setSearchOpen((v) => {
166
+ if (!v) setTimeout(() => searchInputRef.current?.focus(), 50)
167
+ else { setSearchQuery(''); setSearchIdx(0) }
168
+ return !v
169
+ })
170
+ }
171
+ if (e.key === 'Escape' && searchOpen) {
172
+ setSearchOpen(false)
173
+ setSearchQuery('')
174
+ setSearchIdx(0)
175
+ }
176
+ }
177
+ window.addEventListener('keydown', handler)
178
+ return () => window.removeEventListener('keydown', handler)
179
+ }, [searchOpen])
180
+
181
+ return (
182
+ <div className="relative flex-1 min-h-0">
183
+ {/* In-thread search bar */}
184
+ {searchOpen && (
185
+ <div className="absolute top-0 left-0 right-0 z-20 flex items-center gap-2 px-6 md:px-12 lg:px-16 py-2 bg-surface/95 backdrop-blur-sm border-b border-white/[0.06]">
186
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3 shrink-0">
187
+ <circle cx="11" cy="11" r="8" />
188
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
189
+ </svg>
190
+ <input
191
+ ref={searchInputRef}
192
+ type="text"
193
+ value={searchQuery}
194
+ onChange={(e) => { setSearchQuery(e.target.value); setSearchIdx(0) }}
195
+ placeholder="Search in conversation..."
196
+ className="flex-1 bg-transparent text-text text-[13px] outline-none placeholder:text-text-3/50"
197
+ style={{ fontFamily: 'inherit' }}
198
+ onKeyDown={(e) => {
199
+ if (e.key === 'Enter') {
200
+ e.preventDefault()
201
+ if (e.shiftKey) setSearchIdx((v) => Math.max(0, v - 1))
202
+ else setSearchIdx((v) => Math.min(searchMatches.length - 1, v + 1))
203
+ }
204
+ }}
205
+ />
206
+ {searchQuery && (
207
+ <span className="text-[11px] text-text-3 tabular-nums shrink-0">
208
+ {searchMatches.length > 0 ? `${searchIdx + 1}/${searchMatches.length}` : '0 results'}
209
+ </span>
210
+ )}
211
+ <button
212
+ onClick={() => setSearchIdx((v) => Math.max(0, v - 1))}
213
+ disabled={!searchMatches.length}
214
+ aria-label="Previous match"
215
+ className="p-1 rounded-[6px] text-text-3 hover:text-text-2 hover:bg-white/[0.04] disabled:opacity-30 cursor-pointer border-none bg-transparent transition-colors"
216
+ >
217
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="m18 15-6-6-6 6" /></svg>
218
+ </button>
219
+ <button
220
+ onClick={() => setSearchIdx((v) => Math.min(searchMatches.length - 1, v + 1))}
221
+ disabled={!searchMatches.length}
222
+ aria-label="Next match"
223
+ className="p-1 rounded-[6px] text-text-3 hover:text-text-2 hover:bg-white/[0.04] disabled:opacity-30 cursor-pointer border-none bg-transparent transition-colors"
224
+ >
225
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="m6 9 6 6 6-6" /></svg>
226
+ </button>
227
+ <button
228
+ onClick={() => { setSearchOpen(false); setSearchQuery(''); setSearchIdx(0) }}
229
+ aria-label="Close search"
230
+ className="p-1 rounded-[6px] text-text-3 hover:text-text-2 hover:bg-white/[0.04] cursor-pointer border-none bg-transparent transition-colors"
231
+ >
232
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
233
+ </button>
234
+ </div>
235
+ )}
236
+
237
+ <div
238
+ ref={scrollRef}
239
+ onScroll={updateScrollState}
240
+ className="h-full overflow-y-auto px-6 md:px-12 lg:px-16 py-6"
241
+ >
242
+ <div className="flex flex-col gap-6">
243
+ {displayedMessages.map((msg, i) => {
244
+ const isLastAssistant = msg.role === 'assistant' && !streaming
245
+ && displayedMessages.slice(i + 1).every((m) => m.role !== 'assistant')
246
+ const isSearchMatch = searchQuery && searchMatches.some((m) => m.i === i)
247
+ const isCurrentMatch = searchQuery && searchMatches[searchIdx]?.i === i
248
+
249
+ // Date separator
250
+ const prevMsg = i > 0 ? displayedMessages[i - 1] : null
251
+ const showDateSep = msg.time && (!prevMsg?.time || new Date(msg.time).toDateString() !== new Date(prevMsg.time).toDateString())
252
+
253
+ return (
254
+ <div key={`${msg.time}-${i}`}>
255
+ {showDateSep && (
256
+ <div className="flex items-center gap-4 py-2 mb-2">
257
+ <div className="flex-1 h-px bg-white/[0.06]" />
258
+ <span className="text-[10px] font-600 text-text-3/50 uppercase tracking-[0.1em]">
259
+ {dateSeparator(msg.time)}
260
+ </span>
261
+ <div className="flex-1 h-px bg-white/[0.06]" />
262
+ </div>
263
+ )}
264
+ <div className={isCurrentMatch ? 'ring-1 ring-amber-400/50 rounded-[16px] bg-amber-400/[0.04]' : isSearchMatch ? 'bg-white/[0.02] rounded-[16px]' : ''}>
265
+ <MessageBubble
266
+ message={msg}
267
+ assistantName={assistantName}
268
+ isLast={isLastAssistant}
269
+ onRetry={isLastAssistant ? retryLastMessage : undefined}
270
+ />
271
+ </div>
272
+ </div>
273
+ )
274
+ })}
275
+ {streaming && !streamText && <ThinkingIndicator assistantName={assistantName} />}
276
+ {streaming && streamText && <StreamingBubble text={streamText} assistantName={assistantName} />}
277
+ </div>
278
+ </div>
279
+ {showScrollToBottom && (
280
+ <button
281
+ onClick={handleScrollToBottom}
282
+ className="absolute right-6 md:right-12 lg:right-16 bottom-5 inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-white/[0.08] bg-[#171a2b]/95 text-text-2 text-[12px] font-600 hover:bg-[#1e2238] transition-colors shadow-lg cursor-pointer"
283
+ title="Scroll to latest messages"
284
+ >
285
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
286
+ <path d="M12 5v14" />
287
+ <path d="m19 12-7 7-7-7" />
288
+ </svg>
289
+ Latest
290
+ {unreadCount > 0 && (
291
+ <span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-accent-bright text-white text-[10px] font-700">
292
+ {unreadCount}
293
+ </span>
294
+ )}
295
+ </button>
296
+ )}
297
+ </div>
298
+ )
299
+ }
@@ -0,0 +1,196 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import type { Message } from '@/types'
5
+ import { IconButton } from '@/components/shared/icon-button'
6
+
7
+ interface Props {
8
+ messages: Message[]
9
+ open: boolean
10
+ onClose: () => void
11
+ }
12
+
13
+ type EventType = 'user' | 'assistant' | 'delegation' | 'agent_result' | 'system' | 'error' | 'tool_call'
14
+
15
+ interface DebugEvent {
16
+ type: EventType
17
+ label: string
18
+ detail: string
19
+ time: number
20
+ }
21
+
22
+ function classifyMessage(msg: Message): DebugEvent {
23
+ const text = msg.text || ''
24
+
25
+ if (msg.role === 'user') {
26
+ if (text.startsWith('[System]')) {
27
+ return { type: 'system', label: 'System', detail: text.replace('[System] ', ''), time: msg.time }
28
+ }
29
+ if (text.startsWith('[Agent ')) {
30
+ const match = text.match(/\[Agent (.+?) result\]/)
31
+ return { type: 'agent_result', label: `Agent: ${match?.[1] || 'Unknown'}`, detail: text.replace(/\[Agent .+? result\]:?\n?/, ''), time: msg.time }
32
+ }
33
+ if (text.startsWith('[Memory search')) {
34
+ return { type: 'system', label: 'Memory Search', detail: text.replace('[Memory search results]:\n', ''), time: msg.time }
35
+ }
36
+ return { type: 'user', label: 'User', detail: text, time: msg.time }
37
+ }
38
+
39
+ // assistant
40
+ if (text.startsWith('[Delegating to ')) {
41
+ const match = text.match(/\[Delegating to (.+?)\]/)
42
+ return { type: 'delegation', label: `Delegate: ${match?.[1] || 'Unknown'}`, detail: text.replace(/\[Delegating to .+?\]:?\s?/, ''), time: msg.time }
43
+ }
44
+ if (text.startsWith('[Error]')) {
45
+ return { type: 'error', label: 'Error', detail: text.replace('[Error] ', ''), time: msg.time }
46
+ }
47
+ if (text.startsWith('Starting task:')) {
48
+ return { type: 'system', label: 'Task Start', detail: text, time: msg.time }
49
+ }
50
+ return { type: 'assistant', label: 'Assistant', detail: text, time: msg.time }
51
+ }
52
+
53
+ const TYPE_COLORS: Record<EventType, string> = {
54
+ user: '#6366F1',
55
+ assistant: '#a0a0b0',
56
+ delegation: '#F59E0B',
57
+ agent_result: '#10B981',
58
+ system: '#6B7280',
59
+ error: '#EF4444',
60
+ tool_call: '#8B5CF6',
61
+ }
62
+
63
+ const TYPE_ICONS: Record<EventType, string> = {
64
+ user: 'U',
65
+ assistant: 'AI',
66
+ delegation: 'D',
67
+ agent_result: 'R',
68
+ system: 'S',
69
+ error: '!',
70
+ tool_call: 'T',
71
+ }
72
+
73
+ function fmtTime(ts: number) {
74
+ return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
75
+ }
76
+
77
+ export function SessionDebugPanel({ messages, open, onClose }: Props) {
78
+ const [filter, setFilter] = useState<EventType | 'all'>('all')
79
+ const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
80
+
81
+ const events = messages.map(classifyMessage)
82
+ const filtered = filter === 'all' ? events : events.filter((e) => e.type === filter)
83
+
84
+ // Auto-scroll to bottom
85
+ useEffect(() => {
86
+ setExpandedIdx(null)
87
+ }, [messages.length])
88
+
89
+ if (!open) return null
90
+
91
+ const filters: { id: EventType | 'all'; label: string }[] = [
92
+ { id: 'all', label: 'All' },
93
+ { id: 'delegation', label: 'Delegations' },
94
+ { id: 'agent_result', label: 'Results' },
95
+ { id: 'error', label: 'Errors' },
96
+ { id: 'system', label: 'System' },
97
+ ]
98
+
99
+ return (
100
+ <div className="absolute inset-0 z-30 bg-bg/95 backdrop-blur-xl flex flex-col">
101
+ {/* Header */}
102
+ <div className="flex items-center gap-3 px-5 py-3 border-b border-white/[0.06] shrink-0">
103
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6366F1" strokeWidth="2" strokeLinecap="round">
104
+ <path d="M12 20V10" />
105
+ <path d="M18 20V4" />
106
+ <path d="M6 20v-4" />
107
+ </svg>
108
+ <span className="font-display text-[16px] font-600 tracking-[-0.02em] flex-1">Session Debug</span>
109
+ <span className="text-[12px] text-text-3 font-mono">{events.length} events</span>
110
+ <IconButton onClick={onClose} aria-label="Close debug panel">
111
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
112
+ <line x1="18" y1="6" x2="6" y2="18" />
113
+ <line x1="6" y1="6" x2="18" y2="18" />
114
+ </svg>
115
+ </IconButton>
116
+ </div>
117
+
118
+ {/* Filters */}
119
+ <div className="flex gap-2 px-5 py-3 border-b border-white/[0.04] overflow-x-auto shrink-0">
120
+ {filters.map((f) => (
121
+ <button
122
+ key={f.id}
123
+ onClick={() => setFilter(f.id)}
124
+ className={`px-3 py-1.5 rounded-[8px] text-[11px] font-600 cursor-pointer transition-all border whitespace-nowrap
125
+ ${filter === f.id
126
+ ? 'bg-accent-soft border-accent-bright/25 text-accent-bright'
127
+ : 'bg-surface border-white/[0.06] text-text-3 hover:text-text-2'}`}
128
+ style={{ fontFamily: 'inherit' }}
129
+ >
130
+ {f.label}
131
+ </button>
132
+ ))}
133
+ </div>
134
+
135
+ {/* Event timeline */}
136
+ <div className="flex-1 overflow-y-auto px-5 py-4">
137
+ <div className="relative">
138
+ {/* Timeline line */}
139
+ <div className="absolute left-[15px] top-0 bottom-0 w-px bg-white/[0.06]" />
140
+
141
+ {filtered.map((event, i) => {
142
+ const color = TYPE_COLORS[event.type]
143
+ const expanded = expandedIdx === i
144
+ return (
145
+ <button
146
+ key={i}
147
+ onClick={() => setExpandedIdx(expanded ? null : i)}
148
+ className="w-full text-left relative pl-10 pb-4 group cursor-pointer"
149
+ >
150
+ {/* Dot */}
151
+ <div
152
+ className="absolute left-[10px] top-1 w-[11px] h-[11px] rounded-full border-2"
153
+ style={{ borderColor: color, backgroundColor: expanded ? color : 'transparent' }}
154
+ />
155
+
156
+ {/* Content */}
157
+ <div className="flex items-center gap-2 mb-0.5">
158
+ <span className="text-[11px] font-700 uppercase tracking-wider" style={{ color }}>
159
+ {event.label}
160
+ </span>
161
+ <span className="text-[10px] text-text-3/70 font-mono">{fmtTime(event.time)}</span>
162
+ </div>
163
+
164
+ <p className={`text-[12px] text-text-3 leading-[1.5] ${expanded ? 'whitespace-pre-wrap' : 'line-clamp-2'}`}>
165
+ {event.detail}
166
+ </p>
167
+
168
+ {!expanded && event.detail.length > 150 && (
169
+ <span className="text-[11px] text-accent-bright/60 mt-1 inline-block">click to expand</span>
170
+ )}
171
+ </button>
172
+ )
173
+ })}
174
+
175
+ {filtered.length === 0 && (
176
+ <p className="text-center text-[13px] text-text-3 py-12">No events matching filter</p>
177
+ )}
178
+ </div>
179
+ </div>
180
+
181
+ {/* Stats bar */}
182
+ <div className="flex items-center gap-4 px-5 py-3 border-t border-white/[0.06] shrink-0">
183
+ {(['delegation', 'agent_result', 'error'] as EventType[]).map((type) => {
184
+ const count = events.filter((e) => e.type === type).length
185
+ if (!count) return null
186
+ return (
187
+ <span key={type} className="flex items-center gap-1.5 text-[11px] font-mono" style={{ color: TYPE_COLORS[type] }}>
188
+ <span className="w-2 h-2 rounded-full" style={{ backgroundColor: TYPE_COLORS[type] }} />
189
+ {count} {type === 'delegation' ? 'delegations' : type === 'agent_result' ? 'results' : 'errors'}
190
+ </span>
191
+ )
192
+ })}
193
+ </div>
194
+ </div>
195
+ )
196
+ }
@@ -0,0 +1,85 @@
1
+ 'use client'
2
+
3
+ import { useMemo } from 'react'
4
+ import ReactMarkdown from 'react-markdown'
5
+ import remarkGfm from 'remark-gfm'
6
+ import rehypeHighlight from 'rehype-highlight'
7
+ import { AiAvatar } from '@/components/shared/avatar'
8
+ import { CodeBlock } from './code-block'
9
+ import { ToolCallBubble } from './tool-call-bubble'
10
+ import { useChatStore } from '@/stores/use-chat-store'
11
+
12
+ interface Props {
13
+ text: string
14
+ assistantName?: string
15
+ }
16
+
17
+ export function StreamingBubble({ text, assistantName }: Props) {
18
+ const rendered = useMemo(() => text, [text])
19
+ const toolEvents = useChatStore((s) => s.toolEvents)
20
+
21
+ return (
22
+ <div
23
+ className="flex flex-col items-start"
24
+ style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}
25
+ >
26
+ <div className="flex items-center gap-2.5 mb-2 px-1">
27
+ <AiAvatar size="sm" />
28
+ <span className="text-[12px] font-600 text-text-3">{assistantName || 'Claude'}</span>
29
+ <span className="w-2 h-2 rounded-full bg-accent-bright" style={{ animation: 'pulse 1.5s ease infinite' }} />
30
+ </div>
31
+
32
+ {/* Tool call events */}
33
+ {toolEvents.length > 0 && (
34
+ <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
35
+ {toolEvents.map((event) => (
36
+ <ToolCallBubble key={event.id} event={event} />
37
+ ))}
38
+ </div>
39
+ )}
40
+
41
+ {rendered && (
42
+ <div className="max-w-[85%] md:max-w-[72%] bubble-ai px-5 py-3.5">
43
+ <div className="msg-content streaming-cursor text-[15px] leading-[1.7] break-words text-text">
44
+ <ReactMarkdown
45
+ remarkPlugins={[remarkGfm]}
46
+ rehypePlugins={[rehypeHighlight]}
47
+ components={{
48
+ pre({ children }) {
49
+ return <pre>{children}</pre>
50
+ },
51
+ code({ className, children }) {
52
+ const isBlock = className?.startsWith('language-') || className?.startsWith('hljs')
53
+ if (isBlock) {
54
+ return <CodeBlock className={className}>{children}</CodeBlock>
55
+ }
56
+ return <code className={className}>{children}</code>
57
+ },
58
+ a({ href, children }) {
59
+ if (!href) return <>{children}</>
60
+ const ytMatch = href.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/)
61
+ if (ytMatch) {
62
+ return (
63
+ <div className="my-2">
64
+ <iframe
65
+ src={`https://www.youtube-nocookie.com/embed/${ytMatch[1]}`}
66
+ className="w-full aspect-video rounded-[10px] border border-white/10"
67
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
68
+ allowFullScreen
69
+ title="YouTube video"
70
+ />
71
+ </div>
72
+ )
73
+ }
74
+ return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
75
+ },
76
+ }}
77
+ >
78
+ {rendered}
79
+ </ReactMarkdown>
80
+ </div>
81
+ </div>
82
+ )}
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,26 @@
1
+ 'use client'
2
+
3
+ import { AiAvatar } from '@/components/shared/avatar'
4
+
5
+ interface Props {
6
+ assistantName?: string
7
+ }
8
+
9
+ export function ThinkingIndicator({ assistantName }: Props) {
10
+ return (
11
+ <div className="flex flex-col items-start"
12
+ style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}>
13
+ <div className="flex items-center gap-2.5 mb-2 px-1">
14
+ <AiAvatar size="sm" />
15
+ <span className="text-[12px] font-600 text-text-3">{assistantName || 'Claude'}</span>
16
+ </div>
17
+ <div className="bubble-ai px-6 py-5">
18
+ <div className="flex gap-2">
19
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite' }} />
20
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.15s' }} />
21
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.3s' }} />
22
+ </div>
23
+ </div>
24
+ </div>
25
+ )
26
+ }