@swarmclawai/swarmclaw 0.6.4 → 0.6.7

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 (143) hide show
  1. package/README.md +62 -30
  2. package/package.json +10 -1
  3. package/src/app/api/agents/[id]/clone/route.ts +40 -0
  4. package/src/app/api/agents/route.ts +39 -14
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +58 -3
  6. package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
  7. package/src/app/api/chatrooms/[id]/route.ts +34 -2
  8. package/src/app/api/chatrooms/route.ts +26 -3
  9. package/src/app/api/connectors/[id]/health/route.ts +64 -0
  10. package/src/app/api/connectors/route.ts +17 -2
  11. package/src/app/api/knowledge/route.ts +6 -1
  12. package/src/app/api/openclaw/doctor/route.ts +17 -0
  13. package/src/app/api/schedules/[id]/run/route.ts +3 -0
  14. package/src/app/api/sessions/[id]/chat/route.ts +5 -1
  15. package/src/app/api/sessions/route.ts +11 -2
  16. package/src/app/api/tasks/[id]/route.ts +18 -13
  17. package/src/app/api/tasks/route.ts +44 -1
  18. package/src/app/api/usage/route.ts +16 -7
  19. package/src/app/api/wallets/[id]/approve/route.ts +62 -0
  20. package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
  21. package/src/app/api/wallets/[id]/route.ts +118 -0
  22. package/src/app/api/wallets/[id]/send/route.ts +118 -0
  23. package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
  24. package/src/app/api/wallets/route.ts +74 -0
  25. package/src/app/globals.css +8 -0
  26. package/src/cli/index.js +20 -0
  27. package/src/cli/index.ts +223 -39
  28. package/src/cli/spec.js +14 -0
  29. package/src/components/agents/agent-avatar.tsx +15 -1
  30. package/src/components/agents/agent-card.tsx +38 -6
  31. package/src/components/agents/agent-chat-list.tsx +79 -3
  32. package/src/components/agents/agent-sheet.tsx +191 -26
  33. package/src/components/auth/setup-wizard.tsx +268 -353
  34. package/src/components/chat/chat-area.tsx +24 -9
  35. package/src/components/chat/chat-header.tsx +48 -19
  36. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  37. package/src/components/chat/delegation-banner.test.ts +27 -0
  38. package/src/components/chat/delegation-banner.tsx +109 -23
  39. package/src/components/chat/message-bubble.tsx +17 -16
  40. package/src/components/chat/message-list.tsx +6 -5
  41. package/src/components/chat/streaming-bubble.tsx +3 -2
  42. package/src/components/chat/thinking-indicator.tsx +3 -2
  43. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  44. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  45. package/src/components/chatrooms/chatroom-input.tsx +1 -1
  46. package/src/components/chatrooms/chatroom-message.tsx +165 -23
  47. package/src/components/chatrooms/chatroom-sheet.tsx +289 -4
  48. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  49. package/src/components/chatrooms/chatroom-view.tsx +62 -17
  50. package/src/components/connectors/connector-health.tsx +120 -0
  51. package/src/components/connectors/connector-list.tsx +1 -1
  52. package/src/components/connectors/connector-sheet.tsx +9 -0
  53. package/src/components/home/home-view.tsx +25 -3
  54. package/src/components/input/chat-input.tsx +8 -1
  55. package/src/components/knowledge/knowledge-list.tsx +1 -1
  56. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  57. package/src/components/layout/app-layout.tsx +35 -4
  58. package/src/components/memory/memory-agent-list.tsx +1 -1
  59. package/src/components/memory/memory-browser.tsx +1 -0
  60. package/src/components/memory/memory-card.tsx +3 -2
  61. package/src/components/memory/memory-detail.tsx +3 -3
  62. package/src/components/memory/memory-sheet.tsx +2 -2
  63. package/src/components/projects/project-detail.tsx +4 -4
  64. package/src/components/schedules/schedule-list.tsx +55 -9
  65. package/src/components/schedules/schedule-sheet.tsx +134 -23
  66. package/src/components/secrets/secret-sheet.tsx +1 -1
  67. package/src/components/secrets/secrets-list.tsx +1 -1
  68. package/src/components/sessions/session-card.tsx +1 -1
  69. package/src/components/shared/agent-picker-list.tsx +1 -1
  70. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  71. package/src/components/shared/command-palette.tsx +237 -0
  72. package/src/components/shared/connector-platform-icon.tsx +1 -0
  73. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  74. package/src/components/skills/skill-list.tsx +1 -1
  75. package/src/components/skills/skill-sheet.tsx +1 -1
  76. package/src/components/tasks/task-board.tsx +3 -3
  77. package/src/components/tasks/task-card.tsx +22 -2
  78. package/src/components/tasks/task-sheet.tsx +112 -17
  79. package/src/components/usage/metrics-dashboard.tsx +13 -25
  80. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  81. package/src/components/wallets/wallet-panel.tsx +616 -0
  82. package/src/components/wallets/wallet-section.tsx +100 -0
  83. package/src/hooks/use-swipe.ts +49 -0
  84. package/src/lib/providers/anthropic.ts +16 -2
  85. package/src/lib/providers/claude-cli.ts +7 -1
  86. package/src/lib/providers/index.ts +7 -0
  87. package/src/lib/providers/ollama.ts +16 -2
  88. package/src/lib/providers/openai.ts +7 -2
  89. package/src/lib/providers/openclaw.ts +6 -1
  90. package/src/lib/providers/provider-defaults.ts +7 -0
  91. package/src/lib/schedule-templates.ts +115 -0
  92. package/src/lib/server/agent-registry.ts +2 -2
  93. package/src/lib/server/alert-dispatch.ts +64 -0
  94. package/src/lib/server/chat-execution.ts +76 -4
  95. package/src/lib/server/chatroom-health.ts +60 -0
  96. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  97. package/src/lib/server/chatroom-helpers.ts +86 -12
  98. package/src/lib/server/chatroom-routing.ts +65 -0
  99. package/src/lib/server/connectors/discord.ts +3 -0
  100. package/src/lib/server/connectors/email.ts +267 -0
  101. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  102. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  103. package/src/lib/server/connectors/manager.ts +239 -5
  104. package/src/lib/server/connectors/openclaw.ts +3 -0
  105. package/src/lib/server/connectors/slack.ts +6 -0
  106. package/src/lib/server/connectors/telegram.ts +18 -0
  107. package/src/lib/server/connectors/types.ts +2 -0
  108. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  109. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  110. package/src/lib/server/connectors/whatsapp.ts +17 -5
  111. package/src/lib/server/cost.ts +70 -0
  112. package/src/lib/server/create-notification.ts +2 -0
  113. package/src/lib/server/daemon-state.ts +124 -0
  114. package/src/lib/server/dag-validation.ts +115 -0
  115. package/src/lib/server/memory-db.ts +12 -7
  116. package/src/lib/server/openclaw-doctor.ts +48 -0
  117. package/src/lib/server/orchestrator-lg.ts +12 -2
  118. package/src/lib/server/orchestrator.ts +6 -1
  119. package/src/lib/server/queue-followups.test.ts +224 -0
  120. package/src/lib/server/queue.ts +238 -24
  121. package/src/lib/server/scheduler.ts +3 -0
  122. package/src/lib/server/session-run-manager.ts +22 -1
  123. package/src/lib/server/session-tools/chatroom.ts +11 -2
  124. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  125. package/src/lib/server/session-tools/index.ts +8 -2
  126. package/src/lib/server/session-tools/memory.ts +23 -4
  127. package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
  128. package/src/lib/server/session-tools/shell.ts +1 -1
  129. package/src/lib/server/session-tools/wallet.ts +124 -0
  130. package/src/lib/server/session-tools/web.ts +2 -2
  131. package/src/lib/server/solana.ts +122 -0
  132. package/src/lib/server/storage.ts +158 -6
  133. package/src/lib/server/stream-agent-chat.ts +126 -63
  134. package/src/lib/server/task-mention.test.ts +41 -0
  135. package/src/lib/server/task-mention.ts +3 -2
  136. package/src/lib/setup-defaults.ts +277 -0
  137. package/src/lib/tool-definitions.ts +1 -0
  138. package/src/lib/validation/schemas.ts +69 -0
  139. package/src/lib/view-routes.ts +1 -0
  140. package/src/stores/use-app-store.ts +15 -3
  141. package/src/stores/use-chatroom-store.ts +52 -2
  142. package/src/types/index.ts +98 -2
  143. package/tsconfig.json +2 -1
