@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,134 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef } from 'react'
4
+ import { useAppStore } from '@/stores/use-app-store'
5
+ import { useChatroomStore } from '@/stores/use-chatroom-store'
6
+ import { api } from '@/lib/api-client'
7
+ import { TOOL_LABELS } from '@/lib/tool-definitions'
8
+
9
+ interface Props {
10
+ agentId: string
11
+ agentName: string
12
+ text: string
13
+ toolOutputs?: string[]
14
+ }
15
+
16
+ export function ChatroomToolRequestBanner({ agentId, agentName, text, toolOutputs = [] }: Props) {
17
+ const loadAgents = useAppStore((s) => s.loadAgents)
18
+ const agents = useAppStore((s) => s.agents)
19
+ const [granted, setGranted] = useState<Set<string>>(new Set())
20
+ const [denied, setDenied] = useState<Set<string>>(new Set())
21
+ const continueSentRef = useRef(false)
22
+
23
+ const toolRequests: { toolId: string; reason: string }[] = []
24
+ const seen = new Set<string>()
25
+
26
+ function extractFromText(t: string) {
27
+ try {
28
+ const jsonMatches = t.match(/\{"type"\s*:\s*"tool_request"[^}]*\}/g)
29
+ if (jsonMatches) {
30
+ for (const jm of jsonMatches) {
31
+ const parsed = JSON.parse(jm)
32
+ if (parsed.type === 'tool_request' && parsed.toolId && !seen.has(parsed.toolId)) {
33
+ seen.add(parsed.toolId)
34
+ toolRequests.push({ toolId: parsed.toolId, reason: parsed.reason || '' })
35
+ }
36
+ }
37
+ }
38
+ } catch { /* ignore */ }
39
+ }
40
+
41
+ extractFromText(text)
42
+ for (const output of toolOutputs) extractFromText(output)
43
+
44
+ if (toolRequests.length === 0) return null
45
+
46
+ const agent = agents[agentId]
47
+ const agentTools: string[] = agent?.tools || []
48
+
49
+ const handleGrant = async (toolId: string) => {
50
+ if (agentTools.includes(toolId)) {
51
+ setGranted((prev) => new Set(prev).add(toolId))
52
+ return
53
+ }
54
+ const updated = [...agentTools, toolId]
55
+ await api('PUT', `/agents/${agentId}`, { tools: updated })
56
+ await loadAgents()
57
+ const newGranted = new Set(granted).add(toolId)
58
+ setGranted(newGranted)
59
+
60
+ // Auto-continue: once all requested tools are granted, send @mention to continue
61
+ const allGranted = toolRequests.every(
62
+ (r) => newGranted.has(r.toolId) || updated.includes(r.toolId),
63
+ )
64
+ if (allGranted && !continueSentRef.current) {
65
+ continueSentRef.current = true
66
+ setTimeout(() => {
67
+ const { streaming, sendMessage } = useChatroomStore.getState()
68
+ if (!streaming) {
69
+ sendMessage(`@${agentName.replace(/\s+/g, '')} Continue`)
70
+ }
71
+ }, 300)
72
+ }
73
+ }
74
+
75
+ const handleDeny = (toolId: string) => {
76
+ setDenied((prev) => new Set(prev).add(toolId))
77
+ const label = TOOL_LABELS[toolId] || toolId
78
+ setTimeout(() => {
79
+ const { streaming, sendMessage } = useChatroomStore.getState()
80
+ if (!streaming) {
81
+ sendMessage(`@${agentName.replace(/\s+/g, '')} Tool access denied for ${label} — proceed without it.`)
82
+ }
83
+ }, 200)
84
+ }
85
+
86
+ return (
87
+ <div className="max-w-[85%] flex flex-col gap-2 mt-2">
88
+ {toolRequests.map(({ toolId, reason }) => {
89
+ const isGranted = granted.has(toolId) || agentTools.includes(toolId)
90
+ const isDenied = denied.has(toolId)
91
+ const label = TOOL_LABELS[toolId] || toolId
92
+ return (
93
+ <div
94
+ key={toolId}
95
+ className="flex items-center gap-3 px-4 py-3 rounded-[12px] border border-amber-500/20 bg-amber-500/[0.06]"
96
+ style={{ animation: 'fade-in 0.2s ease' }}
97
+ >
98
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-amber-400 shrink-0">
99
+ <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
100
+ </svg>
101
+ <div className="flex-1 min-w-0">
102
+ <p className="text-[12px] text-text-2 font-600">
103
+ <span className="text-accent-bright">{agentName}</span> requesting <span className="text-amber-400">{label}</span>
104
+ </p>
105
+ {reason && <p className="text-[11px] text-text-3/60 mt-0.5 truncate">{reason}</p>}
106
+ </div>
107
+ {isGranted ? (
108
+ <span className="text-[11px] text-emerald-400 font-600 shrink-0">Granted</span>
109
+ ) : isDenied ? (
110
+ <span className="text-[11px] text-red-400 font-600 shrink-0">Denied</span>
111
+ ) : (
112
+ <div className="flex gap-1.5 shrink-0">
113
+ <button
114
+ onClick={() => handleGrant(toolId)}
115
+ 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
+ style={{ fontFamily: 'inherit' }}
117
+ >
118
+ Grant
119
+ </button>
120
+ <button
121
+ onClick={() => handleDeny(toolId)}
122
+ 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
+ style={{ fontFamily: 'inherit' }}
124
+ >
125
+ Deny
126
+ </button>
127
+ </div>
128
+ )}
129
+ </div>
130
+ )
131
+ })}
132
+ </div>
133
+ )
134
+ }
@@ -0,0 +1,88 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
5
+ import { useAppStore } from '@/stores/use-app-store'
6
+ import type { Agent } from '@/types'
7
+
8
+ /** Render text with @mentions highlighted */
9
+ function renderWithMentions(text: string): ReactNode[] {
10
+ const parts: ReactNode[] = []
11
+ let lastIndex = 0
12
+ const regex = /@\S+/g
13
+ let match: RegExpExecArray | null
14
+ while ((match = regex.exec(text)) !== null) {
15
+ if (match.index > lastIndex) {
16
+ parts.push(text.slice(lastIndex, match.index))
17
+ }
18
+ parts.push(
19
+ <span key={match.index} className="text-accent-bright font-600 bg-accent-soft/40 px-0.5 rounded">
20
+ {match[0]}
21
+ </span>
22
+ )
23
+ lastIndex = regex.lastIndex
24
+ }
25
+ if (lastIndex < text.length) {
26
+ parts.push(text.slice(lastIndex))
27
+ }
28
+ return parts
29
+ }
30
+
31
+ interface Props {
32
+ streamingAgents: Map<string, { text: string; name: string; error?: string }>
33
+ }
34
+
35
+ export function ChatroomTypingBar({ streamingAgents }: Props) {
36
+ const agents = useAppStore((s) => s.agents) as Record<string, Agent>
37
+
38
+ if (streamingAgents.size === 0) return null
39
+
40
+ const entries = Array.from(streamingAgents.entries())
41
+ const errors = entries.filter(([, a]) => a.error)
42
+ const active = entries.filter(([, a]) => !a.error)
43
+
44
+ return (
45
+ <div className="flex flex-col gap-1" style={{ animation: 'msg-in 0.2s ease-out both' }}>
46
+ {/* Error indicators */}
47
+ {errors.map(([agentId, a]) => (
48
+ <div key={agentId} className="flex items-center gap-2 px-4 py-1.5 text-[12px] text-red-400">
49
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
50
+ <circle cx="12" cy="12" r="10" />
51
+ <line x1="12" y1="8" x2="12" y2="12" />
52
+ <line x1="12" y1="16" x2="12.01" y2="16" />
53
+ </svg>
54
+ <span>{a.name} — {a.error}</span>
55
+ </div>
56
+ ))}
57
+
58
+ {/* Live streaming messages — show as inline message bubbles */}
59
+ {active.map(([agentId, a]) => {
60
+ const agent = agents[agentId]
61
+ const hasText = a.text.trim().length > 0
62
+
63
+ return (
64
+ <div key={agentId} className="flex gap-2.5 px-4 py-1.5" style={{ animation: 'msg-in 0.2s ease-out both' }}>
65
+ <div className="shrink-0 mt-0.5 w-7">
66
+ <AgentAvatar seed={agent?.avatarSeed || null} name={a.name} size={28} />
67
+ </div>
68
+ <div className="flex-1 min-w-0">
69
+ <div className="flex items-baseline gap-2 mb-0.5">
70
+ <span className="text-[13px] font-600 text-accent-bright">{a.name}</span>
71
+ <div className="flex gap-0.5 items-center label-mono text-accent-bright/60">
72
+ <span className="w-1 h-1 rounded-full bg-accent-bright animate-bounce" style={{ animationDelay: '0ms' }} />
73
+ <span className="w-1 h-1 rounded-full bg-accent-bright animate-bounce" style={{ animationDelay: '150ms' }} />
74
+ <span className="w-1 h-1 rounded-full bg-accent-bright animate-bounce" style={{ animationDelay: '300ms' }} />
75
+ </div>
76
+ </div>
77
+ {hasText && (
78
+ <div className="text-[13px] text-text/70 leading-[1.5] break-words whitespace-pre-wrap">
79
+ {renderWithMentions(a.text)}
80
+ </div>
81
+ )}
82
+ </div>
83
+ </div>
84
+ )
85
+ })}
86
+ </div>
87
+ )
88
+ }
@@ -0,0 +1,344 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useCallback, useState } from 'react'
4
+ import { useChatroomStore } from '@/stores/use-chatroom-store'
5
+ import { useAppStore } from '@/stores/use-app-store'
6
+ import { useWs } from '@/hooks/use-ws'
7
+ import { ChatroomMessageBubble } from './chatroom-message'
8
+ import { ChatroomInput } from './chatroom-input'
9
+ import { ChatroomTypingBar } from './chatroom-typing-bar'
10
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
11
+ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
12
+ import { HeartbeatMoment, ActivityMoment, isNotableTool } from '@/components/chat/activity-moment'
13
+ import type { Chatroom, ChatroomMessage, Agent } from '@/types'
14
+
15
+ function navigateToAgent(agentId: string) {
16
+ useAppStore.getState().setActiveView('agents')
17
+ useAppStore.getState().setCurrentAgent(agentId)
18
+ }
19
+
20
+ type MomentType = { kind: 'heartbeat' } | { kind: 'tool'; name: string; input: string }
21
+
22
+ /** Subscribe to a single agent heartbeat topic — one hook call per agent */
23
+ function useAgentHeartbeat(agentId: string, onPulse: (id: string) => void) {
24
+ const topic = agentId ? `heartbeat:agent:${agentId}` : ''
25
+ const onPulseRef = useRef(onPulse)
26
+ useEffect(() => {
27
+ onPulseRef.current = onPulse
28
+ }, [onPulse])
29
+ useWs(topic, () => onPulseRef.current(agentId))
30
+ }
31
+
32
+ /** Subscribes up to 6 member agents to heartbeat topics */
33
+ function AgentHeartbeatListeners({ agentIds, onPulse }: { agentIds: string[]; onPulse: (id: string) => void }) {
34
+ useAgentHeartbeat(agentIds[0] || '', onPulse)
35
+ useAgentHeartbeat(agentIds[1] || '', onPulse)
36
+ useAgentHeartbeat(agentIds[2] || '', onPulse)
37
+ useAgentHeartbeat(agentIds[3] || '', onPulse)
38
+ useAgentHeartbeat(agentIds[4] || '', onPulse)
39
+ useAgentHeartbeat(agentIds[5] || '', onPulse)
40
+ return null
41
+ }
42
+
43
+ const GROUP_THRESHOLD_MS = 2 * 60 * 1000 // 2 minutes
44
+
45
+ function dayLabel(ts: number): string {
46
+ const d = new Date(ts)
47
+ const now = new Date()
48
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
49
+ const msgDay = new Date(d.getFullYear(), d.getMonth(), d.getDate())
50
+ const diff = today.getTime() - msgDay.getTime()
51
+ if (diff === 0) return 'Today'
52
+ if (diff === 86400000) return 'Yesterday'
53
+ return d.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' })
54
+ }
55
+
56
+ export function ChatroomView() {
57
+ const currentChatroomId = useChatroomStore((s) => s.currentChatroomId)
58
+ const chatrooms = useChatroomStore((s) => s.chatrooms)
59
+ const streaming = useChatroomStore((s) => s.streaming)
60
+ const streamingAgents = useChatroomStore((s) => s.streamingAgents)
61
+ const sendMessage = useChatroomStore((s) => s.sendMessage)
62
+ const toggleReaction = useChatroomStore((s) => s.toggleReaction)
63
+ const togglePin = useChatroomStore((s) => s.togglePin)
64
+ const setReplyingTo = useChatroomStore((s) => s.setReplyingTo)
65
+ const loadChatrooms = useChatroomStore((s) => s.loadChatrooms)
66
+ const setChatroomSheetOpen = useChatroomStore((s) => s.setChatroomSheetOpen)
67
+ const setEditingChatroomId = useChatroomStore((s) => s.setEditingChatroomId)
68
+ const agents = useAppStore((s) => s.agents) as Record<string, Agent>
69
+ const scrollRef = useRef<HTMLDivElement>(null)
70
+ const [pinsExpanded, setPinsExpanded] = useState(false)
71
+
72
+ // Per-agent moment overlays (heartbeat or tool events)
73
+ const [agentMoments, setAgentMoments] = useState<Record<string, MomentType>>({})
74
+
75
+ const handleHeartbeatPulse = useCallback((agentId: string) => {
76
+ setAgentMoments((prev) => ({ ...prev, [agentId]: { kind: 'heartbeat' } }))
77
+ }, [])
78
+
79
+ const clearAgentMoment = useCallback((agentId: string) => {
80
+ setAgentMoments((prev) => {
81
+ const next = { ...prev }
82
+ delete next[agentId]
83
+ return next
84
+ })
85
+ }, [])
86
+
87
+ const chatroom = currentChatroomId ? (chatrooms[currentChatroomId] as Chatroom | undefined) : null
88
+
89
+ // Detect notable tool events from chatroom messages
90
+ const chatroomMessages = chatroom?.messages
91
+ const prevToolKeysRef = useRef<Record<string, string>>({})
92
+ useEffect(() => {
93
+ if (!chatroomMessages?.length) return
94
+ // Find the last message from each agent and check for notable tools
95
+ const lastByAgent = new Map<string, ChatroomMessage>()
96
+ for (const msg of chatroomMessages) {
97
+ if (msg.senderId !== 'user' && msg.senderId !== 'system') {
98
+ lastByAgent.set(msg.senderId, msg)
99
+ }
100
+ }
101
+ for (const [agentId, msg] of lastByAgent) {
102
+ const events = msg.toolEvents
103
+ if (!events?.length) continue
104
+ for (let i = events.length - 1; i >= 0; i--) {
105
+ if (isNotableTool(events[i].name)) {
106
+ const key = `${msg.id}-${events[i].name}-${i}`
107
+ if (key !== prevToolKeysRef.current[agentId]) {
108
+ prevToolKeysRef.current[agentId] = key
109
+ setAgentMoments((prev) => ({ ...prev, [agentId]: { kind: 'tool', name: events[i].name, input: events[i].input || '' } }))
110
+ }
111
+ break
112
+ }
113
+ }
114
+ }
115
+ }, [chatroomMessages])
116
+
117
+ const refreshChatroom = useCallback(() => {
118
+ loadChatrooms()
119
+ // eslint-disable-next-line react-hooks/exhaustive-deps
120
+ }, [])
121
+
122
+ useWs(currentChatroomId ? `chatroom:${currentChatroomId}` : '', refreshChatroom)
123
+
124
+ // Smooth auto-scroll on new messages
125
+ useEffect(() => {
126
+ if (scrollRef.current) {
127
+ scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
128
+ }
129
+ }, [chatroom?.messages.length, streamingAgents.size])
130
+
131
+ const memberAgents = chatroom
132
+ ? (chatroom.agentIds
133
+ .map((id) => agents[id])
134
+ .filter(Boolean) as Agent[])
135
+ : []
136
+
137
+ const streamingAgentIds = new Set(streamingAgents.keys())
138
+ const pinnedIds = chatroom?.pinnedMessageIds || []
139
+ const pinnedMessages = chatroom
140
+ ? (pinnedIds.map((pid) => chatroom.messages.find((m) => m.id === pid)).filter(Boolean) as ChatroomMessage[])
141
+ : []
142
+
143
+ // Heartbeat subscriptions for up to 6 member agents
144
+ const memberAgentIds = chatroom?.agentIds.slice(0, 6) || []
145
+
146
+ if (!chatroom) {
147
+ return (
148
+ <div className="flex-1 flex items-center justify-center px-8">
149
+ <div className="text-center max-w-[420px]">
150
+ <h2 className="font-display text-[24px] font-700 text-text mb-2 tracking-[-0.02em]">
151
+ Select a Chatroom
152
+ </h2>
153
+ <p className="text-[14px] text-text-3">
154
+ Choose a chatroom from the sidebar or create a new one.
155
+ </p>
156
+ </div>
157
+ </div>
158
+ )
159
+ }
160
+
161
+ const handleTransfer = (messageId: string, targetAgentId: string) => {
162
+ if (!chatroom) return
163
+ const msg = chatroom.messages.find((m) => m.id === messageId)
164
+ const targetAgent = agents[targetAgentId]
165
+ if (!msg || !targetAgent) return
166
+ const truncated = msg.text.length > 120 ? msg.text.slice(0, 120) + '...' : msg.text
167
+ sendMessage(`@${targetAgent.name.replace(/\s+/g, '')} [Transferred from @${msg.senderName.replace(/\s+/g, '')}]: "${truncated}"`)
168
+ }
169
+
170
+ return (
171
+ <div className="flex-1 flex flex-col h-full min-w-0">
172
+ {/* Header */}
173
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-white/[0.06] shrink-0">
174
+ <div className="w-8 h-8 rounded-full bg-accent-soft flex items-center justify-center shrink-0">
175
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-bright">
176
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
177
+ </svg>
178
+ </div>
179
+ <div className="flex-1 min-w-0">
180
+ <h3 className="text-[14px] font-600 text-text truncate">{chatroom.name}</h3>
181
+ <p className="text-[11px] text-text-3 truncate">
182
+ {memberAgents.length} agent{memberAgents.length !== 1 ? 's' : ''}
183
+ {chatroom.description ? ` · ${chatroom.description}` : ''}
184
+ </p>
185
+ </div>
186
+ {/* Member avatars */}
187
+ <div className="flex -space-x-1.5 shrink-0">
188
+ {memberAgents.slice(0, 5).map((agent) => (
189
+ <Tooltip key={agent.id}>
190
+ <TooltipTrigger asChild>
191
+ <button
192
+ onClick={() => navigateToAgent(agent.id)}
193
+ className="relative transition-all duration-200 hover:scale-110 hover:z-10 hover:-translate-y-0.5 cursor-pointer bg-transparent border-none p-0"
194
+ >
195
+ <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={22} className="ring-1 ring-bg" status={streamingAgents.has(agent.id) ? 'busy' : 'online'} />
196
+ </button>
197
+ </TooltipTrigger>
198
+ <TooltipContent side="bottom" sideOffset={6}>
199
+ {agent.name}
200
+ </TooltipContent>
201
+ </Tooltip>
202
+ ))}
203
+ {memberAgents.length > 5 && (
204
+ <div className="w-[22px] h-[22px] rounded-full bg-white/[0.08] flex items-center justify-center text-[9px] text-text-3 ring-1 ring-bg">
205
+ +{memberAgents.length - 5}
206
+ </div>
207
+ )}
208
+ </div>
209
+ <button
210
+ onClick={() => {
211
+ setEditingChatroomId(chatroom.id)
212
+ setChatroomSheetOpen(true)
213
+ }}
214
+ className="shrink-0 w-7 h-7 rounded-full flex items-center justify-center hover:bg-white/[0.08] transition-all cursor-pointer"
215
+ >
216
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3">
217
+ <circle cx="12" cy="12" r="3" />
218
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
219
+ </svg>
220
+ </button>
221
+ </div>
222
+
223
+ {/* Pinned messages bar */}
224
+ {pinnedMessages.length > 0 && (
225
+ <div className="border-b border-white/[0.06] shrink-0">
226
+ <button
227
+ onClick={() => setPinsExpanded(!pinsExpanded)}
228
+ className="w-full flex items-center gap-2 px-4 py-2 hover:bg-white/[0.02] transition-colors cursor-pointer bg-transparent border-none text-left"
229
+ style={{ fontFamily: 'inherit' }}
230
+ >
231
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-amber-400 shrink-0">
232
+ <path d="M12 17v5" />
233
+ <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" />
234
+ </svg>
235
+ <span className="text-[12px] font-500 text-text-2">{pinnedMessages.length} pinned</span>
236
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={`text-text-3 transition-transform ${pinsExpanded ? 'rotate-180' : ''}`}>
237
+ <polyline points="6 9 12 15 18 9" />
238
+ </svg>
239
+ </button>
240
+ {pinsExpanded && (
241
+ <div className="px-4 pb-2 flex flex-col gap-1">
242
+ {pinnedMessages.map((pm) => (
243
+ <button
244
+ key={pm.id}
245
+ onClick={() => {
246
+ const el = document.getElementById(`chatroom-msg-${pm.id}`)
247
+ if (el) {
248
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
249
+ el.classList.add('bg-accent-soft/20')
250
+ setTimeout(() => el.classList.remove('bg-accent-soft/20'), 2000)
251
+ }
252
+ }}
253
+ className="flex items-center gap-2 px-2 py-1.5 rounded-[8px] hover:bg-white/[0.04] transition-colors cursor-pointer bg-transparent border-none text-left w-full"
254
+ style={{ fontFamily: 'inherit' }}
255
+ >
256
+ <span className="text-[11px] font-600 text-accent-bright shrink-0">{pm.senderName}</span>
257
+ <span className="text-[11px] text-text-3 truncate flex-1">{pm.text.slice(0, 80)}</span>
258
+ </button>
259
+ ))}
260
+ </div>
261
+ )}
262
+ </div>
263
+ )}
264
+
265
+ <AgentHeartbeatListeners agentIds={memberAgentIds} onPulse={handleHeartbeatPulse} />
266
+
267
+ {/* Messages */}
268
+ <div ref={scrollRef} className="flex-1 overflow-y-auto py-3">
269
+ {chatroom.messages.length === 0 ? (
270
+ <div className="flex items-center justify-center h-full px-6">
271
+ <div className="text-center">
272
+ <p className="text-[13px] text-text-3 mb-1">No messages yet</p>
273
+ <p className="text-[12px] text-text-3/60">Use @AgentName to mention specific agents, or @all for everyone</p>
274
+ </div>
275
+ </div>
276
+ ) : (
277
+ chatroom.messages.map((msg, i) => {
278
+ const prev = i > 0 ? chatroom.messages[i - 1] : null
279
+ const isGrouped = prev
280
+ ? prev.senderId === msg.senderId && (msg.time - prev.time) < GROUP_THRESHOLD_MS
281
+ : false
282
+ // Day separator: show when the date changes between messages
283
+ const prevDay = prev ? new Date(prev.time).toDateString() : null
284
+ const msgDay = new Date(msg.time).toDateString()
285
+ const showDaySep = !prev || prevDay !== msgDay
286
+
287
+ // Moment overlay — show on the last message from each agent that has an active moment
288
+ const senderId = msg.senderId
289
+ const moment = agentMoments[senderId]
290
+ const isLastFromSender = !chatroom.messages.slice(i + 1).some((m) => m.senderId === senderId)
291
+ let momentOverlay: React.ReactNode = null
292
+ if (moment && isLastFromSender && senderId !== 'user' && senderId !== 'system') {
293
+ if (moment.kind === 'heartbeat') {
294
+ momentOverlay = <HeartbeatMoment onDismiss={() => clearAgentMoment(senderId)} />
295
+ } else {
296
+ momentOverlay = (
297
+ <ActivityMoment
298
+ key={`${moment.name}-${senderId}`}
299
+ toolName={moment.name}
300
+ toolInput={moment.input}
301
+ onDismiss={() => clearAgentMoment(senderId)}
302
+ />
303
+ )
304
+ }
305
+ }
306
+
307
+ return (
308
+ <div key={msg.id}>
309
+ {showDaySep && (
310
+ <div className="flex items-center gap-3 px-4 py-3">
311
+ <div className="flex-1 h-px bg-white/[0.06]" />
312
+ <span className="text-[10px] font-600 text-text-3 uppercase tracking-wider">{dayLabel(msg.time)}</span>
313
+ <div className="flex-1 h-px bg-white/[0.06]" />
314
+ </div>
315
+ )}
316
+ <ChatroomMessageBubble
317
+ message={msg}
318
+ agents={agents}
319
+ onToggleReaction={toggleReaction}
320
+ onReply={(m: ChatroomMessage) => setReplyingTo(m)}
321
+ onTogglePin={togglePin}
322
+ onTransfer={handleTransfer}
323
+ pinnedMessageIds={pinnedIds}
324
+ streamingAgentIds={streamingAgentIds}
325
+ messages={chatroom.messages}
326
+ grouped={isGrouped && !showDaySep}
327
+ momentOverlay={momentOverlay}
328
+ />
329
+ </div>
330
+ )
331
+ })
332
+ )}
333
+ <ChatroomTypingBar streamingAgents={streamingAgents} />
334
+ </div>
335
+
336
+ {/* Input */}
337
+ <ChatroomInput
338
+ agents={memberAgents}
339
+ onSend={sendMessage}
340
+ disabled={streaming}
341
+ />
342
+ </div>
343
+ )
344
+ }