@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.
- package/README.md +42 -7
- package/bin/swarmclaw.js +76 -16
- package/next.config.ts +11 -1
- package/package.json +4 -2
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/scripts/postinstall.mjs +18 -0
- package/src/app/api/chatrooms/[id]/chat/route.ts +410 -0
- package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
- package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
- package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
- package/src/app/api/chatrooms/[id]/route.ts +84 -0
- package/src/app/api/chatrooms/route.ts +50 -0
- package/src/app/api/credentials/route.ts +2 -3
- package/src/app/api/knowledge/[id]/route.ts +13 -2
- package/src/app/api/knowledge/route.ts +8 -1
- package/src/app/api/memory/route.ts +8 -0
- package/src/app/api/notifications/[id]/route.ts +27 -0
- package/src/app/api/notifications/route.ts +68 -0
- package/src/app/api/orchestrator/run/route.ts +1 -1
- package/src/app/api/plugins/install/route.ts +2 -2
- package/src/app/api/search/route.ts +155 -0
- package/src/app/api/sessions/[id]/chat/route.ts +2 -0
- package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
- package/src/app/api/sessions/[id]/fork/route.ts +1 -1
- package/src/app/api/sessions/route.ts +3 -3
- package/src/app/api/settings/route.ts +9 -0
- package/src/app/api/setup/check-provider/route.ts +3 -16
- package/src/app/api/skills/[id]/route.ts +6 -0
- package/src/app/api/skills/route.ts +6 -0
- package/src/app/api/tasks/[id]/route.ts +20 -0
- package/src/app/api/tasks/bulk/route.ts +100 -0
- package/src/app/api/tasks/route.ts +1 -0
- package/src/app/api/usage/route.ts +45 -0
- package/src/app/api/webhooks/[id]/route.ts +15 -1
- package/src/app/globals.css +58 -15
- package/src/app/page.tsx +142 -13
- package/src/cli/index.js +42 -0
- package/src/cli/index.test.js +30 -0
- package/src/cli/spec.js +32 -0
- package/src/components/agents/agent-avatar.tsx +57 -10
- package/src/components/agents/agent-card.tsx +48 -15
- package/src/components/agents/agent-chat-list.tsx +123 -10
- package/src/components/agents/agent-list.tsx +50 -19
- package/src/components/agents/agent-sheet.tsx +56 -63
- package/src/components/auth/access-key-gate.tsx +10 -3
- package/src/components/auth/setup-wizard.tsx +2 -2
- package/src/components/auth/user-picker.tsx +31 -3
- package/src/components/chat/activity-moment.tsx +169 -0
- package/src/components/chat/chat-header.tsx +2 -0
- package/src/components/chat/chat-tool-toggles.tsx +1 -1
- package/src/components/chat/file-path-chip.tsx +125 -0
- package/src/components/chat/markdown-utils.ts +9 -0
- package/src/components/chat/message-bubble.tsx +46 -295
- package/src/components/chat/message-list.tsx +50 -1
- package/src/components/chat/streaming-bubble.tsx +36 -46
- package/src/components/chat/suggestions-bar.tsx +1 -1
- package/src/components/chat/thinking-indicator.tsx +72 -10
- package/src/components/chat/tool-call-bubble.tsx +66 -70
- package/src/components/chat/tool-request-banner.tsx +31 -7
- package/src/components/chat/transfer-agent-picker.tsx +63 -0
- package/src/components/chatrooms/agent-hover-card.tsx +124 -0
- package/src/components/chatrooms/chatroom-input.tsx +320 -0
- package/src/components/chatrooms/chatroom-list.tsx +123 -0
- package/src/components/chatrooms/chatroom-message.tsx +427 -0
- package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
- package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
- package/src/components/chatrooms/chatroom-view.tsx +344 -0
- package/src/components/chatrooms/reaction-picker.tsx +273 -0
- package/src/components/connectors/connector-sheet.tsx +34 -47
- package/src/components/home/home-view.tsx +501 -0
- package/src/components/input/chat-input.tsx +79 -41
- package/src/components/knowledge/knowledge-list.tsx +31 -1
- package/src/components/knowledge/knowledge-sheet.tsx +83 -2
- package/src/components/layout/app-layout.tsx +209 -83
- package/src/components/layout/mobile-header.tsx +2 -0
- package/src/components/layout/update-banner.tsx +2 -2
- package/src/components/logs/log-list.tsx +2 -2
- package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
- package/src/components/memory/memory-agent-list.tsx +143 -0
- package/src/components/memory/memory-browser.tsx +205 -0
- package/src/components/memory/memory-card.tsx +34 -7
- package/src/components/memory/memory-detail.tsx +359 -120
- package/src/components/memory/memory-sheet.tsx +157 -23
- package/src/components/plugins/plugin-list.tsx +1 -1
- package/src/components/plugins/plugin-sheet.tsx +1 -1
- package/src/components/projects/project-detail.tsx +509 -0
- package/src/components/projects/project-list.tsx +195 -59
- package/src/components/providers/provider-list.tsx +2 -2
- package/src/components/providers/provider-sheet.tsx +3 -3
- package/src/components/schedules/schedule-card.tsx +3 -2
- package/src/components/schedules/schedule-list.tsx +1 -1
- package/src/components/schedules/schedule-sheet.tsx +25 -25
- package/src/components/secrets/secret-sheet.tsx +47 -24
- package/src/components/secrets/secrets-list.tsx +18 -8
- package/src/components/sessions/new-session-sheet.tsx +33 -65
- package/src/components/sessions/session-card.tsx +45 -14
- package/src/components/sessions/session-list.tsx +35 -18
- package/src/components/shared/agent-picker-list.tsx +90 -0
- package/src/components/shared/agent-switch-dialog.tsx +156 -0
- package/src/components/shared/attachment-chip.tsx +165 -0
- package/src/components/shared/avatar.tsx +10 -1
- package/src/components/shared/check-icon.tsx +12 -0
- package/src/components/shared/confirm-dialog.tsx +1 -1
- package/src/components/shared/empty-state.tsx +32 -0
- package/src/components/shared/file-preview.tsx +34 -0
- package/src/components/shared/form-styles.ts +2 -0
- package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
- package/src/components/shared/notification-center.tsx +223 -0
- package/src/components/shared/profile-sheet.tsx +115 -0
- package/src/components/shared/reply-quote.tsx +26 -0
- package/src/components/shared/search-dialog.tsx +296 -0
- package/src/components/shared/section-label.tsx +12 -0
- package/src/components/shared/settings/plugin-manager.tsx +1 -1
- package/src/components/shared/settings/section-providers.tsx +1 -1
- package/src/components/shared/settings/section-secrets.tsx +1 -1
- package/src/components/shared/settings/section-theme.tsx +95 -0
- package/src/components/shared/settings/section-user-preferences.tsx +39 -0
- package/src/components/shared/settings/settings-page.tsx +180 -27
- package/src/components/shared/settings/settings-sheet.tsx +9 -73
- package/src/components/shared/sheet-footer.tsx +33 -0
- package/src/components/skills/skill-list.tsx +61 -30
- package/src/components/skills/skill-sheet.tsx +81 -2
- package/src/components/tasks/task-board.tsx +448 -26
- package/src/components/tasks/task-card.tsx +46 -9
- package/src/components/tasks/task-column.tsx +62 -3
- package/src/components/tasks/task-list.tsx +12 -4
- package/src/components/tasks/task-sheet.tsx +89 -72
- package/src/components/ui/hover-card.tsx +52 -0
- package/src/components/usage/metrics-dashboard.tsx +78 -0
- package/src/components/usage/usage-list.tsx +1 -1
- package/src/components/webhooks/webhook-sheet.tsx +1 -1
- package/src/hooks/use-view-router.ts +69 -19
- package/src/instrumentation.ts +15 -1
- package/src/lib/chat.ts +2 -0
- package/src/lib/cron-human.ts +114 -0
- package/src/lib/memory.ts +3 -0
- package/src/lib/server/chat-execution.ts +24 -4
- package/src/lib/server/connectors/manager.ts +11 -0
- package/src/lib/server/context-manager.ts +225 -13
- package/src/lib/server/create-notification.ts +42 -0
- package/src/lib/server/daemon-state.ts +165 -10
- package/src/lib/server/execution-log.ts +1 -0
- package/src/lib/server/heartbeat-service.ts +40 -5
- package/src/lib/server/heartbeat-wake.ts +110 -0
- package/src/lib/server/langgraph-checkpoint.ts +1 -0
- package/src/lib/server/memory-consolidation.ts +92 -0
- package/src/lib/server/memory-db.ts +51 -6
- package/src/lib/server/openclaw-gateway.ts +9 -1
- package/src/lib/server/provider-health.ts +125 -0
- package/src/lib/server/queue.ts +5 -4
- package/src/lib/server/scheduler.ts +8 -0
- package/src/lib/server/session-run-manager.ts +4 -0
- package/src/lib/server/session-tools/chatroom.ts +136 -0
- package/src/lib/server/session-tools/context-mgmt.ts +36 -18
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/memory.ts +6 -1
- package/src/lib/server/storage.ts +80 -29
- package/src/lib/server/stream-agent-chat.ts +153 -47
- package/src/lib/server/system-events.ts +49 -0
- package/src/lib/server/ws-hub.ts +11 -0
- package/src/lib/soul-suggestions.ts +109 -0
- package/src/lib/tasks.ts +4 -1
- package/src/lib/view-routes.ts +36 -1
- package/src/lib/ws-client.ts +14 -4
- package/src/proxy.ts +79 -2
- package/src/stores/use-app-store.ts +94 -3
- package/src/stores/use-chat-store.ts +48 -3
- package/src/stores/use-chatroom-store.ts +276 -0
- 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
|
+
}
|