@swarmclawai/swarmclaw 0.7.3 → 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 (147) hide show
  1. package/README.md +47 -40
  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 +17 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +3 -1
  12. package/src/app/api/agents/route.ts +23 -1
  13. package/src/app/api/auth/route.ts +1 -1
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +16 -5
  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]/route.ts +12 -0
  19. package/src/app/api/chats/heartbeat/route.ts +2 -1
  20. package/src/app/api/chats/route.ts +7 -1
  21. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  22. package/src/app/api/external-agents/[id]/route.ts +31 -0
  23. package/src/app/api/external-agents/register/route.ts +3 -0
  24. package/src/app/api/external-agents/route.ts +66 -0
  25. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  26. package/src/app/api/gateways/[id]/route.ts +79 -0
  27. package/src/app/api/gateways/route.ts +57 -0
  28. package/src/app/api/openclaw/gateway/route.ts +10 -7
  29. package/src/app/api/openclaw/skills/route.ts +1 -1
  30. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  31. package/src/app/api/schedules/[id]/route.ts +38 -9
  32. package/src/app/api/schedules/route.ts +51 -28
  33. package/src/app/api/settings/route.ts +6 -10
  34. package/src/app/api/setup/doctor/route.ts +6 -4
  35. package/src/app/api/tasks/[id]/route.ts +2 -1
  36. package/src/app/api/tasks/bulk/route.ts +2 -2
  37. package/src/app/page.tsx +126 -15
  38. package/src/cli/binary.test.js +142 -0
  39. package/src/cli/index.js +34 -11
  40. package/src/cli/index.test.js +195 -0
  41. package/src/cli/index.ts +20 -4
  42. package/src/cli/server-cmd.test.js +59 -0
  43. package/src/cli/spec.js +20 -2
  44. package/src/components/agents/agent-sheet.tsx +249 -7
  45. package/src/components/agents/inspector-panel.tsx +3 -2
  46. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  47. package/src/components/auth/setup-wizard.tsx +970 -275
  48. package/src/components/chat/chat-area.tsx +41 -14
  49. package/src/components/chat/chat-card.tsx +2 -1
  50. package/src/components/chat/chat-header.tsx +8 -13
  51. package/src/components/chat/chat-list.tsx +58 -20
  52. package/src/components/chat/message-list.tsx +142 -18
  53. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  54. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  55. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  56. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +157 -86
  59. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  60. package/src/components/gateways/gateway-sheet.tsx +567 -0
  61. package/src/components/input/chat-input.tsx +135 -86
  62. package/src/components/layout/app-layout.tsx +2 -0
  63. package/src/components/memory/memory-browser.tsx +71 -6
  64. package/src/components/memory/memory-card.tsx +18 -0
  65. package/src/components/memory/memory-detail.tsx +58 -31
  66. package/src/components/memory/memory-sheet.tsx +32 -4
  67. package/src/components/projects/project-detail.tsx +7 -2
  68. package/src/components/providers/provider-list.tsx +158 -2
  69. package/src/components/providers/provider-sheet.tsx +81 -70
  70. package/src/components/shared/bottom-sheet.tsx +31 -15
  71. package/src/components/shared/confirm-dialog.tsx +45 -30
  72. package/src/components/shared/model-combobox.tsx +90 -8
  73. package/src/components/shared/settings/section-heartbeat.tsx +11 -6
  74. package/src/components/shared/settings/section-orchestrator.tsx +3 -0
  75. package/src/components/shared/settings/settings-page.tsx +5 -3
  76. package/src/components/tasks/approvals-panel.tsx +7 -1
  77. package/src/components/ui/dialog.tsx +2 -2
  78. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  79. package/src/lib/heartbeat-defaults.ts +48 -0
  80. package/src/lib/memory-presentation.ts +59 -0
  81. package/src/lib/provider-model-discovery-client.ts +29 -0
  82. package/src/lib/providers/index.ts +12 -5
  83. package/src/lib/runtime-loop.ts +105 -3
  84. package/src/lib/safe-storage.ts +6 -1
  85. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  86. package/src/lib/server/agent-runtime-config.ts +277 -0
  87. package/src/lib/server/approvals-auto-approve.test.ts +59 -0
  88. package/src/lib/server/build-llm.test.ts +13 -5
  89. package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
  90. package/src/lib/server/chat-execution.ts +159 -71
  91. package/src/lib/server/chatroom-helpers.test.ts +7 -0
  92. package/src/lib/server/chatroom-helpers.ts +99 -6
  93. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  94. package/src/lib/server/connectors/manager.ts +89 -61
  95. package/src/lib/server/connectors/slack.ts +1 -1
  96. package/src/lib/server/daemon-state.ts +3 -2
  97. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  98. package/src/lib/server/eval/agent-regression.ts +1742 -0
  99. package/src/lib/server/eval/runner.ts +11 -1
  100. package/src/lib/server/eval/store.ts +2 -1
  101. package/src/lib/server/heartbeat-service.ts +10 -4
  102. package/src/lib/server/main-agent-loop.ts +13 -6
  103. package/src/lib/server/openclaw-exec-config.ts +4 -2
  104. package/src/lib/server/openclaw-gateway.ts +123 -36
  105. package/src/lib/server/orchestrator-lg.ts +1 -2
  106. package/src/lib/server/orchestrator.ts +3 -2
  107. package/src/lib/server/plugins.test.ts +9 -1
  108. package/src/lib/server/plugins.ts +12 -2
  109. package/src/lib/server/provider-model-discovery.ts +481 -0
  110. package/src/lib/server/queue.ts +1 -1
  111. package/src/lib/server/runtime-settings.test.ts +119 -0
  112. package/src/lib/server/runtime-settings.ts +12 -92
  113. package/src/lib/server/schedule-normalization.ts +187 -0
  114. package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
  115. package/src/lib/server/session-tools/crud.ts +27 -3
  116. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  117. package/src/lib/server/session-tools/discovery.ts +18 -8
  118. package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
  119. package/src/lib/server/session-tools/file.ts +8 -2
  120. package/src/lib/server/session-tools/http.ts +9 -3
  121. package/src/lib/server/session-tools/index.ts +31 -1
  122. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  123. package/src/lib/server/session-tools/monitor.ts +14 -7
  124. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  125. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  126. package/src/lib/server/session-tools/platform.ts +1 -1
  127. package/src/lib/server/session-tools/plugin-creator.ts +9 -2
  128. package/src/lib/server/session-tools/sandbox.ts +51 -92
  129. package/src/lib/server/session-tools/session-info.ts +22 -1
  130. package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
  131. package/src/lib/server/session-tools/shell.ts +2 -2
  132. package/src/lib/server/session-tools/subagent.ts +3 -1
  133. package/src/lib/server/session-tools/web.ts +73 -30
  134. package/src/lib/server/storage.ts +29 -3
  135. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  136. package/src/lib/server/stream-agent-chat.ts +139 -4
  137. package/src/lib/server/structured-extract.ts +1 -1
  138. package/src/lib/server/task-mention.ts +0 -1
  139. package/src/lib/server/tool-aliases.ts +37 -6
  140. package/src/lib/server/tool-capability-policy.ts +1 -1
  141. package/src/lib/setup-defaults.ts +352 -11
  142. package/src/lib/tool-definitions.ts +3 -4
  143. package/src/lib/validation/schemas.ts +55 -1
  144. package/src/stores/use-app-store.ts +43 -1
  145. package/src/stores/use-chatroom-store.ts +153 -26
  146. package/src/types/index.ts +189 -6
  147. package/src/app/api/chats/[id]/main-loop/route.ts +0 -13