@@ -10,6 +10,7 @@ import { useChatStore } from '@/stores/use-chat-store'
10
10
  interface Props {
11
11
  assistantName?: string
12
12
  agentAvatarSeed?: string
13
+ agentAvatarUrl?: string | null
13
14
  agentName?: string
14
15
  }
15
16
 
@@ -34,7 +35,7 @@ function ElapsedTimer({ startTime }: { startTime: number }) {
34
35
  )
35
36
  }
36
37
 
37
- export function ThinkingIndicator({ assistantName, agentAvatarSeed, agentName }: Props) {
38
+ export function ThinkingIndicator({ assistantName, agentAvatarSeed, agentAvatarUrl, agentName }: Props) {
38
39
  const streamPhase = useChatStore((s) => s.streamPhase)
39
40
  const streamToolName = useChatStore((s) => s.streamToolName)
40
41
  const thinkingText = useChatStore((s) => s.thinkingText)
@@ -50,7 +51,7 @@ export function ThinkingIndicator({ assistantName, agentAvatarSeed, agentName }:
50
51
  <div className="flex flex-col items-start relative pl-[44px]"
51
52
  style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}>
52
53
  <div className="absolute left-[4px] top-0">
53
- {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={28} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
54
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} avatarUrl={agentAvatarUrl} name={agentName} size={28} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
54
55
  </div>
55
56
  <div className="flex items-center gap-2.5 mb-2 px-1">
56
57
  <span className="text-[12px] font-600 text-text-3">{assistantName || 'Claude'}</span>
@@ -52,7 +52,7 @@ export function TransferAgentPicker({ excludeIds, filterIds, onSelect, onClose }
52
52
  className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-white/[0.06] transition-colors cursor-pointer bg-transparent border-none"
53
53
  style={{ fontFamily: 'inherit' }}
54
54
  >
55
- <AgentAvatar seed={a.avatarSeed} name={a.name} size={20} />
55
+ <AgentAvatar seed={a.avatarSeed} avatarUrl={a.avatarUrl} name={a.name} size={20} />
56
56
  <span className="text-[12px] text-text truncate">{a.name}</span>
57
57
  </button>
58
58
  ))}
