@swarmclawai/swarmclaw 0.7.2 → 0.7.4

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 (274) hide show
  1. package/README.md +116 -50
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +43 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +39 -8
  12. package/src/app/api/agents/route.ts +35 -2
  13. package/src/app/api/auth/route.ts +77 -8
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +22 -6
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  19. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  20. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  21. package/src/app/api/chats/[id]/route.ts +30 -0
  22. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  23. package/src/app/api/chats/heartbeat/route.ts +2 -1
  24. package/src/app/api/chats/route.ts +23 -1
  25. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  26. package/src/app/api/connectors/doctor/route.ts +13 -0
  27. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  28. package/src/app/api/external-agents/[id]/route.ts +31 -0
  29. package/src/app/api/external-agents/register/route.ts +3 -0
  30. package/src/app/api/external-agents/route.ts +66 -0
  31. package/src/app/api/files/open/route.ts +16 -14
  32. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  33. package/src/app/api/gateways/[id]/route.ts +79 -0
  34. package/src/app/api/gateways/route.ts +57 -0
  35. package/src/app/api/memory/maintenance/route.ts +11 -1
  36. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  37. package/src/app/api/openclaw/gateway/route.ts +10 -7
  38. package/src/app/api/openclaw/skills/route.ts +12 -4
  39. package/src/app/api/plugins/dependencies/route.ts +24 -0
  40. package/src/app/api/plugins/install/route.ts +15 -92
  41. package/src/app/api/plugins/route.ts +3 -26
  42. package/src/app/api/plugins/settings/route.ts +17 -12
  43. package/src/app/api/plugins/ui/route.ts +1 -0
  44. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  45. package/src/app/api/schedules/[id]/route.ts +38 -9
  46. package/src/app/api/schedules/route.ts +51 -28
  47. package/src/app/api/settings/route.ts +55 -17
  48. package/src/app/api/setup/doctor/route.ts +6 -4
  49. package/src/app/api/tasks/[id]/route.ts +16 -6
  50. package/src/app/api/tasks/bulk/route.ts +3 -3
  51. package/src/app/api/tasks/route.ts +9 -4
  52. package/src/app/api/webhooks/[id]/route.ts +8 -1
  53. package/src/app/page.tsx +135 -17
  54. package/src/cli/binary.test.js +142 -0
  55. package/src/cli/index.js +38 -11
  56. package/src/cli/index.test.js +195 -0
  57. package/src/cli/index.ts +21 -12
  58. package/src/cli/server-cmd.test.js +59 -0
  59. package/src/cli/spec.js +20 -2
  60. package/src/components/agents/agent-card.tsx +15 -12
  61. package/src/components/agents/agent-chat-list.tsx +101 -1
  62. package/src/components/agents/agent-list.tsx +46 -9
  63. package/src/components/agents/agent-sheet.tsx +456 -23
  64. package/src/components/agents/inspector-panel.tsx +110 -49
  65. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  66. package/src/components/auth/access-key-gate.tsx +36 -97
  67. package/src/components/auth/setup-wizard.tsx +970 -275
  68. package/src/components/chat/chat-area.tsx +70 -27
  69. package/src/components/chat/chat-card.tsx +6 -21
  70. package/src/components/chat/chat-header.tsx +263 -366
  71. package/src/components/chat/chat-list.tsx +62 -26
  72. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  73. package/src/components/chat/message-list.tsx +145 -19
  74. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  75. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  76. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  77. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  78. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  79. package/src/components/chatrooms/chatroom-view.tsx +422 -209
  80. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  81. package/src/components/connectors/connector-list.tsx +265 -127
  82. package/src/components/connectors/connector-sheet.tsx +217 -0
  83. package/src/components/gateways/gateway-sheet.tsx +567 -0
  84. package/src/components/home/home-view.tsx +128 -4
  85. package/src/components/input/chat-input.tsx +135 -86
  86. package/src/components/layout/app-layout.tsx +385 -194
  87. package/src/components/layout/mobile-header.tsx +26 -8
  88. package/src/components/memory/memory-browser.tsx +71 -6
  89. package/src/components/memory/memory-card.tsx +18 -0
  90. package/src/components/memory/memory-detail.tsx +58 -31
  91. package/src/components/memory/memory-sheet.tsx +32 -4
  92. package/src/components/plugins/plugin-list.tsx +15 -3
  93. package/src/components/plugins/plugin-sheet.tsx +118 -9
  94. package/src/components/projects/project-detail.tsx +189 -1
  95. package/src/components/providers/provider-list.tsx +158 -2
  96. package/src/components/providers/provider-sheet.tsx +81 -70
  97. package/src/components/shared/agent-picker-list.tsx +2 -2
  98. package/src/components/shared/bottom-sheet.tsx +31 -15
  99. package/src/components/shared/command-palette.tsx +111 -24
  100. package/src/components/shared/confirm-dialog.tsx +45 -30
  101. package/src/components/shared/model-combobox.tsx +90 -8
  102. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  103. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  104. package/src/components/shared/settings/section-heartbeat.tsx +88 -6
  105. package/src/components/shared/settings/section-orchestrator.tsx +6 -3
  106. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  107. package/src/components/shared/settings/section-secrets.tsx +6 -6
  108. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  109. package/src/components/shared/settings/section-voice.tsx +5 -1
  110. package/src/components/shared/settings/section-web-search.tsx +10 -2
  111. package/src/components/shared/settings/settings-page.tsx +248 -47
  112. package/src/components/tasks/approvals-panel.tsx +211 -18
  113. package/src/components/tasks/task-board.tsx +242 -46
  114. package/src/components/ui/dialog.tsx +2 -2
  115. package/src/components/usage/metrics-dashboard.tsx +74 -1
  116. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  117. package/src/components/wallets/wallet-panel.tsx +17 -5
  118. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  119. package/src/lib/auth.ts +17 -0
  120. package/src/lib/chat-streaming-state.test.ts +108 -0
  121. package/src/lib/chat-streaming-state.ts +108 -0
  122. package/src/lib/heartbeat-defaults.ts +48 -0
  123. package/src/lib/memory-presentation.ts +59 -0
  124. package/src/lib/openclaw-agent-id.test.ts +14 -0
  125. package/src/lib/openclaw-agent-id.ts +31 -0
  126. package/src/lib/provider-model-discovery-client.ts +29 -0
  127. package/src/lib/providers/index.ts +12 -5
  128. package/src/lib/runtime-loop.ts +105 -3
  129. package/src/lib/safe-storage.ts +6 -1
  130. package/src/lib/server/agent-assignment.test.ts +112 -0
  131. package/src/lib/server/agent-assignment.ts +169 -0
  132. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  133. package/src/lib/server/agent-runtime-config.ts +277 -0
  134. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  135. package/src/lib/server/approvals-auto-approve.test.ts +264 -0
  136. package/src/lib/server/approvals.ts +483 -75
  137. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  138. package/src/lib/server/browser-state.test.ts +118 -0
  139. package/src/lib/server/browser-state.ts +123 -0
  140. package/src/lib/server/build-llm.test.ts +44 -0
  141. package/src/lib/server/build-llm.ts +11 -4
  142. package/src/lib/server/builtin-plugins.ts +34 -0
  143. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  144. package/src/lib/server/chat-execution-tool-events.test.ts +219 -0
  145. package/src/lib/server/chat-execution.ts +402 -125
  146. package/src/lib/server/chatroom-health.test.ts +26 -0
  147. package/src/lib/server/chatroom-health.ts +2 -3
  148. package/src/lib/server/chatroom-helpers.test.ts +74 -2
  149. package/src/lib/server/chatroom-helpers.ts +144 -11
  150. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  151. package/src/lib/server/connectors/discord.ts +175 -11
  152. package/src/lib/server/connectors/doctor.test.ts +80 -0
  153. package/src/lib/server/connectors/doctor.ts +116 -0
  154. package/src/lib/server/connectors/manager.ts +994 -130
  155. package/src/lib/server/connectors/policy.test.ts +222 -0
  156. package/src/lib/server/connectors/policy.ts +452 -0
  157. package/src/lib/server/connectors/slack.ts +189 -10
  158. package/src/lib/server/connectors/telegram.ts +65 -15
  159. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  160. package/src/lib/server/connectors/thread-context.ts +72 -0
  161. package/src/lib/server/connectors/types.ts +41 -11
  162. package/src/lib/server/daemon-state.ts +62 -3
  163. package/src/lib/server/data-dir.ts +13 -0
  164. package/src/lib/server/delegation-jobs.test.ts +140 -0
  165. package/src/lib/server/delegation-jobs.ts +248 -0
  166. package/src/lib/server/document-utils.test.ts +47 -0
  167. package/src/lib/server/document-utils.ts +397 -0
  168. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  169. package/src/lib/server/eval/agent-regression.ts +1742 -0
  170. package/src/lib/server/eval/runner.ts +11 -1
  171. package/src/lib/server/eval/store.ts +2 -1
  172. package/src/lib/server/heartbeat-service.ts +23 -43
  173. package/src/lib/server/heartbeat-source.test.ts +22 -0
  174. package/src/lib/server/heartbeat-source.ts +7 -0
  175. package/src/lib/server/identity-continuity.test.ts +77 -0
  176. package/src/lib/server/identity-continuity.ts +127 -0
  177. package/src/lib/server/mailbox-utils.ts +347 -0
  178. package/src/lib/server/main-agent-loop.ts +31 -964
  179. package/src/lib/server/memory-db.ts +4 -6
  180. package/src/lib/server/memory-tiers.ts +40 -0
  181. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  182. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  183. package/src/lib/server/openclaw-exec-config.ts +6 -5
  184. package/src/lib/server/openclaw-gateway.ts +123 -36
  185. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  186. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  187. package/src/lib/server/openclaw-sync.ts +3 -2
  188. package/src/lib/server/orchestrator-lg.ts +18 -8
  189. package/src/lib/server/orchestrator.ts +5 -4
  190. package/src/lib/server/playwright-proxy.mjs +27 -3
  191. package/src/lib/server/plugins.test.ts +215 -0
  192. package/src/lib/server/plugins.ts +832 -69
  193. package/src/lib/server/provider-health.ts +33 -3
  194. package/src/lib/server/provider-model-discovery.ts +481 -0
  195. package/src/lib/server/queue.ts +4 -21
  196. package/src/lib/server/runtime-settings.test.ts +119 -0
  197. package/src/lib/server/runtime-settings.ts +12 -92
  198. package/src/lib/server/schedule-normalization.ts +187 -0
  199. package/src/lib/server/scheduler.ts +2 -0
  200. package/src/lib/server/session-archive-memory.test.ts +85 -0
  201. package/src/lib/server/session-archive-memory.ts +230 -0
  202. package/src/lib/server/session-mailbox.ts +8 -18
  203. package/src/lib/server/session-reset-policy.test.ts +99 -0
  204. package/src/lib/server/session-reset-policy.ts +311 -0
  205. package/src/lib/server/session-run-manager.ts +33 -80
  206. package/src/lib/server/session-tools/autonomy-tools.test.ts +128 -0
  207. package/src/lib/server/session-tools/calendar.ts +2 -12
  208. package/src/lib/server/session-tools/connector.ts +109 -8
  209. package/src/lib/server/session-tools/context.ts +14 -2
  210. package/src/lib/server/session-tools/crawl.ts +447 -0
  211. package/src/lib/server/session-tools/crud.ts +96 -34
  212. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  213. package/src/lib/server/session-tools/delegate.ts +406 -20
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  215. package/src/lib/server/session-tools/discovery.ts +40 -12
  216. package/src/lib/server/session-tools/document.ts +283 -0
  217. package/src/lib/server/session-tools/email.ts +1 -3
  218. package/src/lib/server/session-tools/extract.ts +137 -0
  219. package/src/lib/server/session-tools/file-normalize.test.ts +98 -0
  220. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  221. package/src/lib/server/session-tools/file.ts +243 -24
  222. package/src/lib/server/session-tools/http.ts +9 -3
  223. package/src/lib/server/session-tools/human-loop.ts +227 -0
  224. package/src/lib/server/session-tools/image-gen.ts +1 -3
  225. package/src/lib/server/session-tools/index.ts +87 -2
  226. package/src/lib/server/session-tools/mailbox.ts +276 -0
  227. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  228. package/src/lib/server/session-tools/memory.ts +35 -3
  229. package/src/lib/server/session-tools/monitor.ts +162 -12
  230. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  231. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  232. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  233. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  234. package/src/lib/server/session-tools/platform.ts +142 -4
  235. package/src/lib/server/session-tools/plugin-creator.ts +95 -25
  236. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  237. package/src/lib/server/session-tools/replicate.ts +1 -3
  238. package/src/lib/server/session-tools/sandbox.ts +51 -92
  239. package/src/lib/server/session-tools/schedule.ts +20 -10
  240. package/src/lib/server/session-tools/session-info.ts +58 -4
  241. package/src/lib/server/session-tools/session-tools-wiring.test.ts +54 -17
  242. package/src/lib/server/session-tools/shell.ts +2 -2
  243. package/src/lib/server/session-tools/subagent.ts +195 -27
  244. package/src/lib/server/session-tools/table.ts +587 -0
  245. package/src/lib/server/session-tools/wallet.ts +13 -10
  246. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  247. package/src/lib/server/session-tools/web.ts +947 -108
  248. package/src/lib/server/storage.ts +255 -10
  249. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  250. package/src/lib/server/stream-agent-chat.ts +185 -25
  251. package/src/lib/server/structured-extract.test.ts +72 -0
  252. package/src/lib/server/structured-extract.ts +373 -0
  253. package/src/lib/server/task-mention.test.ts +16 -2
  254. package/src/lib/server/task-mention.ts +61 -11
  255. package/src/lib/server/tool-aliases.ts +80 -12
  256. package/src/lib/server/tool-capability-policy.ts +7 -1
  257. package/src/lib/server/tool-retry.ts +2 -0
  258. package/src/lib/server/watch-jobs.test.ts +173 -0
  259. package/src/lib/server/watch-jobs.ts +532 -0
  260. package/src/lib/server/ws-hub.ts +5 -3
  261. package/src/lib/setup-defaults.ts +352 -11
  262. package/src/lib/tool-definitions.ts +3 -4
  263. package/src/lib/validation/schemas.test.ts +26 -0
  264. package/src/lib/validation/schemas.ts +62 -1
  265. package/src/lib/ws-client.ts +14 -12
  266. package/src/proxy.ts +5 -5
  267. package/src/stores/use-app-store.ts +43 -7
  268. package/src/stores/use-chat-store.ts +31 -2
  269. package/src/stores/use-chatroom-store.ts +153 -26
  270. package/src/types/index.ts +470 -44
  271. package/src/app/api/chats/[id]/main-loop/route.ts +0 -94
  272. package/src/components/chat/new-chat-sheet.tsx +0 -253
  273. package/src/lib/server/main-session.ts +0 -17
  274. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -8,13 +8,14 @@ import { fetchMessages } from '@/lib/chats'
