@swarmclawai/swarmclaw 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +42 -7
  2. package/bin/swarmclaw.js +76 -16
  3. package/next.config.ts +11 -1
  4. package/package.json +4 -2
  5. package/public/screenshots/agents.png +0 -0
  6. package/public/screenshots/dashboard.png +0 -0
  7. package/public/screenshots/providers.png +0 -0
  8. package/public/screenshots/tasks.png +0 -0
  9. package/scripts/postinstall.mjs +18 -0
  10. package/src/app/api/chatrooms/[id]/chat/route.ts +410 -0
  11. package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
  12. package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
  13. package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
  14. package/src/app/api/chatrooms/[id]/route.ts +84 -0
  15. package/src/app/api/chatrooms/route.ts +50 -0
  16. package/src/app/api/credentials/route.ts +2 -3
  17. package/src/app/api/knowledge/[id]/route.ts +13 -2
  18. package/src/app/api/knowledge/route.ts +8 -1
  19. package/src/app/api/memory/route.ts +8 -0
  20. package/src/app/api/notifications/[id]/route.ts +27 -0
  21. package/src/app/api/notifications/route.ts +68 -0
  22. package/src/app/api/orchestrator/run/route.ts +1 -1
  23. package/src/app/api/plugins/install/route.ts +2 -2
  24. package/src/app/api/search/route.ts +155 -0
  25. package/src/app/api/sessions/[id]/chat/route.ts +2 -0
  26. package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
  27. package/src/app/api/sessions/[id]/fork/route.ts +1 -1
  28. package/src/app/api/sessions/route.ts +3 -3
  29. package/src/app/api/settings/route.ts +9 -0
  30. package/src/app/api/setup/check-provider/route.ts +3 -16
  31. package/src/app/api/skills/[id]/route.ts +6 -0
  32. package/src/app/api/skills/route.ts +6 -0
  33. package/src/app/api/tasks/[id]/route.ts +20 -0
  34. package/src/app/api/tasks/bulk/route.ts +100 -0
  35. package/src/app/api/tasks/route.ts +1 -0
  36. package/src/app/api/usage/route.ts +45 -0
  37. package/src/app/api/webhooks/[id]/route.ts +15 -1
  38. package/src/app/globals.css +58 -15
  39. package/src/app/page.tsx +142 -13
  40. package/src/cli/index.js +42 -0
  41. package/src/cli/index.test.js +30 -0
  42. package/src/cli/spec.js +32 -0
  43. package/src/components/agents/agent-avatar.tsx +57 -10
  44. package/src/components/agents/agent-card.tsx +48 -15
  45. package/src/components/agents/agent-chat-list.tsx +123 -10
  46. package/src/components/agents/agent-list.tsx +50 -19
  47. package/src/components/agents/agent-sheet.tsx +56 -63
  48. package/src/components/auth/access-key-gate.tsx +10 -3
  49. package/src/components/auth/setup-wizard.tsx +2 -2
  50. package/src/components/auth/user-picker.tsx +31 -3
  51. package/src/components/chat/activity-moment.tsx +169 -0
  52. package/src/components/chat/chat-header.tsx +2 -0
  53. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  54. package/src/components/chat/file-path-chip.tsx +125 -0
  55. package/src/components/chat/markdown-utils.ts +9 -0
  56. package/src/components/chat/message-bubble.tsx +46 -295
  57. package/src/components/chat/message-list.tsx +50 -1
  58. package/src/components/chat/streaming-bubble.tsx +36 -46
  59. package/src/components/chat/suggestions-bar.tsx +1 -1
  60. package/src/components/chat/thinking-indicator.tsx +72 -10
  61. package/src/components/chat/tool-call-bubble.tsx +66 -70
  62. package/src/components/chat/tool-request-banner.tsx +31 -7
  63. package/src/components/chat/transfer-agent-picker.tsx +63 -0
  64. package/src/components/chatrooms/agent-hover-card.tsx +124 -0
  65. package/src/components/chatrooms/chatroom-input.tsx +320 -0
  66. package/src/components/chatrooms/chatroom-list.tsx +123 -0
  67. package/src/components/chatrooms/chatroom-message.tsx +427 -0
  68. package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
  69. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
  70. package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
  71. package/src/components/chatrooms/chatroom-view.tsx +344 -0
  72. package/src/components/chatrooms/reaction-picker.tsx +273 -0
  73. package/src/components/connectors/connector-sheet.tsx +34 -47
  74. package/src/components/home/home-view.tsx +501 -0
  75. package/src/components/input/chat-input.tsx +79 -41
  76. package/src/components/knowledge/knowledge-list.tsx +31 -1
  77. package/src/components/knowledge/knowledge-sheet.tsx +83 -2
  78. package/src/components/layout/app-layout.tsx +209 -83
  79. package/src/components/layout/mobile-header.tsx +2 -0
  80. package/src/components/layout/update-banner.tsx +2 -2
  81. package/src/components/logs/log-list.tsx +2 -2
  82. package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
  83. package/src/components/memory/memory-agent-list.tsx +143 -0
  84. package/src/components/memory/memory-browser.tsx +205 -0
  85. package/src/components/memory/memory-card.tsx +34 -7
  86. package/src/components/memory/memory-detail.tsx +359 -120
  87. package/src/components/memory/memory-sheet.tsx +157 -23
  88. package/src/components/plugins/plugin-list.tsx +1 -1
  89. package/src/components/plugins/plugin-sheet.tsx +1 -1
  90. package/src/components/projects/project-detail.tsx +509 -0
  91. package/src/components/projects/project-list.tsx +195 -59
  92. package/src/components/providers/provider-list.tsx +2 -2
  93. package/src/components/providers/provider-sheet.tsx +3 -3
  94. package/src/components/schedules/schedule-card.tsx +3 -2
  95. package/src/components/schedules/schedule-list.tsx +1 -1
  96. package/src/components/schedules/schedule-sheet.tsx +25 -25
  97. package/src/components/secrets/secret-sheet.tsx +47 -24
  98. package/src/components/secrets/secrets-list.tsx +18 -8
  99. package/src/components/sessions/new-session-sheet.tsx +33 -65
  100. package/src/components/sessions/session-card.tsx +45 -14
  101. package/src/components/sessions/session-list.tsx +35 -18
  102. package/src/components/shared/agent-picker-list.tsx +90 -0
  103. package/src/components/shared/agent-switch-dialog.tsx +156 -0
  104. package/src/components/shared/attachment-chip.tsx +165 -0
  105. package/src/components/shared/avatar.tsx +10 -1
  106. package/src/components/shared/check-icon.tsx +12 -0
  107. package/src/components/shared/confirm-dialog.tsx +1 -1
  108. package/src/components/shared/empty-state.tsx +32 -0
  109. package/src/components/shared/file-preview.tsx +34 -0
  110. package/src/components/shared/form-styles.ts +2 -0
  111. package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
  112. package/src/components/shared/notification-center.tsx +223 -0
  113. package/src/components/shared/profile-sheet.tsx +115 -0
  114. package/src/components/shared/reply-quote.tsx +26 -0
  115. package/src/components/shared/search-dialog.tsx +296 -0
  116. package/src/components/shared/section-label.tsx +12 -0
  117. package/src/components/shared/settings/plugin-manager.tsx +1 -1
  118. package/src/components/shared/settings/section-providers.tsx +1 -1
  119. package/src/components/shared/settings/section-secrets.tsx +1 -1
  120. package/src/components/shared/settings/section-theme.tsx +95 -0
  121. package/src/components/shared/settings/section-user-preferences.tsx +39 -0
  122. package/src/components/shared/settings/settings-page.tsx +180 -27
  123. package/src/components/shared/settings/settings-sheet.tsx +9 -73
  124. package/src/components/shared/sheet-footer.tsx +33 -0
  125. package/src/components/skills/skill-list.tsx +61 -30
  126. package/src/components/skills/skill-sheet.tsx +81 -2
  127. package/src/components/tasks/task-board.tsx +448 -26
  128. package/src/components/tasks/task-card.tsx +46 -9
  129. package/src/components/tasks/task-column.tsx +62 -3
  130. package/src/components/tasks/task-list.tsx +12 -4
  131. package/src/components/tasks/task-sheet.tsx +89 -72
  132. package/src/components/ui/hover-card.tsx +52 -0
  133. package/src/components/usage/metrics-dashboard.tsx +78 -0
  134. package/src/components/usage/usage-list.tsx +1 -1
  135. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  136. package/src/hooks/use-view-router.ts +69 -19
  137. package/src/instrumentation.ts +15 -1
  138. package/src/lib/chat.ts +2 -0
  139. package/src/lib/cron-human.ts +114 -0
  140. package/src/lib/memory.ts +3 -0
  141. package/src/lib/server/chat-execution.ts +24 -4
  142. package/src/lib/server/connectors/manager.ts +11 -0
  143. package/src/lib/server/context-manager.ts +225 -13
  144. package/src/lib/server/create-notification.ts +42 -0
  145. package/src/lib/server/daemon-state.ts +165 -10
  146. package/src/lib/server/execution-log.ts +1 -0
  147. package/src/lib/server/heartbeat-service.ts +40 -5
  148. package/src/lib/server/heartbeat-wake.ts +110 -0
  149. package/src/lib/server/langgraph-checkpoint.ts +1 -0
  150. package/src/lib/server/memory-consolidation.ts +92 -0
  151. package/src/lib/server/memory-db.ts +51 -6
  152. package/src/lib/server/openclaw-gateway.ts +9 -1
  153. package/src/lib/server/provider-health.ts +125 -0
  154. package/src/lib/server/queue.ts +5 -4
  155. package/src/lib/server/scheduler.ts +8 -0
  156. package/src/lib/server/session-run-manager.ts +4 -0
  157. package/src/lib/server/session-tools/chatroom.ts +136 -0
  158. package/src/lib/server/session-tools/context-mgmt.ts +36 -18
  159. package/src/lib/server/session-tools/index.ts +2 -0
  160. package/src/lib/server/session-tools/memory.ts +6 -1
  161. package/src/lib/server/storage.ts +80 -29
  162. package/src/lib/server/stream-agent-chat.ts +153 -47
  163. package/src/lib/server/system-events.ts +49 -0
  164. package/src/lib/server/ws-hub.ts +11 -0
  165. package/src/lib/soul-suggestions.ts +109 -0
  166. package/src/lib/tasks.ts +4 -1
  167. package/src/lib/view-routes.ts +36 -1
  168. package/src/lib/ws-client.ts +14 -4
  169. package/src/proxy.ts +79 -2
  170. package/src/stores/use-app-store.ts +94 -3
  171. package/src/stores/use-chat-store.ts +48 -3
  172. package/src/stores/use-chatroom-store.ts +276 -0
  173. package/src/types/index.ts +69 -2