@@ -46,7 +46,7 @@ export function AgentHoverCard({ agent, children, status }: Props) {
46
46
  <HoverCardContent align="start" className="w-[280px]">
47
47
  {/* Header: avatar + name + model */}
48
48
  <div className="flex items-center gap-2">
49
- <AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={28} status={status} />
49
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={28} status={status} />
50
50
  <div className="min-w-0 flex-1">
51
51
  <div className="text-[13px] font-600 text-text truncate">{agent.name}</div>
52
52
  <div className="label-mono truncate">{agent.model}</div>
@@ -203,7 +203,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
203
203
  selectedIndex === i + 1 ? 'bg-white/[0.08]' : 'hover:bg-white/[0.06]'
204
204
  }`}
205
205
  >
206
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={20} />
206
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={20} />
207
207
  <span className="text-[13px] text-text">{agent.name}</span>
208
208
  </button>
209
209
  ))}
@@ -15,7 +15,7 @@ import { ChatroomToolRequestBanner } from './chatroom-tool-request-banner'
15
15
  import { isStructuredMarkdown } from '@/components/chat/markdown-utils'
16
16
  import { TransferAgentPicker } from '@/components/chat/transfer-agent-picker'
17
17
  import { ConnectorPlatformIcon, CONNECTOR_PLATFORM_META } from '@/components/shared/connector-platform-icon'
18
- import type { ChatroomMessage, Agent } from '@/types'
18
+ import type { ChatroomMessage, Chatroom, Agent } from '@/types'
19
19
 
20
20
  interface Props {
21
21
  message: ChatroomMessage
@@ -24,6 +24,11 @@ interface Props {
24
24
  onReply?: (message: ChatroomMessage) => void
25
25
  onTogglePin?: (messageId: string) => void
26
26
  onTransfer?: (messageId: string, targetAgentId: string) => void
27
+ onDeleteMessage?: (messageId: string, targetAgentId: string) => void
28
+ onMuteAgent?: (agentId: string) => void
29
+ onUnmuteAgent?: (agentId: string) => void
30
+ onSetRole?: (agentId: string, role: 'admin' | 'moderator' | 'member') => void
31
+ chatroom?: Chatroom
27
32
  pinnedMessageIds?: string[]
28
33
  /** Set of agentIds currently streaming */
29
34
  streamingAgentIds?: Set<string>
@@ -51,6 +56,25 @@ function navigateToAgent(agentId: string) {
51
56
  useAppStore.getState().setCurrentAgent(agentId)
52
57
  }
53
58
 
59
+ function getMemberRoleFromChatroom(chatroom: Chatroom | undefined, agentId: string): string {
60
+ if (!chatroom?.members?.length) return 'member'
61
+ const member = chatroom.members.find((m) => m.agentId === agentId)
62
+ return member?.role || 'member'
63
+ }
64
+
65
+ function isAgentMutedInChatroom(chatroom: Chatroom | undefined, agentId: string): boolean {
66
+ if (!chatroom?.members?.length) return false
67
+ const member = chatroom.members.find((m) => m.agentId === agentId)
68
+ if (!member?.mutedUntil) return false
69
+ return new Date(member.mutedUntil).getTime() > Date.now()
70
+ }
71
+
72
+ function roleBadgeStyle(role: string): { label: string; className: string } | null {
73
+ if (role === 'admin') return { label: 'Admin', className: 'bg-purple-500/20 text-purple-400 border-purple-500/30' }
74
+ if (role === 'moderator') return { label: 'Mod', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' }
75
+ return null
76
+ }
77
+
54
78
  /** Pre-process @mentions into markdown-friendly format for ReactMarkdown */
55
79
  function preprocessMentions(text: string, agents: Record<string, Agent>): string {
56
80
  const nameToId = new Map<string, string>()
@@ -111,9 +135,10 @@ function renderChatroomAttachments(message: ChatroomMessage) {
111
135
  )
112
136
  }
113
137
 
114
- export function ChatroomMessageBubble({ message, agents, onToggleReaction, onReply, onTogglePin, onTransfer, pinnedMessageIds, streamingAgentIds, messages, grouped: isGrouped, momentOverlay }: Props) {
138
+ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onReply, onTogglePin, onTransfer, onDeleteMessage, onMuteAgent, onUnmuteAgent, onSetRole, chatroom, pinnedMessageIds, streamingAgentIds, messages, grouped: isGrouped, momentOverlay }: Props) {
115
139
  const [showPicker, setShowPicker] = useState(false)
116
140
  const [showTransferPicker, setShowTransferPicker] = useState(false)
141
+ const [showModMenu, setShowModMenu] = useState(false)
117
142
  const userAvatarSeed = useAppStore((s) => s.appSettings.userAvatarSeed)
118
143
  const wide = isStructuredMarkdown(message.text)
119
144
 
@@ -172,7 +197,7 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
172
197
  className="bg-transparent border-none p-0 cursor-pointer transition-all duration-150 hover:scale-110 hover:-translate-y-0.5"
173
198
  style={momentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}
174
199
  >
175
- <AgentAvatar seed={agent.avatarSeed || null} name={message.senderName} size={28} status={streamingAgentIds?.has(message.senderId) ? 'busy' : 'online'} />
200
+ <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={message.senderName} size={28} status={streamingAgentIds?.has(message.senderId) ? 'busy' : 'online'} />
176
201
  </button>
177
202
  ) : (
178
203
  <div style={momentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}>
@@ -185,26 +210,41 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
185
210
 
186
211
  {/* Content */}
187
212
  <div className="flex-1 min-w-0">
188
- {!isGrouped && (
189
- <div className="flex items-baseline gap-2 mb-0.5">
190
- {!isUser && agent ? (
191
- <AgentHoverCard agent={agent}>
192
- <span className="text-[13px] font-600 text-accent-bright hover:underline cursor-pointer flex items-center gap-1.5">
213
+ {!isGrouped && (() => {
214
+ const role = !isUser ? getMemberRoleFromChatroom(chatroom, message.senderId) : 'member'
215
+ const badge = !isUser ? roleBadgeStyle(role) : null
216
+ const muted = !isUser ? isAgentMutedInChatroom(chatroom, message.senderId) : false
217
+ return (
218
+ <div className="flex items-baseline gap-2 mb-0.5">
219
+ {!isUser && agent ? (
220
+ <AgentHoverCard agent={agent}>
221
+ <span className="text-[13px] font-600 text-accent-bright hover:underline cursor-pointer flex items-center gap-1.5">
222
+ {message.source && <ConnectorPlatformIcon platform={message.source.platform} size={12} />}
223
+ {message.senderName}
224
+ </span>
225
+ </AgentHoverCard>
226
+ ) : (
227
+ <span className="text-[13px] font-600 text-text flex items-center gap-1.5">
193
228
  {message.source && <ConnectorPlatformIcon platform={message.source.platform} size={12} />}
194
- {message.senderName}
229
+ {isUser && message.source?.senderName
230
+ ? `${message.source.senderName} via ${CONNECTOR_PLATFORM_META[message.source.platform]?.label || message.source.platform}`
231
+ : message.senderName}
195
232
  </span>
196
- </AgentHoverCard>
197
- ) : (
198
- <span className="text-[13px] font-600 text-text flex items-center gap-1.5">
199
- {message.source && <ConnectorPlatformIcon platform={message.source.platform} size={12} />}
200
- {isUser && message.source?.senderName
201
- ? `${message.source.senderName} via ${CONNECTOR_PLATFORM_META[message.source.platform]?.label || message.source.platform}`
202
- : message.senderName}
203
- </span>
204
- )}
205
- <span className="label-mono" title={new Date(message.time).toLocaleString()}>{formatRelativeTime(message.time)}</span>
206
- </div>
207
- )}
233
+ )}
234
+ {badge && (
235
+ <span className={`text-[9px] font-600 px-1 py-0.5 rounded border leading-none ${badge.className}`}>
236
+ {badge.label}
237
+ </span>
238
+ )}
239
+ {muted && (
240
+ <span className="text-[9px] font-600 px-1 py-0.5 rounded border leading-none bg-red-500/20 text-red-400 border-red-500/30">
241
+ Muted
242
+ </span>
243
+ )}
244
+ <span className="label-mono" title={new Date(message.time).toLocaleString()}>{formatRelativeTime(message.time)}</span>
245
+ </div>
246
+ )
247
+ })()}
208
248
 
209
249
  {/* Reply quote */}
210
250
  {replyToMessage && (
@@ -352,8 +392,8 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
352
392
  )}
353
393
  </div>
354
394
 
355
- {/* Action buttons (reply + pin + transfer + reaction) */}
356
- <div className="relative shrink-0 mt-0.5 flex items-start gap-0.5" style={{ zIndex: showPicker || showTransferPicker ? 50 : undefined }}>
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 }}>
357
397
  {/* Reply button */}
358
398
  {onReply && (
359
399
  <button
@@ -405,6 +445,108 @@ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onRep
405
445
  onClose={() => setShowTransferPicker(false)}
406
446
  />
407
447
  )}
448
+ {/* Moderation menu button */}
449
+ {!isUser && (onDeleteMessage || onMuteAgent || onSetRole) && (
450
+ <button
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"
453
+ title="Moderate"
454
+ >
455
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3">
456
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
457
+ </svg>
458
+ </button>
459
+ )}
460
+ {showModMenu && !isUser && (
461
+ <div className="absolute right-0 top-7 z-50 bg-[#1a1a2e] border border-white/[0.1] rounded-[8px] shadow-lg py-1 min-w-[160px]">
462
+ {onDeleteMessage && (
463
+ <button
464
+ onClick={() => {
465
+ onDeleteMessage(message.id, message.senderId)
466
+ setShowModMenu(false)
467
+ }}
468
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] text-red-400 hover:bg-white/[0.06] transition-colors cursor-pointer bg-transparent border-none text-left"
469
+ style={{ fontFamily: 'inherit' }}
470
+ >
471
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
472
+ <polyline points="3 6 5 6 21 6" />
473
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
474
+ </svg>
475
+ Delete message
476
+ </button>
477
+ )}
478
+ {onMuteAgent && onUnmuteAgent && (() => {
479
+ const muted = isAgentMutedInChatroom(chatroom, message.senderId)
480
+ return muted ? (
481
+ <button
482
+ onClick={() => {
483
+ onUnmuteAgent(message.senderId)
484
+ setShowModMenu(false)
485
+ }}
486
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] text-green-400 hover:bg-white/[0.06] transition-colors cursor-pointer bg-transparent border-none text-left"
487
+ style={{ fontFamily: 'inherit' }}
488
+ >
489
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
490
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
491
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
492
+ </svg>
493
+ Unmute agent
494
+ </button>
495
+ ) : (
496
+ <button
497
+ onClick={() => {
498
+ onMuteAgent(message.senderId)
499
+ setShowModMenu(false)
500
+ }}
501
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] text-amber-400 hover:bg-white/[0.06] transition-colors cursor-pointer bg-transparent border-none text-left"
502
+ style={{ fontFamily: 'inherit' }}
503
+ >
504
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
505
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
506
+ <line x1="23" y1="9" x2="17" y2="15" />
507
+ <line x1="17" y1="9" x2="23" y2="15" />
508
+ </svg>
509
+ Mute 30 min
510
+ </button>
511
+ )
512
+ })()}
513
+ {onSetRole && (() => {
514
+ const currentRole = getMemberRoleFromChatroom(chatroom, message.senderId)
515
+ const roleOptions: Array<{ value: 'admin' | 'moderator' | 'member'; label: string }> = [
516
+ { value: 'admin', label: 'Set Admin' },
517
+ { value: 'moderator', label: 'Set Moderator' },
518
+ { value: 'member', label: 'Set Member' },
519
+ ]
520
+ return roleOptions
521
+ .filter((opt) => opt.value !== currentRole)
522
+ .map((opt) => (
523
+ <button
524
+ key={opt.value}
525
+ onClick={() => {
526
+ onSetRole(message.senderId, opt.value)
527
+ setShowModMenu(false)
528
+ }}
529
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] text-text-2 hover:bg-white/[0.06] transition-colors cursor-pointer bg-transparent border-none text-left"
530
+ style={{ fontFamily: 'inherit' }}
531
+ >
532
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
533
+ <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
534
+ <circle cx="8.5" cy="7" r="4" />
535
+ <polyline points="17 11 19 13 23 9" />
536
+ </svg>
537
+ {opt.label}
538
+ </button>
539
+ ))
540
+ })()}
541
+ <button
542
+ onClick={() => setShowModMenu(false)}
543
+ className="w-full px-3 py-1.5 text-[11px] text-text-3 hover:bg-white/[0.06] transition-colors cursor-pointer bg-transparent border-none text-left border-t border-white/[0.06] mt-1"
544
+ style={{ fontFamily: 'inherit' }}
545
+ >
546
+ Cancel
547
+ </button>
548
+ </div>
549
+ )}
408
550
  {/* Reaction button */}
409
551
  <button
410
552
  onClick={() => setShowPicker(!showPicker)}
@@ -6,9 +6,145 @@ import { useAppStore } from '@/stores/use-app-store'
6
6
  import { BottomSheet } from '@/components/shared/bottom-sheet'
7
7
  import { toast } from 'sonner'
8
8
  import { AgentAvatar } from '@/components/agents/agent-avatar'
9
- import type { Agent } from '@/types'
9
+ import type { Agent, ChatroomRoutingRule } from '@/types'
10
10
  import { CheckIcon } from '@/components/shared/check-icon'
11
11
 
12
+ function genRuleId(): string {
13
+ return `rule-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
14
+ }
15
+
16
+ interface RuleFormState {
17
+ type: 'keyword' | 'capability'
18
+ pattern: string
19
+ keywords: string
20
+ agentId: string
21
+ priority: number
22
+ }
23
+
24
+ const emptyRuleForm: RuleFormState = {
25
+ type: 'keyword',
26
+ pattern: '',
27
+ keywords: '',
28
+ agentId: '',
29
+ priority: 10,
30
+ }
31
+
32
+ function RoutingRuleForm({
33
+ rule,
34
+ memberAgents,
35
+ onSave,
36
+ onCancel,
37
+ }: {
38
+ rule: RuleFormState
39
+ memberAgents: Agent[]
40
+ onSave: (form: RuleFormState) => void
41
+ onCancel: () => void
42
+ }) {
43
+ const [form, setForm] = useState<RuleFormState>(rule)
44
+
45
+ return (
46
+ <div className="p-3 rounded-[8px] bg-white/[0.04] border border-white/[0.08] space-y-3">
47
+ <div className="flex gap-2">
48
+ {(['keyword', 'capability'] as const).map((t) => (
49
+ <button
50
+ key={t}
51
+ type="button"
52
+ onClick={() => setForm((f) => ({ ...f, type: t }))}
53
+ className={`flex-1 py-1.5 text-[11px] font-600 capitalize rounded-[6px] cursor-pointer transition-all ${
54
+ form.type === t
55
+ ? 'bg-accent-soft text-accent-bright'
56
+ : 'bg-white/[0.04] text-text-3 hover:text-text-2'
57
+ }`}
58
+ >
59
+ {t}
60
+ </button>
61
+ ))}
62
+ </div>
63
+
64
+ {form.type === 'keyword' && (
65
+ <>
66
+ <div>
67
+ <label className="block text-[11px] font-600 text-text-3 mb-1">Keywords (comma-separated)</label>
68
+ <input
69
+ type="text"
70
+ value={form.keywords}
71
+ onChange={(e) => setForm((f) => ({ ...f, keywords: e.target.value }))}
72
+ placeholder="e.g. deploy, devops, infrastructure"
73
+ className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40"
74
+ />
75
+ </div>
76
+ <div>
77
+ <label className="block text-[11px] font-600 text-text-3 mb-1">Regex pattern (optional)</label>
78
+ <input
79
+ type="text"
80
+ value={form.pattern}
81
+ onChange={(e) => setForm((f) => ({ ...f, pattern: e.target.value }))}
82
+ placeholder="e.g. deploy|release|ship"
83
+ className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40"
84
+ />
85
+ </div>
86
+ </>
87
+ )}
88
+
89
+ {form.type === 'capability' && (
90
+ <div>
91
+ <label className="block text-[11px] font-600 text-text-3 mb-1">Capability pattern</label>
92
+ <input
93
+ type="text"
94
+ value={form.pattern}
95
+ onChange={(e) => setForm((f) => ({ ...f, pattern: e.target.value }))}
96
+ placeholder="e.g. frontend, research, devops"
97
+ className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40"
98
+ />
99
+ </div>
100
+ )}
101
+
102
+ <div className="flex gap-2">
103
+ <div className="flex-1">
104
+ <label className="block text-[11px] font-600 text-text-3 mb-1">Route to agent</label>
105
+ <select
106
+ value={form.agentId}
107
+ onChange={(e) => setForm((f) => ({ ...f, agentId: e.target.value }))}
108
+ className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text focus:outline-none focus:border-accent-bright/40"
109
+ >
110
+ <option value="">Select agent...</option>
111
+ {memberAgents.map((a) => (
112
+ <option key={a.id} value={a.id}>{a.name}</option>
113
+ ))}
114
+ </select>
115
+ </div>
116
+ <div className="w-20">
117
+ <label className="block text-[11px] font-600 text-text-3 mb-1">Priority</label>
118
+ <input
119
+ type="number"
120
+ value={form.priority}
121
+ onChange={(e) => setForm((f) => ({ ...f, priority: parseInt(e.target.value, 10) || 0 }))}
122
+ className="w-full px-2.5 py-1.5 rounded-[6px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text focus:outline-none focus:border-accent-bright/40"
123
+ />
124
+ </div>
125
+ </div>
126
+
127
+ <div className="flex gap-2 justify-end">
128
+ <button
129
+ type="button"
130
+ onClick={onCancel}
131
+ className="px-3 py-1.5 text-[11px] font-600 text-text-3 hover:text-text-2 cursor-pointer"
132
+ >
133
+ Cancel
134
+ </button>
135
+ <button
136
+ type="button"
137
+ onClick={() => onSave(form)}
138
+ disabled={!form.agentId || (form.type === 'keyword' && !form.keywords.trim() && !form.pattern.trim()) || (form.type === 'capability' && !form.pattern.trim())}
139
+ className="px-3 py-1.5 text-[11px] font-600 bg-accent-bright text-white rounded-[6px] hover:bg-accent-bright/90 disabled:opacity-50 cursor-pointer"
140
+ >
141
+ Save Rule
142
+ </button>
143
+ </div>
144
+ </div>
145
+ )
146
+ }
147
+
12
148
  export function ChatroomSheet() {
13
149
  const open = useChatroomStore((s) => s.chatroomSheetOpen)
14
150
  const editingId = useChatroomStore((s) => s.editingChatroomId)
@@ -25,7 +161,10 @@ export function ChatroomSheet() {
25
161
  const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([])
26
162
  const [chatMode, setChatMode] = useState<'sequential' | 'parallel'>('sequential')
27
163
  const [autoAddress, setAutoAddress] = useState(false)
164
+ const [routingRules, setRoutingRules] = useState<ChatroomRoutingRule[]>([])
28
165
  const [saving, setSaving] = useState(false)
166
+ const [addingRule, setAddingRule] = useState(false)
167
+ const [editingRuleId, setEditingRuleId] = useState<string | null>(null)
29
168
 
30
169
  const editing = editingId ? chatrooms[editingId] : null
31
170
 
@@ -36,24 +175,36 @@ export function ChatroomSheet() {
36
175
  setSelectedAgentIds([...editing.agentIds])
37
176
  setChatMode(editing.chatMode || 'sequential')
38
177
  setAutoAddress(editing.autoAddress || false)
178
+ setRoutingRules([...(editing.routingRules || [])])
39
179
  } else {
40
180
  setName('')
41
181
  setDescription('')
42
182
  setSelectedAgentIds([])
43
183
  setChatMode('sequential')
44
184
  setAutoAddress(false)
185
+ setRoutingRules([])
45
186
  }
187
+ setAddingRule(false)
188
+ setEditingRuleId(null)
46
189
  }, [editing, open])