@@ -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" />
@@ -7,6 +7,14 @@ import { useWs } from '@/hooks/use-ws'
7
7
  import type { Chatroom } from '@/types'
8
8
  import { EmptyState } from '@/components/shared/empty-state'
9
9
 
10
+ function formatRoomTime(ts: number): string {
11
+ const diff = Date.now() - ts
12
+ if (diff < 60_000) return 'Now'
13
+ if (diff < 3_600_000) return `${Math.max(1, Math.floor(diff / 60_000))}m`
14
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
15
+ return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
16
+ }
17
+
10
18
  export function ChatroomList() {
11
19
  const chatrooms = useChatroomStore((s) => s.chatrooms)
12
20
  const currentChatroomId = useChatroomStore((s) => s.currentChatroomId)
@@ -15,6 +23,9 @@ export function ChatroomList() {
15
23
  const setChatroomSheetOpen = useChatroomStore((s) => s.setChatroomSheetOpen)
16
24
  const setEditingChatroomId = useChatroomStore((s) => s.setEditingChatroomId)
17
25
  const agents = useAppStore((s) => s.agents)
26
+ const lastReadTimestamps = useAppStore((s) => s.lastReadTimestamps)
27
+ const [filter, setFilter] = useState<'all' | 'active' | 'recent' | 'unread'>('all')
28
+ const [search, setSearch] = useState('')
18
29
 
19
30
  const refresh = useCallback(() => {
20
31
  loadChatrooms()
@@ -24,32 +35,56 @@ export function ChatroomList() {
24
35
  useEffect(() => { refresh() }, [refresh])
25
36
  useWs('chatrooms', refresh, 15_000)
26
37
 
27
- // Auto-select the latest chatroom when none is selected
28
38
  useEffect(() => {
29
39
  if (currentChatroomId) return
30
40
  const latest = Object.values(chatrooms).sort((a, b) => b.updatedAt - a.updatedAt)[0]
31
41
  if (latest) setCurrentChatroom(latest.id)
32
42
  }, [chatrooms, currentChatroomId, setCurrentChatroom])
33
43
 
34
- const [filter, setFilter] = useState<'all' | 'active' | 'recent'>('all')
44
+ const enriched = useMemo(() => (
45
+ Object.values(chatrooms)
46
+ .map((chatroom: Chatroom) => {
47
+ const memberNames = chatroom.agentIds
48
+ .map((id) => agents[id]?.name)
49
+ .filter(Boolean)
50
+ const lastMsg = chatroom.messages[chatroom.messages.length - 1]
51
+ const lastReadAt = lastReadTimestamps[chatroom.id] || 0
52
+ const unreadCount = chatroom.messages.filter(
53
+ (msg) => msg.senderId !== 'user' && msg.senderId !== 'system' && (msg.time || 0) > lastReadAt,
54
+ ).length
35
55
 
36
- const sorted = useMemo(() =>
37
- Object.values(chatrooms).sort(
38
- (a: Chatroom, b: Chatroom) => b.updatedAt - a.updatedAt
39
- ), [chatrooms])
56
+ return {
57
+ chatroom,
58
+ memberNames,
59
+ lastMsg,
60
+ unreadCount,
61
+ searchText: [
62
+ chatroom.name,
63
+ chatroom.description,
64
+ memberNames.join(' '),
65
+ lastMsg?.senderName,
66
+ lastMsg?.text,
67
+ ].filter(Boolean).join(' ').toLowerCase(),
68
+ }
69
+ })
70
+ .sort((a, b) => b.chatroom.updatedAt - a.chatroom.updatedAt)
71
+ ), [agents, chatrooms, lastReadTimestamps])
40
72
 
41
73
  const filtered = useMemo(() => {
42
- if (filter === 'all') return sorted
74
+ const query = search.trim().toLowerCase()
43
75
  const now = Date.now()
44
- return sorted.filter((c) => {
45
- if (filter === 'active') return now - c.updatedAt < 3_600_000 // 1h
46
- return now - c.updatedAt < 86_400_000 // 24h
76
+ return enriched.filter((item) => {
77
+ if (query && !item.searchText.includes(query)) return false
78
+ if (filter === 'active') return now - item.chatroom.updatedAt < 3_600_000
79
+ if (filter === 'recent') return now - item.chatroom.updatedAt < 86_400_000
80
+ if (filter === 'unread') return item.unreadCount > 0
81
+ return true
47
82
  })
48
- }, [sorted, filter])
83
+ }, [enriched, filter, search])
49
84
 
50
85
  return (
51
86
  <div className="flex-1 overflow-y-auto">
52
- {sorted.length === 0 ? (
87
+ {enriched.length === 0 ? (
53
88
  <EmptyState
54
89
  icon={
55
90
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="text-accent-bright">
@@ -61,75 +96,109 @@ export function ChatroomList() {
61
96
  action={{ label: '+ New Chatroom', onClick: () => { setEditingChatroomId(null); setChatroomSheetOpen(true) } }}
62
97
  />
63
98
  ) : (
64
- <div className="p-3 space-y-1">
65
- {sorted.length > 2 && (
66
- <div className="flex items-center gap-1 px-1 pb-2" style={{ animation: 'fade-up 0.4s var(--ease-spring)' }}>
67
- {(['all', 'active', 'recent'] as const).map((f) => (
99
+ <div className="p-3 space-y-3">
100
+ <div className="space-y-2">
101
+ <input
102
+ type="text"
103
+ value={search}
104
+ onChange={(e) => setSearch(e.target.value)}
105
+ placeholder="Search rooms, members, or recent messages..."
106
+ className="w-full rounded-[12px] border border-white/[0.06] bg-surface px-3 py-2.5 text-[13px] text-text placeholder:text-text-3/70 focus:outline-none focus:border-accent-bright/35"
107
+ />
108
+ <div className="flex flex-wrap items-center gap-1">
109
+ {(['all', 'active', 'recent', 'unread'] as const).map((value) => (
68
110
  <button
69
- key={f}
111
+ key={value}
70
112
  type="button"
71
- onClick={() => setFilter(f)}
72
- data-active={filter === f || undefined}
73
- className="px-3 py-1.5 rounded-[8px] text-[11px] font-600 border-none cursor-pointer transition-all focus-visible:ring-1 focus-visible:ring-accent-bright/50
113
+ onClick={() => setFilter(value)}
114
+ data-active={filter === value || undefined}
115
+ className="rounded-[8px] border-none px-3 py-1.5 text-[11px] font-600 capitalize cursor-pointer transition-all focus-visible:ring-1 focus-visible:ring-accent-bright/50
74
116
  data-[active]:bg-accent-soft data-[active]:text-accent-bright
75
117
  bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]"
76
118
  >
77
- {f}
119
+ {value}
78
120
  </button>
79
121
  ))}
122
+ <span className="ml-auto text-[11px] text-text-3/55">
123
+ {filtered.length} room{filtered.length === 1 ? '' : 's'}
124
+ </span>
80
125
  </div>
81
- )}
82
- {filtered.map((chatroom, idx) => {
83
- const isActive = chatroom.id === currentChatroomId
84
- const memberNames = chatroom.agentIds
85
- .map((id) => agents[id]?.name)
86
- .filter(Boolean)
87
- .slice(0, 3)
88
- const lastMsg = chatroom.messages[chatroom.messages.length - 1]
126
+ </div>
89
127
 
90
- return (
91
- <button
92
- key={chatroom.id}
93
- onClick={() => setCurrentChatroom(chatroom.id)}
94
- className={`w-full text-left py-3.5 px-4 rounded-[14px] transition-all cursor-pointer group border border-transparent relative overflow-hidden ${
95
- isActive
96
- ? 'bg-accent-soft/60'
97
- : 'hover:bg-white/[0.04] hover:scale-[1.01]'
98
- }`}
99
- style={{
100
- animation: 'fade-up 0.4s var(--ease-spring) both',
101
- animationDelay: `${idx * 0.03}s`
102
- }}
103
- >
104
- <div className="flex items-center gap-2 mb-0.5">
105
- <div className="w-7 h-7 rounded-full bg-accent-soft flex items-center justify-center shrink-0">
106
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-bright">
107
- <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
108
- </svg>
109
- </div>
110
- <span className={`text-[13px] font-600 truncate ${isActive ? 'text-accent-bright' : 'text-text'}`}>
111
- {chatroom.name}
112
- </span>
113
- {isActive && (
114
- <div className="absolute left-0 top-3 bottom-3 w-1 rounded-r-full bg-accent-bright" style={{ animation: 'spring-in 0.3s var(--ease-spring)' }} />
115
- )}
116
- <span className="label-mono ml-auto shrink-0">
117
- {chatroom.agentIds.length} agents
118
- </span>
119
- </div>
120
- {memberNames.length > 0 && (
121
- <p className="text-[11px] text-text-3 truncate pl-9">
122
- {memberNames.join(', ')}{chatroom.agentIds.length > 3 ? ` +${chatroom.agentIds.length - 3}` : ''}
123
- </p>
124
- )}
125
- {lastMsg && (
126
- <p className="text-[11px] text-text-3/70 truncate pl-9 mt-0.5">
127
- {lastMsg.senderName}: {lastMsg.text.slice(0, 60)}
128
- </p>
129
- )}
130
- </button>
131
- )
132
- })}
128
+ {filtered.length === 0 ? (
129
+ <div className="rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-8 text-center">
130
+ <div className="text-[13px] font-600 text-text-2">No rooms match this view</div>
131
+ <div className="mt-1 text-[12px] text-text-3/65">
132
+ Clear the search or switch filters to see more chatrooms.
133
+ </div>
134
+ </div>
135
+ ) : (
136
+ <div className="space-y-1">
137
+ {filtered.map(({ chatroom, memberNames, lastMsg, unreadCount }, idx) => {
138
+ const isActive = chatroom.id === currentChatroomId
139
+ return (
140
+ <button
141
+ key={chatroom.id}
142
+ onClick={() => setCurrentChatroom(chatroom.id)}
143
+ className={`relative w-full overflow-hidden rounded-[14px] border px-4 py-3.5 text-left transition-all cursor-pointer ${
144
+ isActive
145
+ ? 'border-accent-bright/20 bg-accent-soft/55'
146
+ : 'border-transparent hover:bg-white/[0.04] hover:border-white/[0.05]'
147
+ }`}
148
+ style={{
149
+ animation: 'fade-up 0.4s var(--ease-spring) both',
150
+ animationDelay: `${idx * 0.03}s`,
151
+ }}
152
+ >
153
+ {isActive && (
154
+ <div className="absolute inset-y-3 left-0 w-1 rounded-r-full bg-accent-bright" />
155
+ )}
156
+ <div className="flex items-start gap-3">
157
+ <div className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-accent-soft shrink-0">
158
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-bright">
159
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
160
+ </svg>
161
+ </div>
162
+ <div className="min-w-0 flex-1">
163
+ <div className="flex items-center gap-2">
164
+ <span className={`truncate text-[13px] font-700 ${isActive ? 'text-accent-bright' : 'text-text'}`}>
165
+ {chatroom.name}
166
+ </span>
167
+ {unreadCount > 0 && (
168
+ <span className="inline-flex min-w-[18px] items-center justify-center rounded-full bg-accent-bright px-1.5 py-0.5 text-[10px] font-700 text-white">
169
+ {unreadCount > 99 ? '99+' : unreadCount}
170
+ </span>
171
+ )}
172
+ <span className="ml-auto shrink-0 text-[10px] font-mono text-text-3/55">
173
+ {formatRoomTime(lastMsg?.time || chatroom.updatedAt)}
174
+ </span>
175
+ </div>
176
+ <div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-text-3">
177
+ <span>{chatroom.agentIds.length} agent{chatroom.agentIds.length === 1 ? '' : 's'}</span>
178
+ {chatroom.chatMode === 'parallel' && (
179
+ <span className="rounded-[6px] bg-sky-500/10 px-1.5 py-0.5 text-sky-300">Parallel</span>
180
+ )}
181
+ {chatroom.autoAddress && (
182
+ <span className="rounded-[6px] bg-emerald-500/10 px-1.5 py-0.5 text-emerald-300">Auto-address</span>
183
+ )}
184
+ </div>
185
+ {memberNames.length > 0 && (
186
+ <p className="mt-1 truncate text-[11px] text-text-3/80">
187
+ {memberNames.slice(0, 3).join(', ')}{memberNames.length > 3 ? ` +${memberNames.length - 3}` : ''}
188
+ </p>
189
+ )}
190
+ {lastMsg && (
191
+ <p className="mt-1 truncate text-[11px] text-text-3/65">
192
+ {lastMsg.senderName}: {lastMsg.text.slice(0, 72)}
193
+ </p>
194
+ )}
195
+ </div>
196
+ </div>
197
+ </button>
198
+ )
199
+ })}
200
+ </div>
201
+ )}
133
202
  </div>
134
203
  )}
135
204
  </div>
@@ -393,12 +393,12 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
393
393
  </div>
394
394
 
395
395
  {/* Action buttons (reply + pin + transfer + moderate + reaction) */}
396
- <div className="relative shrink-0 mt-0.5 flex items-start gap-0.5" style={{ zIndex: showPicker || showTransferPicker || showModMenu ? 50 : undefined }}>
396
+ <div className="relative shrink-0 mt-0.5 flex items-start gap-1" style={{ zIndex: showPicker || showTransferPicker || showModMenu ? 50 : undefined }}>
397
397
  {/* Reply button */}
398
398
  {onReply && (
399
399
  <button
400
400
  onClick={() => onReply(message)}
401
- className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-full flex items-center justify-center hover:bg-white/[0.08] transition-all cursor-pointer"
401
+ className="w-7 h-7 rounded-[8px] border border-white/[0.06] bg-white/[0.02] flex items-center justify-center hover:bg-white/[0.08] transition-all cursor-pointer"
402
402
  title="Reply"
403
403
  >
404
404
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-3">
@@ -411,7 +411,7 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
411
411
  {onTogglePin && (
412
412
  <button
413
413
  onClick={() => onTogglePin(message.id)}
414
- className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-full flex items-center justify-center hover:bg-white/[0.08] transition-all cursor-pointer"
414
+ className="w-7 h-7 rounded-[8px] border border-white/[0.06] bg-white/[0.02] flex items-center justify-center hover:bg-white/[0.08] transition-all cursor-pointer"
415
415
  title={pinnedMessageIds?.includes(message.id) ? 'Unpin message' : 'Pin message'}
416
416
  >
417
417
  <svg width="12" height="12" viewBox="0 0 24 24" fill={pinnedMessageIds?.includes(message.id) ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={pinnedMessageIds?.includes(message.id) ? 'text-amber-400' : 'text-text-3'}>
@@ -424,7 +424,7 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
424
424
  {onTransfer && !isUser && (
425
425
  <button
426
426
  onClick={() => setShowTransferPicker(!showTransferPicker)}
427
- className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-full flex items-center justify-center hover:bg-white/[0.08] transition-all cursor-pointer"
427
+ className="w-7 h-7 rounded-[8px] border border-white/[0.06] bg-white/[0.02] flex items-center justify-center hover:bg-white/[0.08] transition-all cursor-pointer"
428
428
  title="Transfer to agent"
429
429
  >
430
430
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-3">
@@ -449,7 +449,7 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
449
449
  {!isUser && (onDeleteMessage || onMuteAgent || onSetRole) && (
450
450
  <button
451
451
  onClick={() => setShowModMenu(!showModMenu)}
452
- className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-full flex items-center justify-center hover:bg-white/[0.08] transition-all cursor-pointer"
452
+ className="w-7 h-7 rounded-[8px] border border-white/[0.06] bg-white/[0.02] flex items-center justify-center hover:bg-white/[0.08] transition-all cursor-pointer"
453
453
  title="Moderate"
454
454
  >
455
455
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3">
@@ -550,7 +550,8 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
550
550
  {/* Reaction button */}
551
551
  <button
552
552
  onClick={() => setShowPicker(!showPicker)}
553
- className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-full flex items-center justify-center hover:bg-white/[0.08] transition-all cursor-pointer"
553
+ className="w-7 h-7 rounded-[8px] border border-white/[0.06] bg-white/[0.02] flex items-center justify-center hover:bg-white/[0.08] transition-all cursor-pointer"
554
+ title="Add reaction"
554
555
  >
555
556
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-3">
556
557
  <circle cx="12" cy="12" r="10" />
@@ -190,6 +190,10 @@ export function ChatroomSheet() {
190
190
 
191
191
  const handleSave = async () => {
192
192
  if (!name.trim() || saving) return
193
+ if (selectedAgentIds.length === 0) {
194
+ toast.error('Select at least one chatroom member.')
195
+ return
196
+ }
193
197
  setSaving(true)
194
198
  try {
195
199
  const payload = {
@@ -363,6 +367,9 @@ export function ChatroomSheet() {
363
367
  <label className="block text-[12px] font-600 text-text-2 mb-1.5">
364
368
  Members ({selectedAgentIds.length} selected)
365
369
  </label>
370
+ <p className="mb-2 text-[11px] text-text-3">
371
+ Choose the agents who should be available in this room. Every chatroom needs at least one member.
372
+ </p>
366
373
  <div className="max-h-[240px] overflow-y-auto rounded-[8px] border border-white/[0.08] bg-white/[0.03]">
367
374
  {agentList.length === 0 ? (
368
375
  <p className="p-3 text-[12px] text-text-3">No agents available</p>
@@ -387,6 +394,11 @@ export function ChatroomSheet() {
387
394
  })
388
395
  )}
389
396
  </div>
397
+ {selectedAgentIds.length === 0 && (
398
+ <p className="mt-2 text-[11px] text-amber-300">
399
+ Select at least one member before creating the room.
400
+ </p>
401
+ )}
390
402
  </div>
391
403
 
392
404
  {/* Routing Rules */}
@@ -484,7 +496,7 @@ export function ChatroomSheet() {
484
496
  <div className="flex items-center gap-3 mt-6">
485
497
  <button
486
498
  onClick={handleSave}
487
- disabled={!name.trim() || saving}
499
+ disabled={!name.trim() || saving || selectedAgentIds.length === 0}
488
500
  className="flex-1 py-2.5 rounded-[8px] text-[13px] font-600 bg-accent-bright text-white hover:bg-accent-bright/90 transition-all disabled:opacity-50 cursor-pointer"
489
501
  >
490
502
  {saving ? 'Saving...' : editing ? 'Save Changes' : 'Create Chatroom'}
@@ -103,6 +103,9 @@ export function ChatroomToolRequestBanner({ agentId, agentName, text, toolOutput
103
103
  <span className="text-accent-bright">{agentName}</span> requesting <span className="text-amber-400">{label}</span>
104
104
  </p>
105
105
  {reason && <p className="text-[11px] text-text-3/60 mt-0.5 truncate">{reason}</p>}
106
+ <p className="text-[10px] text-text-3/45 mt-1">
107
+ Approving updates this agent&apos;s tool access and posts a follow-up continue message in the room.
108
+ </p>
106
109
  </div>
107
110
  {isGranted ? (
108
111
  <span className="text-[11px] text-emerald-400 font-600 shrink-0">Granted</span>
@@ -115,14 +118,14 @@ export function ChatroomToolRequestBanner({ agentId, agentName, text, toolOutput
115
118
  className="px-3 py-1.5 rounded-[8px] bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 text-[11px] font-600 border-none cursor-pointer transition-colors"
116
119
  style={{ fontFamily: 'inherit' }}
117
120
  >
118
- Grant
121
+ Grant & Continue
119
122
  </button>
120
123
  <button
121
124
  onClick={() => handleDeny(toolId)}
122
125
  className="px-3 py-1.5 rounded-[8px] bg-red-500/15 hover:bg-red-500/25 text-red-400 text-[11px] font-600 border-none cursor-pointer transition-colors"
123
126
  style={{ fontFamily: 'inherit' }}
124
127
  >
125
- Deny
128
+ Deny & Reply
126
129
  </button>
127
130
  </div>
128
131
  )}