8
8
  import { toast } from 'sonner'
9
9
  import { Skeleton } from '@/components/shared/skeleton'
10
10
  import { EmptyState } from '@/components/shared/empty-state'
11
+ import { Dropdown, DropdownItem } from '@/components/shared/dropdown'
11
12
 
12
13
  interface Props {
13
14
  inSidebar?: boolean
14
15
  onSelect?: () => void
15
16
  }
16
17
 
17
- type SessionFilter = 'all' | 'active' | 'human' | 'orchestrated'
18
+ type SessionFilter = 'all' | 'active' | 'unread'
18
19
  type SortMode = 'lastActive' | 'name' | 'messages'
19
20
 
20
21
  export function ChatList({ inSidebar, onSelect }: Props) {
@@ -24,15 +25,19 @@ export function ChatList({ inSidebar, onSelect }: Props) {
24
25
  const setCurrentSession = useAppStore((s) => s.setCurrentSession)
25
26
  const loadSessions = useAppStore((s) => s.loadSessions)
26
27
  const loadConnectors = useAppStore((s) => s.loadConnectors)
27
- const setNewSessionOpen = useAppStore((s) => s.setNewSessionOpen)
28
+ const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
28
29
  const clearSessions = useAppStore((s) => s.clearSessions)
29
30
  const togglePinSession = useAppStore((s) => s.togglePinSession)
30
31
  const markChatRead = useAppStore((s) => s.markChatRead)
32
+ const lastReadTimestamps = useAppStore((s) => s.lastReadTimestamps)
33
+ const agents = useAppStore((s) => s.agents)
34
+ const connectors = useAppStore((s) => s.connectors)
31
35
  const setMessages = useChatStore((s) => s.setMessages)
32
36
  const [search, setSearch] = useState('')
33
37
  const [typeFilter, setTypeFilter] = useState<SessionFilter>('all')
34
38
  const [sortMode, setSortMode] = useState<SortMode>('lastActive')
35
39
  const [loaded, setLoaded] = useState(Object.keys(sessions).length > 0)
40
+ const [bulkMenuOpen, setBulkMenuOpen] = useState(false)
36
41
 
37
42
  useEffect(() => {
38
43
  if (Object.keys(sessions).length > 0 && !loaded) setLoaded(true)
@@ -56,10 +61,32 @@ export function ChatList({ inSidebar, onSelect }: Props) {
56
61
  const filtered = useMemo(() => {
57
62
  return allUserSessions
58
63
  .filter((s) => {
59
- if (search && !s.name.toLowerCase().includes(search.toLowerCase())) return false
64
+ const unreadCount = (s.messages || []).filter(
65
+ (m) => m.role === 'assistant' && (m.time || 0) > (lastReadTimestamps[s.id] || 0),
66
+ ).length
67
+ if (search) {
68
+ const agent = s.agentId ? agents[s.agentId] : null
69
+ const connector = Object.values(connectors).find((item) => item.chatroomId == null && item.agentId === s.agentId && item.isEnabled !== false)
70
+ const lastMessage = s.messages?.[s.messages.length - 1]
71
+ const haystack = [
72
+ s.name,
73
+ agent?.name,
74
+ s.provider,
75
+ s.model,
76
+ s.cwd,
77
+ connector?.name,
78
+ connector?.platform,
79
+ lastMessage?.text,
80
+ lastMessage?.source?.senderName,
81
+ lastMessage?.source?.platform,
82
+ ]
83
+ .filter(Boolean)
84
+ .join(' ')
85
+ .toLowerCase()
86
+ if (!haystack.includes(search.toLowerCase())) return false
87
+ }
60
88
  if (typeFilter === 'active' && !s.active) return false
61
- if (typeFilter === 'human' && s.sessionType === 'orchestrated') return false
62
- if (typeFilter === 'orchestrated' && s.sessionType !== 'orchestrated') return false
89
+ if (typeFilter === 'unread' && unreadCount === 0) return false
63
90
  return true
64
91
  })
65
92
  .sort((a, b) => {
@@ -71,7 +98,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
71
98
  if (sortMode === 'messages') return (b.messages?.length || 0) - (a.messages?.length || 0)
72
99
  return (b.lastActiveAt || 0) - (a.lastActiveAt || 0)
73
100
  })
74
- }, [allUserSessions, search, typeFilter, sortMode])
101
+ }, [agents, allUserSessions, connectors, lastReadTimestamps, search, sortMode, typeFilter])
75
102
 
76
103
  const handleSelect = async (id: string) => {
77
104
  setCurrentSession(id)
@@ -115,8 +142,8 @@ export function ChatList({ inSidebar, onSelect }: Props) {
115
142
  </svg>
116
143
  }
117
144
  title="No chats yet"
118
- subtitle="Create one to start chatting"
119
- action={!inSidebar ? { label: '+ New Chat', onClick: () => setNewSessionOpen(true) } : undefined}
145
+ subtitle="Create an agent to open its persistent thread"
146
+ action={!inSidebar ? { label: '+ New Agent', onClick: () => setAgentSheetOpen(true) } : undefined}
120
147
  />
121
148
  )
122
149
  }
@@ -125,7 +152,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
125
152
  <div className="flex-1 flex flex-col overflow-y-auto">
126
153
  {/* Filter tabs — always visible when sessions exist */}
127
154
  <div className="flex items-center gap-1 px-4 pt-2 pb-1 shrink-0">
128
- {(['all', 'active', 'human', 'orchestrated'] as SessionFilter[]).map((f) => (
155
+ {(['all', 'active', 'unread'] as SessionFilter[]).map((f) => (
129
156
  <button
130
157
  key={f}
131
158
  onClick={() => setTypeFilter(f)}
@@ -133,25 +160,34 @@ export function ChatList({ inSidebar, onSelect }: Props) {
133
160
  ${typeFilter === f ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2'}`}
134
161
  style={{ fontFamily: 'inherit' }}
135
162
  >
136
- {f === 'all' ? 'All' : f === 'active' ? 'Active' : f === 'human' ? 'Human' : 'AI'}
163
+ {f === 'all' ? 'All' : f === 'active' ? 'Active' : 'Unread'}
137
164
  </button>
138
165
  ))}
139
166
  {filtered.length > 0 && (
140
- <button
141
- onClick={async () => {
142
- if (!window.confirm(`Delete ${filtered.length} chat${filtered.length === 1 ? '' : 's'}?`)) return
143
- await clearSessions(filtered.map((s) => s.id))
144
- toast.success(`${filtered.length} chat${filtered.length === 1 ? '' : 's'} deleted`)
145
- }}
146
- className="ml-auto p-1.5 rounded-[8px] text-text-3/70 hover:text-red-400 hover:bg-red-400/[0.06]
147
- cursor-pointer transition-all bg-transparent border-none"
148
- title="Clear all chats"
149
- >
150
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
151
- <polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
152
- <path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
153
- </svg>
154
- </button>
167
+ <div className="ml-auto relative">
168
+ <button
169
+ onClick={() => setBulkMenuOpen((open) => !open)}
170
+ className="p-1.5 rounded-[8px] text-text-3/70 hover:text-text-2 hover:bg-white/[0.04]
171
+ cursor-pointer transition-all bg-transparent border-none"
172
+ title="More actions"
173
+ >
174
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
175
+ <circle cx="5" cy="12" r="1.75" />
176
+ <circle cx="12" cy="12" r="1.75" />
177
+ <circle cx="19" cy="12" r="1.75" />
178
+ </svg>
179
+ </button>
180
+ <Dropdown open={bulkMenuOpen} onClose={() => setBulkMenuOpen(false)}>
181
+ <DropdownItem onClick={async () => {
182
+ 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`)
186
+ }}>
187
+ Clear filtered chats
188
+ </DropdownItem>
189
+ </Dropdown>
190
+ </div>
155
191
  )}
156
192
  </div>
157
193
 
@@ -209,7 +245,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
209
245
  ) : (
210
246
  <div className="flex-1 flex flex-col items-center justify-center gap-3 text-text-3 p-8 text-center">
211
247
  <p className="text-[13px] text-text-3/50">
212
- No {typeFilter === 'orchestrated' ? 'AI' : typeFilter === 'active' ? 'active' : typeFilter} chats{search ? ` matching "${search}"` : ''}
248
+ No {typeFilter === 'active' ? 'active' : typeFilter} chats{search ? ` matching "${search}"` : ''}
213
249
  </p>
214
250
  </div>
215
251
  )}
@@ -68,7 +68,7 @@ export function CheckpointTimeline({ sessionId }: Props) {
68
68
  return (
69
69
  <div className="p-8 text-center">
70
70
  <p className="text-text-3 text-[13px]">No checkpoints found for this chat.</p>
71
- <p className="text-[11px] text-text-3/50 mt-1">Only LangGraph-orchestrated chats support time travel.</p>
71
+ <p className="text-[11px] text-text-3/50 mt-1">Checkpoint restore is only available when a backend created checkpoints for this chat.</p>
72
72
  </div>
73
73
  )
74
74
  }
@@ -1,10 +1,12 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
3
+ import { DEFAULT_HEARTBEAT_SHOW_ALERTS, DEFAULT_HEARTBEAT_SHOW_OK } from '@/lib/heartbeat-defaults'
4
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4
5
  import type { Message } from '@/types'
5
6
  import { useChatStore } from '@/stores/use-chat-store'
6
7
  import { useAppStore } from '@/stores/use-app-store'
7
8
  import { api } from '@/lib/api-client'
9
+ import { shouldHidePersistedStreamingAssistantMessage } from '@/lib/chat-streaming-state'
8
10
  import { AgentAvatar } from '@/components/agents/agent-avatar'
9
11
  import { MessageBubble } from './message-bubble'
10
12
  import { StreamingBubble } from './streaming-bubble'
@@ -49,9 +51,10 @@ interface Props {
49
51
  messages: Message[]
50
52
  streaming: boolean
51
53
  connectorFilter?: string | null
54
+ loading?: boolean
52
55
  }
53
56
 
54
- export function MessageList({ messages, streaming, connectorFilter = null }: Props) {
57
+ export function MessageList({ messages, streaming, connectorFilter = null, loading = false }: Props) {
55
58
  const scrollRef = useRef<HTMLDivElement>(null)
56
59
  const [showScrollToBottom, setShowScrollToBottom] = useState(false)
57
60
  const snapUntilRef = useRef(0)
@@ -78,8 +81,8 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
78
81
  || (session?.provider === 'claude-cli' ? undefined : session?.model || session?.provider)
79
82
  || undefined
80
83
 
81
- const showOk = appSettings.heartbeatShowOk ?? false
82
- const showAlerts = appSettings.heartbeatShowAlerts ?? true
84
+ const showOk = appSettings.heartbeatShowOk ?? DEFAULT_HEARTBEAT_SHOW_OK
85
+ const showAlerts = appSettings.heartbeatShowAlerts ?? DEFAULT_HEARTBEAT_SHOW_ALERTS
83
86
 
84
87
  // Gateway disconnect overlay for openclaw agents
85
88
  const isOpenClaw = agent?.provider === 'openclaw'
@@ -156,6 +159,10 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
156
159
  const [searchQuery, setSearchQuery] = useState('')
157
160
  const [searchIdx, setSearchIdx] = useState(0)
158
161
  const searchInputRef = useRef<HTMLInputElement>(null)
162
+ const openSearch = useCallback(() => {
163
+ setSearchOpen(true)
164
+ setTimeout(() => searchInputRef.current?.focus(), 50)
165
+ }, [])
159
166
 
160
167
  const isHeartbeatMessage = (msg: Message) =>
161
168
  msg.role === 'assistant' && (msg.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(msg.text || '') || /^\s*NO_MESSAGE\b/i.test(msg.text || ''))
@@ -164,6 +171,7 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
164
171
 
165
172
  const displayedMessages: Message[] = []
166
173
  for (const msg of messages) {
174
+ if (shouldHidePersistedStreamingAssistantMessage(msg, { localStreaming: streaming, displayText })) continue
167
175
  const isHeartbeat = isHeartbeatMessage(msg)
168
176
 
169
177
  // Visibility filtering based on settings
@@ -190,11 +198,13 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
190
198
  }
191
199
 
192
200
  // Search matches
193
- const searchMatches = searchQuery.trim()
194
- ? filteredMessages
195
- .map((msg, i) => ({ msg, i }))
196
- .filter(({ msg }) => msg.text.toLowerCase().includes(searchQuery.toLowerCase()))
197
- : []
201
+ const searchMatches = useMemo(() => {
202
+ const normalizedQuery = searchQuery.trim().toLowerCase()
203
+ if (!normalizedQuery) return []
204
+ return filteredMessages
205
+ .map((msg, i) => ({ msg, i }))
206
+ .filter(({ msg }) => msg.text.toLowerCase().includes(normalizedQuery))
207
+ }, [filteredMessages, searchQuery])
198
208
 
199
209
  // Track whether user is at/near bottom so we know whether to auto-scroll on new content
200
210
  const wasAtBottomRef = useRef(true)
@@ -309,6 +319,15 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
309
319
  return () => window.removeEventListener('swarmclaw:scroll-to-message', handler)
310
320
  }, [])
311
321
 
322
+ useEffect(() => {
323
+ if (!searchQuery || !searchMatches.length) return
324
+ const currentMatch = searchMatches[searchIdx]
325
+ if (!currentMatch) return
326
+ const el = scrollRef.current?.querySelector(`[data-message-index="${currentMatch.i}"]`) as HTMLElement | null
327
+ if (!el) return
328
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
329
+ }, [searchIdx, searchMatches, searchQuery])
330
+
312
331
  // Ctrl+F search toggle
313
332
  useEffect(() => {
314
333
  const handler = (e: KeyboardEvent) => {
@@ -332,9 +351,81 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
332
351
 
333
352
  return (
334
353
  <div className="relative flex-1 min-h-0 min-w-0 flex flex-col overflow-hidden">
354
+ <div className="shrink-0 px-4 md:px-12 lg:px-16 pt-3">
355
+ <div className="flex flex-wrap items-center gap-2 rounded-[14px] border border-white/[0.06] bg-surface/55 px-3 py-2 backdrop-blur-sm">
356
+ <button
357
+ type="button"
358
+ onClick={() => {
359
+ if (searchOpen) {
360
+ setSearchOpen(false)
361
+ setSearchQuery('')
362
+ setSearchIdx(0)
363
+ } else {
364
+ openSearch()
365
+ }
366
+ }}
367
+ className={`inline-flex items-center gap-1.5 rounded-[9px] border px-2.5 py-1.5 text-[11px] font-600 transition-colors cursor-pointer ${
368
+ searchOpen
369
+ ? 'border-accent-bright/25 bg-accent-soft/60 text-accent-bright'
370
+ : 'border-white/[0.06] bg-white/[0.03] text-text-3 hover:text-text-2 hover:bg-white/[0.06]'
371
+ }`}
372
+ >
373
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
374
+ <circle cx="11" cy="11" r="8" />
375
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
376
+ </svg>
377
+ Find
378
+ <span className="hidden sm:inline text-text-3/50">Cmd/Ctrl+F</span>
379
+ </button>
380
+ <button
381
+ type="button"
382
+ onClick={() => setBookmarkFilter((v) => !v)}
383
+ className={`inline-flex items-center gap-1.5 rounded-[9px] border px-2.5 py-1.5 text-[11px] font-600 transition-colors cursor-pointer ${
384
+ bookmarkFilter
385
+ ? 'border-amber-400/25 bg-amber-500/10 text-amber-300'
386
+ : 'border-white/[0.06] bg-white/[0.03] text-text-3 hover:text-text-2 hover:bg-white/[0.06]'
387
+ }`}
388
+ >
389
+ <svg width="12" height="12" viewBox="0 0 24 24" fill={bookmarkFilter ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
390
+ <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
391
+ </svg>
392
+ {bookmarkFilter ? 'Bookmarked' : 'Bookmarks'}
393
+ </button>
394
+ {(searchQuery || bookmarkFilter) && (
395
+ <button
396
+ type="button"
397
+ onClick={() => {
398
+ setSearchOpen(false)
399
+ setSearchQuery('')
400
+ setSearchIdx(0)
401
+ setBookmarkFilter(false)
402
+ }}
403
+ className="inline-flex items-center gap-1.5 rounded-[9px] border border-white/[0.06] bg-transparent px-2.5 py-1.5 text-[11px] font-600 text-text-3 hover:text-text-2 hover:bg-white/[0.04] cursor-pointer transition-colors"
404
+ >
405
+ Reset filters
406
+ </button>
407
+ )}
408
+ <div className="ml-auto flex items-center gap-2 text-[11px] text-text-3/60">
409
+ {searchQuery ? (
410
+ <span className="tabular-nums">
411
+ {searchMatches.length > 0 ? `${searchIdx + 1}/${searchMatches.length}` : '0 results'}
412
+ </span>
413
+ ) : (
414
+ <span>{filteredMessages.length} message{filteredMessages.length === 1 ? '' : 's'}</span>
415
+ )}
416
+ {loading && (
417
+ <span className="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2 py-1 text-text-3/70">
418
+ <span className="w-2 h-2 rounded-full bg-accent-bright animate-pulse" />
419
+ Loading thread
420
+ </span>
421
+ )}
422
+ </div>
423
+ </div>
424
+ </div>
425
+
335
426
  {/* In-thread search bar */}
336
427
  {searchOpen && (
337
- <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]">
428
+ <div className="shrink-0 z-20 flex items-center gap-2 px-4 md:px-12 lg:px-16 py-2 bg-surface/95 backdrop-blur-sm border-b border-white/[0.06]">
338
429
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3 shrink-0">
339
430
  <circle cx="11" cy="11" r="8" />
340
431
  <line x1="21" y1="21" x2="16.65" y2="16.65" />
@@ -399,7 +490,7 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
399
490
  <div
400
491
  ref={scrollRef}
401
492
  onScroll={updateScrollState}
402
- className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 md:px-12 lg:px-16 pt-6 pb-[120px] md:pb-10 fade-up"
493
+ className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 md:px-12 lg:px-16 pt-4 pb-[120px] md:pb-10 fade-up"
403
494
  >
404
495
  <div className="flex flex-col gap-6 relative">
405
496
  {/* Chat spine — vertical line for assistant messages */}
@@ -440,13 +531,48 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
440
531
  </div>
441
532
  )}
442
533
  {filteredMessages.length === 0 && !streaming && (
443
- <div className="flex flex-col items-center justify-center gap-3 py-20 text-center" style={{ animation: 'fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) both' }}>
444
- <AgentAvatar seed={agent?.avatarSeed || null} avatarUrl={agent?.avatarUrl} name={agent?.name || 'Agent'} size={48} />
445
- <span className="font-display text-[16px] font-600 text-text-2">{agent?.name || 'Assistant'}</span>
446
- <span className="text-[14px] text-text-3/60">
447
- {INTRO_GREETINGS[stableHash(agent?.id || session?.id || '') % INTRO_GREETINGS.length]}
448
- </span>
449
- </div>
534
+ searchQuery.trim() || bookmarkFilter || connectorFilter ? (
535
+ <div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
536
+ <div className="w-12 h-12 rounded-full bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
537
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" className="text-text-3/70">
538
+ <circle cx="11" cy="11" r="8" />
539
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
540
+ </svg>
541
+ </div>
542
+ <span className="font-display text-[16px] font-600 text-text-2">
543
+ {bookmarkFilter ? 'No bookmarked messages here' : 'No messages match these filters'}
544
+ </span>
545
+ <span className="text-[13px] text-text-3/60 max-w-[360px]">
546
+ {searchQuery.trim()
547
+ ? `Nothing in this thread matches "${searchQuery.trim()}".`
548
+ : connectorFilter
549
+ ? 'Try another source filter or reset the thread filters.'
550
+ : 'Try another keyword or turn off bookmarks-only mode.'}
551
+ </span>
552
+ {(searchQuery.trim() || bookmarkFilter) && (
553
+ <button
554
+ type="button"
555
+ onClick={() => {
556
+ setSearchOpen(false)
557
+ setSearchQuery('')
558
+ setSearchIdx(0)
559
+ setBookmarkFilter(false)
560
+ }}
561
+ className="rounded-[10px] border border-white/[0.06] bg-white/[0.03] px-3 py-2 text-[12px] font-600 text-text-2 hover:bg-white/[0.06] cursor-pointer transition-colors"
562
+ >
563
+ Clear thread filters
564
+ </button>
565
+ )}
566
+ </div>
567
+ ) : (
568
+ <div className="flex flex-col items-center justify-center gap-3 py-20 text-center" style={{ animation: 'fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) both' }}>
569
+ <AgentAvatar seed={agent?.avatarSeed || null} avatarUrl={agent?.avatarUrl} name={agent?.name || 'Agent'} size={48} />
570
+ <span className="font-display text-[16px] font-600 text-text-2">{agent?.name || 'Assistant'}</span>
571
+ <span className="text-[14px] text-text-3/60">
572
+ {INTRO_GREETINGS[stableHash(agent?.id || session?.id || '') % INTRO_GREETINGS.length]}
573
+ </span>
574
+ </div>
575
+ )
450
576
  )}
451
577
  {filteredMessages.map((msg, i) => {
452
578
  // Context-clear divider — render a visual separator instead of a bubble
@@ -514,7 +640,7 @@ export function MessageList({ messages, streaming, connectorFilter = null }: Pro
514
640
 
515
641
  return (
516
642
  <div
517
- key={`${sessionId}-${msg.time}-${i}`}
643
+ key={`${sessionId}-${msg.role}-${originalIndex >= 0 ? originalIndex : i}`}
518
644
  data-message-index={i}
519
645
  style={{
520
646
  animation: `${msg.role === 'user' ? 'msg-in-right' : 'msg-in-left'} 0.4s var(--ease-spring) both`,
@@ -29,6 +29,16 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
29
29
  const removePendingFile = useChatroomStore((s) => s.removePendingFile)
30
30
  const replyingTo = useChatroomStore((s) => s.replyingTo)
31
31
  const setReplyingTo = useChatroomStore((s) => s.setReplyingTo)
32
+ const streaming = useChatroomStore((s) => s.streaming)
33
+ const queuedMessages = useChatroomStore((s) => s.queuedMessages)
34
+ const removeQueuedMessage = useChatroomStore((s) => s.removeQueuedMessage)
35
+
36
+ const resizeTextarea = useCallback(() => {
37
+ const node = inputRef.current
38
+ if (!node) return
39
+ node.style.height = 'auto'
40
+ node.style.height = `${Math.min(node.scrollHeight, 160)}px`
41
+ }, [])
32
42
 
33
43
  // Draft persistence: restore on chatroom change
34
44
  const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -38,6 +48,10 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
38
48
  setText(draft || '')
39
49
  }, [chatroomId])
40
50
 
51
+ useEffect(() => {
52
+ resizeTextarea()
53
+ }, [resizeTextarea, text, chatroomId])
54
+
41
55
  // Debounced save to localStorage
42
56
  useEffect(() => {
43
57
  if (!chatroomId) return
@@ -83,6 +97,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
83
97
 
84
98
  const handleChange = useCallback((value: string) => {
85
99
  setText(value)
100
+ resizeTextarea()
86
101
  const cursorPos = inputRef.current?.selectionStart || value.length
87
102
  const beforeCursor = value.slice(0, cursorPos)
88
103
  const mentionMatch = beforeCursor.match(/@(\S*)$/)
@@ -95,7 +110,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
95
110
  setMentionFilter('')
96
111
  setSelectedIndex(0)
97
112
  }
98
- }, [])
113
+ }, [resizeTextarea])
99
114
 
100
115
  const insertMention = useCallback((name: string) => {
101
116
  const cursorPos = inputRef.current?.selectionStart || text.length
@@ -138,13 +153,23 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
138
153
  return parts.length > 0 ? parts : null
139
154
  }, [text])
140
155
 
141
- const mentionDropdownVisible = showMentions && (filteredAgents.length > 0 || mentionFilter === '')
156
+ const mentionDropdownVisible = showMentions
142
157
  const mentionItems = mentionDropdownVisible
143
158
  ? ['all', ...filteredAgents.map((a) => a.name)]
144
159
  : []
160
+ const visibleQueuedMessages = queuedMessages.filter((item) => item.chatroomId === chatroomId)
161
+
162
+ const handleSendCurrent = useCallback(() => {
163
+ if ((!text.trim() && !pendingFiles.length) || disabled) return
164
+ onSend(text)
165
+ setText('')
166
+ resizeTextarea()
167
+ if (chatroomId) safeStorageRemove(`sc_draft_cr_${chatroomId}`)
168
+ setShowMentions(false)
169
+ }, [chatroomId, disabled, onSend, pendingFiles.length, resizeTextarea, text])
145
170
 
146
171
  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
147
- if (mentionDropdownVisible) {
172
+ if (mentionDropdownVisible && mentionItems.length > 0) {
148
173
  if (e.key === 'ArrowDown') {
149
174
  e.preventDefault()
150
175
  setSelectedIndex((i) => (i + 1) % mentionItems.length)
@@ -165,12 +190,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
165
190
 
166
191
  if (e.key === 'Enter' && !e.shiftKey) {
167
192
  e.preventDefault()
168
- if ((text.trim() || pendingFiles.length) && !disabled) {
169
- onSend(text)
170
- setText('')
171
- if (chatroomId) safeStorageRemove(`sc_draft_cr_${chatroomId}`)
172
- setShowMentions(false)
173
- }
193
+ handleSendCurrent()
174
194
  }
175
195
  if (e.key === 'Escape') {
176
196
  if (replyingTo) {
@@ -195,21 +215,68 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
195
215
  <span className="text-[13px] text-text">all</span>
196
216
  <span className="text-[11px] text-text-3 ml-auto">Mention all agents</span>
197
217
  </button>
198
- {filteredAgents.map((agent, i) => (
199
- <button
200
- key={agent.id}
201
- onClick={() => insertMention(agent.name)}
202
- className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all cursor-pointer ${
203
- selectedIndex === i + 1 ? 'bg-white/[0.08]' : 'hover:bg-white/[0.06]'
204
- }`}
205
- >
206
- <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={20} />
207
- <span className="text-[13px] text-text">{agent.name}</span>
208
- </button>
218
+ {filteredAgents.length > 0 ? (
219
+ filteredAgents.map((agent, i) => (
220
+ <button
221
+ key={agent.id}
222
+ onClick={() => insertMention(agent.name)}
223
+ className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all cursor-pointer ${
224
+ selectedIndex === i + 1 ? 'bg-white/[0.08]' : 'hover:bg-white/[0.06]'
225
+ }`}
226
+ >
227
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={20} />
228
+ <span className="text-[13px] text-text">{agent.name}</span>
229
+ </button>
230
+ ))
231
+ ) : (
232
+ <div className="px-3 py-3 text-[12px] text-text-3">
233
+ No agents match <span className="text-text">@{mentionFilter}</span>.
234
+ </div>
235
+ )}
236
+ </div>
237
+ )}
238
+
239
+ {visibleQueuedMessages.length > 0 && (
240
+ <div className="mb-2 flex flex-wrap items-center gap-1.5">
241
+ <span className="label-mono text-amber-400/70">Queued</span>
242
+ {visibleQueuedMessages.map((item) => (
243
+ <span key={item.id} className="inline-flex items-center gap-1.5 rounded-[8px] border border-amber-500/15 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300">
244
+ <span className="truncate max-w-[180px]">
245
+ {item.text.trim() || `Attachment${item.pendingFiles.length > 1 ? 's' : ''}`}
246
+ </span>
247
+ {item.pendingFiles.length > 0 && (
248
+ <span className="rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px]">
249
+ +{item.pendingFiles.length} file{item.pendingFiles.length === 1 ? '' : 's'}
250
+ </span>
251
+ )}
252
+ <button
253
+ type="button"
254
+ onClick={() => removeQueuedMessage(item.id)}
255
+ className="border-none bg-transparent p-0 text-amber-300/70 hover:text-amber-200 cursor-pointer"
256
+ >
257
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
258
+ <line x1="18" y1="6" x2="6" y2="18" />
259
+ <line x1="6" y1="6" x2="18" y2="18" />
260
+ </svg>
261
+ </button>
262
+ </span>
209
263
  ))}
210
264
  </div>
211
265
  )}
212
266
 
267
+ {visibleQueuedMessages.length === 0 && !disabled && (
268
+ <div className="mb-2 flex items-center justify-between gap-2 rounded-[10px] border border-white/[0.06] bg-white/[0.03] px-3 py-2">
269
+ <span className="text-[11px] text-text-3">
270
+ {streaming
271
+ ? 'Current round is still running. Press send to queue the next message.'
272
+ : agents.length > 0
273
+ ? 'Use @AgentName or @all to direct the next reply.'
274
+ : 'Start the next round here.'}
275
+ </span>
276
+ <span className="text-[10px] text-text-3/50">Enter sends · Shift+Enter newline</span>
277
+ </div>
278
+ )}
279
+
213
280
  {/* Reply preview banner */}
214
281
  {replyingTo && (
215
282
  <div className="flex items-center gap-2 mb-2 px-2 py-1.5 rounded-[8px] bg-white/[0.04] border border-white/[0.06]">
@@ -268,12 +335,12 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
268
335
  </svg>
269
336
  </button>
270
337
 
271
- <div className="flex-1 relative rounded-[8px] bg-white/[0.06] border border-white/[0.08] focus-within:border-accent-bright/40">
338
+ <div className="flex-1 relative rounded-[10px] bg-white/[0.06] border border-white/[0.08] focus-within:border-accent-bright/40">
272
339
  {/* Highlight mirror — renders @mentions with accent background behind the transparent textarea */}
273
340
  <div
274
341
  aria-hidden
275
342
  className="absolute inset-0 px-3 py-2 text-[13px] leading-[1.5] break-words whitespace-pre-wrap pointer-events-none overflow-hidden"
276
- style={{ minHeight: '38px', color: 'transparent' }}
343
+ style={{ minHeight: '44px', color: 'transparent' }}
277
344
  >
278
345
  {highlightedSegments}
279
346
  </div>
@@ -286,21 +353,17 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
286
353
  placeholder="Type a message... Use @ to mention agents"
287
354
  disabled={disabled}
288
355
  rows={1}
289
- className="w-full resize-none px-3 py-2 rounded-[8px] bg-transparent text-[13px] text-text placeholder:text-text-3 focus:outline-none max-h-[120px] disabled:opacity-50 relative border-none"
290
- style={{ minHeight: '38px' }}
356
+ className="relative w-full resize-none rounded-[10px] border-none bg-transparent px-3 py-2.5 text-[13px] text-text placeholder:text-text-3 focus:outline-none max-h-[160px] disabled:opacity-50"
357
+ style={{ minHeight: '44px' }}
291
358
  />
292
359
  </div>
293
360
  <button
294
- onClick={() => {
295
- if ((text.trim() || pendingFiles.length) && !disabled) {
296
- onSend(text)
297
- setText('')
298
- if (chatroomId) safeStorageRemove(`sc_draft_cr_${chatroomId}`)
299
- setShowMentions(false)
300
- }
301
- }}
361
+ onClick={handleSendCurrent}
302
362
  disabled={(!text.trim() && !pendingFiles.length) || disabled}
303
- className="shrink-0 w-9 h-9 rounded-[8px] bg-accent-bright flex items-center justify-center hover:bg-accent-bright/90 transition-all disabled:opacity-30 cursor-pointer"
363
+ className={`shrink-0 w-10 h-10 rounded-[10px] flex items-center justify-center transition-all disabled:opacity-30 cursor-pointer ${
364
+ streaming ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'bg-accent-bright hover:bg-accent-bright/90'
365
+ }`}
366
+ title="Send or queue message"
304
367
  >
305
368
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
306
369
  <line x1="22" y1="2" x2="11" y2="13" />