@@ -0,0 +1,427 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import ReactMarkdown from 'react-markdown'
5
+ import remarkGfm from 'remark-gfm'
6
+ import rehypeHighlight from 'rehype-highlight'
7
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
8
+ import { CodeBlock } from '@/components/chat/code-block'
9
+ import { ReactionPicker } from './reaction-picker'
10
+ import { ReplyQuote } from '@/components/shared/reply-quote'
11
+ import { AttachmentChip, parseAttachmentUrl } from '@/components/shared/attachment-chip'
12
+ import { useAppStore } from '@/stores/use-app-store'
13
+ import { AgentHoverCard } from './agent-hover-card'
14
+ import { ChatroomToolRequestBanner } from './chatroom-tool-request-banner'
15
+ import { isStructuredMarkdown } from '@/components/chat/markdown-utils'
16
+ import { TransferAgentPicker } from '@/components/chat/transfer-agent-picker'
17
+ import type { ChatroomMessage, Agent } from '@/types'
18
+
19
+ interface Props {
20
+ message: ChatroomMessage
21
+ agents: Record<string, Agent>
22
+ onToggleReaction: (messageId: string, emoji: string) => void
23
+ onReply?: (message: ChatroomMessage) => void
24
+ onTogglePin?: (messageId: string) => void
25
+ onTransfer?: (messageId: string, targetAgentId: string) => void
26
+ pinnedMessageIds?: string[]
27
+ /** Set of agentIds currently streaming */
28
+ streamingAgentIds?: Set<string>
29
+ /** All messages in the chatroom, for resolving replyToId */
30
+ messages?: ChatroomMessage[]
31
+ /** Whether this message is grouped with the previous (same sender within 2min) */
32
+ grouped?: boolean
33
+ /** Moment overlay to display above the avatar (heartbeat/tool activity) */
34
+ momentOverlay?: React.ReactNode
35
+ }
36
+
37
+ function formatRelativeTime(ts: number): string {
38
+ const now = Date.now()
39
+ const diffSec = Math.floor((now - ts) / 1000)
40
+ if (diffSec < 60) return 'just now'
41
+ const diffMin = Math.floor(diffSec / 60)
42
+ if (diffMin < 60) return `${diffMin}m ago`
43
+ const diffHr = Math.floor(diffMin / 60)
44
+ if (diffHr < 24) return `${diffHr}h ago`
45
+ return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
46
+ }
47
+
48
+ function navigateToAgent(agentId: string) {
49
+ useAppStore.getState().setActiveView('agents')
50
+ useAppStore.getState().setCurrentAgent(agentId)
51
+ }
52
+
53
+ /** Pre-process @mentions into markdown-friendly format for ReactMarkdown */
54
+ function preprocessMentions(text: string, agents: Record<string, Agent>): string {
55
+ const nameToId = new Map<string, string>()
56
+ for (const [id, agent] of Object.entries(agents)) {
57
+ nameToId.set(agent.name.toLowerCase().replace(/\s+/g, ''), id)
58
+ }
59
+ return text.replace(/@(\S+)/g, (match, name) => {
60
+ const agentId = nameToId.get(name.toLowerCase())
61
+ if (agentId) {
62
+ return `[@${name}](#agent:${agentId})`
63
+ }
64
+ // Unrecognized mentions still get styled as mention links
65
+ return `[@${name}](#mention:${name})`
66
+ })
67
+ }
68
+
69
+ /** Group reactions by emoji */
70
+ function groupReactions(reactions: Array<{ emoji: string; reactorId: string }>): Array<{ emoji: string; count: number; hasUser: boolean }> {
71
+ const map = new Map<string, { count: number; hasUser: boolean }>()
72
+ for (const r of reactions) {
73
+ const existing = map.get(r.emoji) || { count: 0, hasUser: false }
74
+ existing.count++
75
+ if (r.reactorId === 'user') existing.hasUser = true
76
+ map.set(r.emoji, existing)
77
+ }
78
+ return Array.from(map.entries()).map(([emoji, data]) => ({ emoji, ...data }))
79
+ }
80
+
81
+ // TransferAgentPicker imported from @/components/chat/transfer-agent-picker
82
+
83
+ /** Render chatroom message attachments */
84
+ function renderChatroomAttachments(message: ChatroomMessage) {
85
+ const isUser = message.senderId === 'user'
86
+ const seen = new Set<string>()
87
+ const chips: { url: string; filename: string }[] = []
88
+
89
+ if (message.imagePath) {
90
+ const primary = parseAttachmentUrl(message.imagePath)
91
+ if (primary.url) {
92
+ seen.add(primary.url)
93
+ chips.push(primary)
94
+ }
95
+ }
96
+ if (message.attachedFiles?.length) {
97
+ for (const fp of message.attachedFiles) {
98
+ const att = parseAttachmentUrl(fp)
99
+ if (att.url && !seen.has(att.url)) {
100
+ seen.add(att.url)
101
+ chips.push(att)
102
+ }
103
+ }
104
+ }
105
+ if (!chips.length) return null
106
+ return (
107
+ <div className="flex flex-col">
108
+ {chips.map((c) => <AttachmentChip key={c.url} url={c.url} filename={c.filename} isUserMsg={isUser} />)}
109
+ </div>
110
+ )
111
+ }
112
+
113
+ export function ChatroomMessageBubble({ message, agents, onToggleReaction, onReply, onTogglePin, onTransfer, pinnedMessageIds, streamingAgentIds, messages, grouped: isGrouped, momentOverlay }: Props) {
114
+ const [showPicker, setShowPicker] = useState(false)
115
+ const [showTransferPicker, setShowTransferPicker] = useState(false)
116
+ const userAvatarSeed = useAppStore((s) => s.appSettings.userAvatarSeed)
117
+ const wide = isStructuredMarkdown(message.text)
118
+
119
+ // System event messages (join/leave)
120
+ if (message.senderId === 'system') {
121
+ return (
122
+ <div className="flex justify-center py-1.5 px-4">
123
+ <span className="text-[11px] text-text-3/50 font-500 flex items-center gap-1.5">
124
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/40">
125
+ {message.text.includes('left') ? (
126
+ <><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></>
127
+ ) : (
128
+ <><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" /></>
129
+ )}
130
+ </svg>
131
+ {message.text}
132
+ </span>
133
+ </div>
134
+ )
135
+ }
136
+
137
+ const isUser = message.senderId === 'user'
138
+ const agent = !isUser ? agents[message.senderId] : null
139
+ const groupedReactions = groupReactions(message.reactions)
140
+
141
+ // Resolve reply-to message
142
+ const replyToMessage = message.replyToId && messages
143
+ ? messages.find((m) => m.id === message.replyToId)
144
+ : null
145
+
146
+ // Pre-process text for markdown rendering
147
+ const processedText = preprocessMentions(message.text, agents)
148
+
149
+ return (
150
+ <div
151
+ id={`chatroom-msg-${message.id}`}
152
+ className={`group flex gap-2.5 px-4 hover:bg-white/[0.02] ${isGrouped ? 'py-0.5' : 'py-1.5'}`}
153
+ style={{ animation: 'msg-in 0.25s ease-out both' }}
154
+ >
155
+ {/* Avatar or spacer */}
156
+ <div className="shrink-0 mt-0.5 w-7 relative">
157
+ {!isGrouped && (
158
+ isUser ? (
159
+ userAvatarSeed ? (
160
+ <div style={momentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}>
161
+ <AgentAvatar seed={userAvatarSeed} name={message.senderName} size={28} />
162
+ </div>
163
+ ) : (
164
+ <div className="w-7 h-7 rounded-full bg-white/[0.08] flex items-center justify-center text-[11px] font-600 text-text-2">
165
+ You
166
+ </div>
167
+ )
168
+ ) : agent ? (
169
+ <button
170
+ onClick={() => navigateToAgent(message.senderId)}
171
+ className="bg-transparent border-none p-0 cursor-pointer transition-all duration-150 hover:scale-110 hover:-translate-y-0.5"
172
+ style={momentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}
173
+ >
174
+ <AgentAvatar seed={agent.avatarSeed || null} name={message.senderName} size={28} status={streamingAgentIds?.has(message.senderId) ? 'busy' : 'online'} />
175
+ </button>
176
+ ) : (
177
+ <div style={momentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}>
178
+ <AgentAvatar seed={null} name={message.senderName} size={28} />
179
+ </div>
180
+ )
181
+ )}
182
+ {momentOverlay}
183
+ </div>
184
+
185
+ {/* Content */}
186
+ <div className="flex-1 min-w-0">
187
+ {!isGrouped && (
188
+ <div className="flex items-baseline gap-2 mb-0.5">
189
+ {!isUser && agent ? (
190
+ <AgentHoverCard agent={agent}>
191
+ <span className="text-[13px] font-600 text-accent-bright hover:underline cursor-pointer">
192
+ {message.senderName}
193
+ </span>
194
+ </AgentHoverCard>
195
+ ) : (
196
+ <span className="text-[13px] font-600 text-text">
197
+ {message.senderName}
198
+ </span>
199
+ )}
200
+ <span className="label-mono" title={new Date(message.time).toLocaleString()}>{formatRelativeTime(message.time)}</span>
201
+ </div>
202
+ )}
203
+
204
+ {/* Reply quote */}
205
+ {replyToMessage && (
206
+ <ReplyQuote
207
+ senderName={replyToMessage.senderName}
208
+ text={replyToMessage.text}
209
+ onClick={() => {
210
+ const el = document.getElementById(`chatroom-msg-${replyToMessage.id}`)
211
+ if (el) {
212
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
213
+ el.classList.add('bg-accent-soft/20')
214
+ setTimeout(() => el.classList.remove('bg-accent-soft/20'), 2000)
215
+ }
216
+ }}
217
+ />
218
+ )}
219
+
220
+ {/* Attachments */}
221
+ {renderChatroomAttachments(message)}
222
+
223
+ {/* Message text with markdown */}
224
+ <div className={`text-[13px] text-text leading-[1.5] break-words chatroom-prose ${wide ? 'max-w-[92%]' : 'max-w-[85%]'}`}>
225
+ <ReactMarkdown
226
+ remarkPlugins={[remarkGfm]}
227
+ rehypePlugins={[rehypeHighlight]}
228
+ components={{
229
+ pre({ children }) {
230
+ return <pre>{children}</pre>
231
+ },
232
+ code({ className, children }) {
233
+ const isBlock = className?.startsWith('language-') || className?.startsWith('hljs')
234
+ if (isBlock) {
235
+ return <CodeBlock className={className}>{children}</CodeBlock>
236
+ }
237
+ return (
238
+ <code className="px-1 py-0.5 rounded bg-white/[0.08] text-[12px] font-mono text-accent-bright/90">
239
+ {children}
240
+ </code>
241
+ )
242
+ },
243
+ a({ href, children }) {
244
+ if (!href) return <>{children}</>
245
+ // Agent mention links (recognized agents — hover card)
246
+ if (href.startsWith('#agent:')) {
247
+ const agentId = href.replace('#agent:', '')
248
+ const mentionAgent = agents[agentId]
249
+ if (mentionAgent) {
250
+ return (
251
+ <AgentHoverCard agent={mentionAgent}>
252
+ <span className="text-accent-bright font-600 bg-accent-soft/40 px-0.5 rounded hover:underline cursor-pointer">
253
+ {children}
254
+ </span>
255
+ </AgentHoverCard>
256
+ )
257
+ }
258
+ return (
259
+ <span className="text-accent-bright font-600 bg-accent-soft/40 px-0.5 rounded">
260
+ {children}
261
+ </span>
262
+ )
263
+ }
264
+ // Unrecognized @mention — styled but not clickable
265
+ if (href.startsWith('#mention:')) {
266
+ return (
267
+ <span className="text-accent-bright font-600 bg-accent-soft/40 px-0.5 rounded">
268
+ {children}
269
+ </span>
270
+ )
271
+ }
272
+ // YouTube embeds
273
+ const ytMatch = href.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
274
+ if (ytMatch) {
275
+ return (
276
+ <div className="my-2">
277
+ <iframe
278
+ src={`https://www.youtube.com/embed/${ytMatch[1]}`}
279
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
280
+ allowFullScreen
281
+ className="w-full max-w-[480px] aspect-video rounded-[8px] border border-white/[0.06]"
282
+ />
283
+ </div>
284
+ )
285
+ }
286
+ // Upload links
287
+ if (typeof href === 'string' && href.includes('/api/uploads/')) {
288
+ const filename = href.split('/').pop() || 'file'
289
+ return (
290
+ <a href={href} download={filename} className="text-accent-bright hover:underline">
291
+ {children}
292
+ </a>
293
+ )
294
+ }
295
+ // Default external link
296
+ return (
297
+ <a href={href} target="_blank" rel="noopener noreferrer" className="text-accent-bright hover:underline">
298
+ {children}
299
+ </a>
300
+ )
301
+ },
302
+ img({ src, alt }) {
303
+ if (!src || typeof src !== 'string') return null
304
+ const isVideo = /\.(mp4|webm|mov|avi)$/i.test(src)
305
+ if (isVideo) {
306
+ return <video src={src} controls preload="none" className="max-w-full rounded-[8px] my-2" />
307
+ }
308
+ return (
309
+ <a href={src} download target="_blank" rel="noopener noreferrer" className="block my-2">
310
+ <img src={src} alt={alt || 'Image'} loading="lazy" className="max-w-full max-h-[400px] rounded-[8px] border border-white/[0.06]" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
311
+ </a>
312
+ )
313
+ },
314
+ }}
315
+ >
316
+ {processedText}
317
+ </ReactMarkdown>
318
+ </div>
319
+
320
+ {/* Tool request banner for agent messages */}
321
+ {!isUser && agent && (
322
+ <ChatroomToolRequestBanner
323
+ agentId={message.senderId}
324
+ agentName={message.senderName}
325
+ text={message.text}
326
+ />
327
+ )}
328
+
329
+ {/* Reactions */}
330
+ {groupedReactions.length > 0 && (
331
+ <div className="flex flex-wrap gap-1 mt-1.5">
332
+ {groupedReactions.map(({ emoji, count, hasUser }) => (
333
+ <button
334
+ key={emoji}
335
+ onClick={() => onToggleReaction(message.id, emoji)}
336
+ className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[11px] transition-all cursor-pointer ${
337
+ hasUser
338
+ ? 'bg-[#1a1a3a] border border-accent-bright/30'
339
+ : 'bg-[#16162a] border border-white/[0.1] hover:bg-[#1e1e38]'
340
+ }`}
341
+ >
342
+ <span>{emoji}</span>
343
+ {count > 1 && <span className="text-text-3">{count}</span>}
344
+ </button>
345
+ ))}
346
+ </div>
347
+ )}
348
+ </div>
349
+
350
+ {/* Action buttons (reply + pin + transfer + reaction) */}
351
+ <div className="relative shrink-0 mt-0.5 flex items-start gap-0.5" style={{ zIndex: showPicker || showTransferPicker ? 50 : undefined }}>
352
+ {/* Reply button */}
353
+ {onReply && (
354
+ <button
355
+ onClick={() => onReply(message)}
356
+ 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"
357
+ title="Reply"
358
+ >
359
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-3">
360
+ <polyline points="9 17 4 12 9 7" />
361
+ <path d="M20 18v-2a4 4 0 0 0-4-4H4" />
362
+ </svg>
363
+ </button>
364
+ )}
365
+ {/* Pin button */}
366
+ {onTogglePin && (
367
+ <button
368
+ onClick={() => onTogglePin(message.id)}
369
+ 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"
370
+ title={pinnedMessageIds?.includes(message.id) ? 'Unpin message' : 'Pin message'}
371
+ >
372
+ <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'}>
373
+ <path d="M12 17v5" />
374
+ <path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16h14v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 2-2H6a2 2 0 0 0 2 2 1 1 0 0 1 1 1z" />
375
+ </svg>
376
+ </button>
377
+ )}
378
+ {/* Transfer button */}
379
+ {onTransfer && !isUser && (
380
+ <button
381
+ onClick={() => setShowTransferPicker(!showTransferPicker)}
382
+ 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"
383
+ title="Transfer to agent"
384
+ >
385
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-3">
386
+ <path d="M8 3L4 7l4 4" />
387
+ <path d="M4 7h16" />
388
+ <path d="M16 21l4-4-4-4" />
389
+ <path d="M20 17H4" />
390
+ </svg>
391
+ </button>
392
+ )}
393
+ {showTransferPicker && onTransfer && (
394
+ <TransferAgentPicker
395
+ excludeIds={[message.senderId]}
396
+ onSelect={(targetId) => {
397
+ onTransfer(message.id, targetId)
398
+ setShowTransferPicker(false)
399
+ }}
400
+ onClose={() => setShowTransferPicker(false)}
401
+ />
402
+ )}
403
+ {/* Reaction button */}
404
+ <button
405
+ onClick={() => setShowPicker(!showPicker)}
406
+ 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"
407
+ >
408
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-3">
409
+ <circle cx="12" cy="12" r="10" />
410
+ <path d="M8 14s1.5 2 4 2 4-2 4-2" />
411
+ <line x1="9" y1="9" x2="9.01" y2="9" />
412
+ <line x1="15" y1="9" x2="15.01" y2="9" />
413
+ </svg>
414
+ </button>
415
+ {showPicker && (
416
+ <ReactionPicker
417
+ onSelect={(emoji) => {
418
+ onToggleReaction(message.id, emoji)
419
+ setShowPicker(false)
420
+ }}
421
+ onClose={() => setShowPicker(false)}
422
+ />
423
+ )}
424
+ </div>
425
+ </div>
426
+ )
427
+ }
@@ -0,0 +1,215 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { useChatroomStore } from '@/stores/use-chatroom-store'
5
+ import { useAppStore } from '@/stores/use-app-store'
6
+ import { BottomSheet } from '@/components/shared/bottom-sheet'
7
+ import { toast } from 'sonner'
8
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
9
+ import type { Agent } from '@/types'
10
+ import { CheckIcon } from '@/components/shared/check-icon'
11
+
12
+ export function ChatroomSheet() {
13
+ const open = useChatroomStore((s) => s.chatroomSheetOpen)
14
+ const editingId = useChatroomStore((s) => s.editingChatroomId)
15
+ const chatrooms = useChatroomStore((s) => s.chatrooms)
16
+ const setChatroomSheetOpen = useChatroomStore((s) => s.setChatroomSheetOpen)
17
+ const createChatroom = useChatroomStore((s) => s.createChatroom)
18
+ const updateChatroom = useChatroomStore((s) => s.updateChatroom)
19
+ const deleteChatroom = useChatroomStore((s) => s.deleteChatroom)
20
+ const setCurrentChatroom = useChatroomStore((s) => s.setCurrentChatroom)
21
+ const agents = useAppStore((s) => s.agents)
22
+
23
+ const [name, setName] = useState('')
24
+ const [description, setDescription] = useState('')
25
+ const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([])
26
+ const [chatMode, setChatMode] = useState<'sequential' | 'parallel'>('sequential')
27
+ const [autoAddress, setAutoAddress] = useState(false)
28
+ const [saving, setSaving] = useState(false)
29
+
30
+ const editing = editingId ? chatrooms[editingId] : null
31
+
32
+ useEffect(() => {
33
+ if (editing) {
34
+ setName(editing.name)
35
+ setDescription(editing.description || '')
36
+ setSelectedAgentIds([...editing.agentIds])
37
+ setChatMode(editing.chatMode || 'sequential')
38
+ setAutoAddress(editing.autoAddress || false)
39
+ } else {
40
+ setName('')
41
+ setDescription('')
42
+ setSelectedAgentIds([])
43
+ setChatMode('sequential')
44
+ setAutoAddress(false)
45
+ }
46
+ }, [editing, open])
47
+
48
+ const handleSave = async () => {
49
+ if (!name.trim() || saving) return
50
+ setSaving(true)
51
+ try {
52
+ if (editing) {
53
+ await updateChatroom(editing.id, { name, description, agentIds: selectedAgentIds, chatMode, autoAddress })
54
+ toast.success('Chatroom saved')
55
+ } else {
56
+ const chatroom = await createChatroom({ name, description, agentIds: selectedAgentIds, chatMode, autoAddress })
57
+ setCurrentChatroom(chatroom.id)
58
+ toast.success('Chatroom created')
59
+ }
60
+ setChatroomSheetOpen(false)
61
+ } finally {
62
+ setSaving(false)
63
+ }
64
+ }
65
+
66
+ const handleDelete = async () => {
67
+ if (!editing || saving) return
68
+ setSaving(true)
69
+ try {
70
+ await deleteChatroom(editing.id)
71
+ toast.success('Chatroom deleted')
72
+ setChatroomSheetOpen(false)
73
+ } finally {
74
+ setSaving(false)
75
+ }
76
+ }
77
+
78
+ const toggleAgent = (agentId: string) => {
79
+ setSelectedAgentIds((prev) =>
80
+ prev.includes(agentId) ? prev.filter((id) => id !== agentId) : [...prev, agentId]
81
+ )
82
+ }
83
+
84
+ const agentList = Object.values(agents).filter(
85
+ (a: Agent) => !a.trashedAt
86
+ ) as Agent[]
87
+
88
+ return (
89
+ <BottomSheet open={open} onClose={() => setChatroomSheetOpen(false)}>
90
+ <div className="p-6 max-w-[560px] mx-auto">
91
+ <h2 className="font-display text-[18px] font-700 text-text mb-4">
92
+ {editing ? 'Edit Chatroom' : 'Create Chatroom'}
93
+ </h2>
94
+
95
+ <div className="space-y-4">
96
+ <div>
97
+ <label className="block text-[12px] font-600 text-text-2 mb-1.5">Name</label>
98
+ <input
99
+ type="text"
100
+ value={name}
101
+ onChange={(e) => setName(e.target.value)}
102
+ placeholder="e.g. Research Team"
103
+ className="w-full px-3 py-2 rounded-[8px] bg-white/[0.06] border border-white/[0.08] text-[13px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40"
104
+ />
105
+ </div>
106
+
107
+ <div>
108
+ <label className="block text-[12px] font-600 text-text-2 mb-1.5">Description</label>
109
+ <input
110
+ type="text"
111
+ value={description}
112
+ onChange={(e) => setDescription(e.target.value)}
113
+ placeholder="Optional description"
114
+ className="w-full px-3 py-2 rounded-[8px] bg-white/[0.06] border border-white/[0.08] text-[13px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40"
115
+ />
116
+ </div>
117
+
118
+ <div>
119
+ <label className="block text-[12px] font-600 text-text-2 mb-1.5">Response Mode</label>
120
+ <div className="flex rounded-[8px] border border-white/[0.08] overflow-hidden">
121
+ {(['sequential', 'parallel'] as const).map((mode) => (
122
+ <button
123
+ key={mode}
124
+ type="button"
125
+ onClick={() => setChatMode(mode)}
126
+ className={`flex-1 py-2 text-[12px] font-600 capitalize cursor-pointer transition-all ${
127
+ chatMode === mode
128
+ ? 'bg-accent-soft text-accent-bright'
129
+ : 'bg-transparent text-text-3 hover:text-text-2'
130
+ }`}
131
+ >
132
+ {mode}
133
+ </button>
134
+ ))}
135
+ </div>
136
+ <p className="text-[11px] text-text-3 mt-1">
137
+ {chatMode === 'parallel'
138
+ ? 'All mentioned agents respond simultaneously'
139
+ : 'Agents respond one at a time in order'}
140
+ </p>
141
+ </div>
142
+
143
+ <div>
144
+ <button
145
+ type="button"
146
+ onClick={() => setAutoAddress((v) => !v)}
147
+ className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-[8px] border border-white/[0.08] bg-white/[0.03] cursor-pointer transition-all hover:bg-white/[0.05]"
148
+ >
149
+ <div className={`w-8 h-[18px] rounded-full transition-all relative ${autoAddress ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}>
150
+ <div className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white transition-all ${autoAddress ? 'left-[16px]' : 'left-[2px]'}`} />
151
+ </div>
152
+ <div className="flex-1 text-left">
153
+ <span className="text-[12px] font-600 text-text-2">Auto-address all agents</span>
154
+ <p className="text-[11px] text-text-3 mt-0.5">
155
+ {autoAddress
156
+ ? 'Every message is sent to all agents, no @mention needed'
157
+ : 'Only agents you @mention will respond'}
158
+ </p>
159
+ </div>
160
+ </button>
161
+ </div>
162
+
163
+ <div>
164
+ <label className="block text-[12px] font-600 text-text-2 mb-1.5">
165
+ Members ({selectedAgentIds.length} selected)
166
+ </label>
167
+ <div className="max-h-[240px] overflow-y-auto rounded-[8px] border border-white/[0.08] bg-white/[0.03]">
168
+ {agentList.length === 0 ? (
169
+ <p className="p-3 text-[12px] text-text-3">No agents available</p>
170
+ ) : (
171
+ agentList.map((agent) => {
172
+ const selected = selectedAgentIds.includes(agent.id)
173
+ return (
174
+ <button
175
+ key={agent.id}
176
+ onClick={() => toggleAgent(agent.id)}
177
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-left transition-all cursor-pointer ${
178
+ selected ? 'bg-accent-soft/40' : 'hover:bg-white/[0.04]'
179
+ }`}
180
+ >
181
+ <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />
182
+ <span className="text-[13px] text-text flex-1 truncate">{agent.name}</span>
183
+ {selected && (
184
+ <CheckIcon size={14} className="text-accent-bright shrink-0" />
185
+ )}
186
+ </button>
187
+ )
188
+ })
189
+ )}
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ <div className="flex items-center gap-3 mt-6">
195
+ <button
196
+ onClick={handleSave}
197
+ disabled={!name.trim() || saving}
198
+ 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"
199
+ >
200
+ {saving ? 'Saving...' : editing ? 'Save Changes' : 'Create Chatroom'}
201
+ </button>
202
+ {editing && (
203
+ <button
204
+ onClick={handleDelete}
205
+ disabled={saving}
206
+ className="py-2.5 px-4 rounded-[8px] text-[13px] font-600 text-red-400 hover:bg-red-500/10 transition-all cursor-pointer"
207
+ >
208
+ Delete
209
+ </button>
210
+ )}
211
+ </div>
212
+ </div>
213
+ </BottomSheet>
214
+ )
215
+ }