47
190
 
48
191
  const handleSave = async () => {
49
192
  if (!name.trim() || saving) return
50
193
  setSaving(true)
51
194
  try {
195
+ const payload = {
196
+ name,
197
+ description,
198
+ agentIds: selectedAgentIds,
199
+ chatMode,
200
+ autoAddress,
201
+ routingRules: routingRules.length > 0 ? routingRules : undefined,
202
+ }
52
203
  if (editing) {
53
- await updateChatroom(editing.id, { name, description, agentIds: selectedAgentIds, chatMode, autoAddress })
204
+ await updateChatroom(editing.id, payload)
54
205
  toast.success('Chatroom saved')
55
206
  } else {
56
- const chatroom = await createChatroom({ name, description, agentIds: selectedAgentIds, chatMode, autoAddress })
207
+ const chatroom = await createChatroom(payload)
57
208
  setCurrentChatroom(chatroom.id)
58
209
  toast.success('Chatroom created')
59
210
  }
@@ -81,10 +232,53 @@ export function ChatroomSheet() {
81
232
  )
82
233
  }
83
234
 
235
+ const handleAddRule = (form: RuleFormState) => {
236
+ const rule: ChatroomRoutingRule = {
237
+ id: genRuleId(),
238
+ type: form.type,
239
+ agentId: form.agentId,
240
+ priority: form.priority,
241
+ ...(form.pattern.trim() ? { pattern: form.pattern.trim() } : {}),
242
+ ...(form.type === 'keyword' && form.keywords.trim()
243
+ ? { keywords: form.keywords.split(',').map((k) => k.trim()).filter(Boolean) }
244
+ : {}),
245
+ }
246
+ setRoutingRules((prev) => [...prev, rule].sort((a, b) => a.priority - b.priority))
247
+ setAddingRule(false)
248
+ }
249
+
250
+ const handleEditRule = (form: RuleFormState) => {
251
+ setRoutingRules((prev) =>
252
+ prev.map((r) =>
253
+ r.id === editingRuleId
254
+ ? {
255
+ ...r,
256
+ type: form.type,
257
+ agentId: form.agentId,
258
+ priority: form.priority,
259
+ pattern: form.pattern.trim() || undefined,
260
+ keywords:
261
+ form.type === 'keyword' && form.keywords.trim()
262
+ ? form.keywords.split(',').map((k) => k.trim()).filter(Boolean)
263
+ : undefined,
264
+ }
265
+ : r,
266
+ ).sort((a, b) => a.priority - b.priority),
267
+ )
268
+ setEditingRuleId(null)
269
+ }
270
+
271
+ const removeRule = (ruleId: string) => {
272
+ setRoutingRules((prev) => prev.filter((r) => r.id !== ruleId))
273
+ }
274
+
84
275
  const agentList = Object.values(agents).filter(
85
276
  (a: Agent) => !a.trashedAt
86
277
  ) as Agent[]
87
278
 
279
+ const memberAgents = agentList.filter((a) => selectedAgentIds.includes(a.id))
280
+ const sortedRules = [...routingRules].sort((a, b) => a.priority - b.priority)
281
+
88
282
  return (
89
283
  <BottomSheet open={open} onClose={() => setChatroomSheetOpen(false)}>
90
284
  <div className="p-6 max-w-[560px] mx-auto">
@@ -178,7 +372,7 @@ export function ChatroomSheet() {
178
372
  selected ? 'bg-accent-soft/40' : 'hover:bg-white/[0.04]'
179
373
  }`}
180
374
  >
181
- <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />
375
+ <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={24} />
182
376
  <span className="text-[13px] text-text flex-1 truncate">{agent.name}</span>
183
377
  {selected && (
184
378
  <CheckIcon size={14} className="text-accent-bright shrink-0" />
@@ -189,6 +383,97 @@ export function ChatroomSheet() {
189
383
  )}
190
384
  </div>
191
385
  </div>
386
+
387
+ {/* Routing Rules */}
388
+ <div>
389
+ <label className="block text-[12px] font-600 text-text-2 mb-1.5">
390
+ Routing Rules ({sortedRules.length})
391
+ </label>
392
+ <p className="text-[11px] text-text-3 mb-2">
393
+ Route messages to specific agents based on keywords or capabilities. Evaluated before auto-address.
394
+ </p>
395
+
396
+ {sortedRules.length > 0 && (
397
+ <div className="space-y-2 mb-2">
398
+ {sortedRules.map((rule) => {
399
+ const agent = agents[rule.agentId]
400
+ if (editingRuleId === rule.id) {
401
+ return (
402
+ <RoutingRuleForm
403
+ key={rule.id}
404
+ rule={{
405
+ type: rule.type,
406
+ pattern: rule.pattern || '',
407
+ keywords: rule.keywords?.join(', ') || '',
408
+ agentId: rule.agentId,
409
+ priority: rule.priority,
410
+ }}
411
+ memberAgents={memberAgents}
412
+ onSave={handleEditRule}
413
+ onCancel={() => setEditingRuleId(null)}
414
+ />
415
+ )
416
+ }
417
+ return (
418
+ <div
419
+ key={rule.id}
420
+ className="flex items-center gap-2 px-3 py-2 rounded-[8px] bg-white/[0.04] border border-white/[0.08]"
421
+ >
422
+ <span className="text-[10px] font-700 text-text-3 bg-white/[0.06] px-1.5 py-0.5 rounded">
423
+ P{rule.priority}
424
+ </span>
425
+ <span className="text-[10px] font-600 text-accent-bright/70 uppercase">
426
+ {rule.type}
427
+ </span>
428
+ <span className="text-[12px] text-text-2 flex-1 truncate">
429
+ {rule.type === 'keyword'
430
+ ? (rule.keywords?.join(', ') || rule.pattern || '(no match)')
431
+ : (rule.pattern || '(no pattern)')}
432
+ </span>
433
+ <span className="text-[11px] text-text-3 truncate max-w-[100px]">
434
+ {agent?.name || 'Unknown'}
435
+ </span>
436
+ <button
437
+ type="button"
438
+ onClick={() => setEditingRuleId(rule.id)}
439
+ className="text-[11px] text-text-3 hover:text-text-2 cursor-pointer px-1"
440
+ >
441
+ Edit
442
+ </button>
443
+ <button
444
+ type="button"
445
+ onClick={() => removeRule(rule.id)}
446
+ className="text-[11px] text-red-400 hover:text-red-300 cursor-pointer px-1"
447
+ >
448
+ Remove
449
+ </button>
450
+ </div>
451
+ )
452
+ })}
453
+ </div>
454
+ )}
455
+
456
+ {addingRule ? (
457
+ <RoutingRuleForm
458
+ rule={emptyRuleForm}
459
+ memberAgents={memberAgents}
460
+ onSave={handleAddRule}
461
+ onCancel={() => setAddingRule(false)}
462
+ />
463
+ ) : (
464
+ <button
465
+ type="button"
466
+ onClick={() => setAddingRule(true)}
467
+ disabled={memberAgents.length === 0}
468
+ className="w-full py-2 rounded-[8px] border border-dashed border-white/[0.12] text-[12px] font-600 text-text-3 hover:text-text-2 hover:border-white/[0.2] cursor-pointer transition-all disabled:opacity-40 disabled:cursor-not-allowed"
469
+ >
470
+ + Add Rule
471
+ </button>
472
+ )}
473
+ {memberAgents.length === 0 && (
474
+ <p className="text-[11px] text-text-3 mt-1">Add members first to create routing rules.</p>
475
+ )}
476
+ </div>
192
477
  </div>
193
478
 
194
479
  <div className="flex items-center gap-3 mt-6">