@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
package/src/cli/spec.js
CHANGED
|
@@ -25,6 +25,21 @@ const COMMAND_GROUPS = {
|
|
|
25
25
|
login: { description: 'Validate an access key', method: 'POST', path: '/auth' },
|
|
26
26
|
},
|
|
27
27
|
},
|
|
28
|
+
chatrooms: {
|
|
29
|
+
description: 'Manage multi-agent chatrooms',
|
|
30
|
+
commands: {
|
|
31
|
+
list: { description: 'List chatrooms', method: 'GET', path: '/chatrooms' },
|
|
32
|
+
get: { description: 'Get chatroom by id', method: 'GET', path: '/chatrooms/:id', params: ['id'] },
|
|
33
|
+
create: { description: 'Create a chatroom', method: 'POST', path: '/chatrooms' },
|
|
34
|
+
update: { description: 'Update a chatroom', method: 'PUT', path: '/chatrooms/:id', params: ['id'] },
|
|
35
|
+
delete: { description: 'Delete a chatroom', method: 'DELETE', path: '/chatrooms/:id', params: ['id'] },
|
|
36
|
+
chat: { description: 'Post chatroom message and stream agent replies', method: 'POST', path: '/chatrooms/:id/chat', params: ['id'] },
|
|
37
|
+
'add-member': { description: 'Add an agent to a chatroom', method: 'POST', path: '/chatrooms/:id/members', params: ['id'] },
|
|
38
|
+
'remove-member': { description: 'Remove an agent from a chatroom', method: 'DELETE', path: '/chatrooms/:id/members', params: ['id'] },
|
|
39
|
+
react: { description: 'Toggle reaction on a chatroom message', method: 'POST', path: '/chatrooms/:id/reactions', params: ['id'] },
|
|
40
|
+
pin: { description: 'Toggle pin on a chatroom message', method: 'POST', path: '/chatrooms/:id/pins', params: ['id'] },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
28
43
|
connectors: {
|
|
29
44
|
description: 'Manage chat connectors',
|
|
30
45
|
commands: {
|
|
@@ -130,6 +145,16 @@ const COMMAND_GROUPS = {
|
|
|
130
145
|
get: { description: 'Download memory image by filename', method: 'GET', path: '/memory-images/:filename', params: ['filename'], binary: true },
|
|
131
146
|
},
|
|
132
147
|
},
|
|
148
|
+
notifications: {
|
|
149
|
+
description: 'In-app notification center',
|
|
150
|
+
commands: {
|
|
151
|
+
list: { description: 'List notifications (supports --query unreadOnly=true,limit=100)', method: 'GET', path: '/notifications' },
|
|
152
|
+
create: { description: 'Create notification', method: 'POST', path: '/notifications' },
|
|
153
|
+
clear: { description: 'Clear read notifications', method: 'DELETE', path: '/notifications' },
|
|
154
|
+
'mark-read': { description: 'Mark notification as read', method: 'PUT', path: '/notifications/:id', params: ['id'] },
|
|
155
|
+
delete: { description: 'Delete notification by id', method: 'DELETE', path: '/notifications/:id', params: ['id'] },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
133
158
|
orchestrator: {
|
|
134
159
|
description: 'Orchestrator runs and run-state APIs',
|
|
135
160
|
commands: {
|
|
@@ -198,6 +223,12 @@ const COMMAND_GROUPS = {
|
|
|
198
223
|
'models-reset': { description: 'Delete provider model overrides', method: 'DELETE', path: '/providers/:id/models', params: ['id'] },
|
|
199
224
|
},
|
|
200
225
|
},
|
|
226
|
+
search: {
|
|
227
|
+
description: 'Global search across app resources',
|
|
228
|
+
commands: {
|
|
229
|
+
query: { description: 'Search agents/tasks/sessions/schedules/webhooks/skills (supports --query q=term)', method: 'GET', path: '/search' },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
201
232
|
schedules: {
|
|
202
233
|
description: 'Scheduled task automation',
|
|
203
234
|
commands: {
|
|
@@ -287,6 +318,7 @@ const COMMAND_GROUPS = {
|
|
|
287
318
|
list: { description: 'List tasks', method: 'GET', path: '/tasks' },
|
|
288
319
|
get: { description: 'Get task by id', method: 'GET', path: '/tasks/:id', params: ['id'] },
|
|
289
320
|
create: { description: 'Create task', method: 'POST', path: '/tasks' },
|
|
321
|
+
bulk: { description: 'Bulk update tasks (status/agent/project)', method: 'POST', path: '/tasks/bulk' },
|
|
290
322
|
update: { description: 'Update task', method: 'PUT', path: '/tasks/:id', params: ['id'] },
|
|
291
323
|
delete: { description: 'Archive task', method: 'DELETE', path: '/tasks/:id', params: ['id'] },
|
|
292
324
|
archive: { description: 'Archive task', method: 'DELETE', path: '/tasks/:id', params: ['id'] },
|
|
@@ -3,26 +3,66 @@
|
|
|
3
3
|
import { useMemo } from 'react'
|
|
4
4
|
import multiavatar from '@multiavatar/multiavatar'
|
|
5
5
|
|
|
6
|
+
/** Strip scripts/event handlers from SVG to prevent XSS */
|
|
7
|
+
function sanitizeSvg(svg: string): string {
|
|
8
|
+
return svg
|
|
9
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
10
|
+
.replace(/\bon\w+\s*=\s*"[^"]*"/gi, '')
|
|
11
|
+
.replace(/\bon\w+\s*=\s*'[^']*'/gi, '')
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
interface Props {
|
|
7
15
|
seed?: string | null
|
|
8
16
|
name: string
|
|
9
17
|
size?: number
|
|
10
18
|
className?: string
|
|
19
|
+
status?: 'idle' | 'busy' | 'online'
|
|
20
|
+
heartbeatPulse?: boolean
|
|
11
21
|
}
|
|
12
22
|
|
|
13
|
-
|
|
23
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
24
|
+
busy: 'bg-amber-400',
|
|
25
|
+
online: 'bg-emerald-400',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HEART_PATH = 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
|
|
29
|
+
|
|
30
|
+
export function AgentAvatar({ seed, name, size = 32, className = '', status, heartbeatPulse }: Props) {
|
|
14
31
|
const svgHtml = useMemo(() => {
|
|
15
32
|
if (!seed) return null
|
|
16
|
-
return multiavatar(seed)
|
|
33
|
+
return sanitizeSvg(multiavatar(seed))
|
|
17
34
|
}, [seed])
|
|
18
35
|
|
|
36
|
+
const dotSize = Math.max(6, Math.round(size * 0.28))
|
|
37
|
+
const dot = status && status !== 'idle' ? (
|
|
38
|
+
<span
|
|
39
|
+
className={`absolute -bottom-0.5 -right-0.5 rounded-full ${STATUS_COLORS[status]} ring-2 ring-[#0f0f1a]`}
|
|
40
|
+
style={{ width: dotSize, height: dotSize }}
|
|
41
|
+
title={status === 'busy' ? 'Busy' : 'Online'}
|
|
42
|
+
/>
|
|
43
|
+
) : null
|
|
44
|
+
|
|
45
|
+
const heartEl = heartbeatPulse ? (
|
|
46
|
+
<svg
|
|
47
|
+
className="absolute left-1/2 -translate-x-1/2 pointer-events-none"
|
|
48
|
+
style={{ top: -Math.max(10, size * 0.35), width: 10, height: 10, animation: 'heartbeat-float 1.5s ease forwards' }}
|
|
49
|
+
viewBox="0 0 24 24"
|
|
50
|
+
fill="#22c55e"
|
|
51
|
+
>
|
|
52
|
+
<path d={HEART_PATH} />
|
|
53
|
+
</svg>
|
|
54
|
+
) : null
|
|
55
|
+
|
|
19
56
|
if (svgHtml) {
|
|
20
57
|
return (
|
|
21
|
-
<div
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
58
|
+
<div className={`relative shrink-0 ${className}`} style={{ width: size, height: size }}>
|
|
59
|
+
<div
|
|
60
|
+
className="rounded-full overflow-hidden w-full h-full"
|
|
61
|
+
dangerouslySetInnerHTML={{ __html: svgHtml }}
|
|
62
|
+
/>
|
|
63
|
+
{heartEl}
|
|
64
|
+
{dot}
|
|
65
|
+
</div>
|
|
26
66
|
)
|
|
27
67
|
}
|
|
28
68
|
|
|
@@ -36,10 +76,17 @@ export function AgentAvatar({ seed, name, size = 32, className = '' }: Props) {
|
|
|
36
76
|
|
|
37
77
|
return (
|
|
38
78
|
<div
|
|
39
|
-
className={`shrink-0
|
|
40
|
-
style={{ width: size, height: size
|
|
79
|
+
className={`relative shrink-0 ${className}`}
|
|
80
|
+
style={{ width: size, height: size }}
|
|
41
81
|
>
|
|
42
|
-
|
|
82
|
+
<div
|
|
83
|
+
className="rounded-full flex items-center justify-center bg-accent-soft text-accent-bright font-600 w-full h-full"
|
|
84
|
+
style={{ fontSize: size * 0.38 }}
|
|
85
|
+
>
|
|
86
|
+
{initials || '?'}
|
|
87
|
+
</div>
|
|
88
|
+
{heartEl}
|
|
89
|
+
{dot}
|
|
43
90
|
</div>
|
|
44
91
|
)
|
|
45
92
|
}
|
|
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
|
|
4
4
|
import type { Agent } from '@/types'
|
|
5
5
|
import { useAppStore } from '@/stores/use-app-store'
|
|
6
6
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
7
|
+
import { useWs } from '@/hooks/use-ws'
|
|
7
8
|
import { api } from '@/lib/api-client'
|
|
8
9
|
import { createAgent, deleteAgent } from '@/lib/agents'
|
|
9
10
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
|
@@ -17,16 +18,18 @@ import {
|
|
|
17
18
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog'
|
|
18
19
|
import { useApprovalStore } from '@/stores/use-approval-store'
|
|
19
20
|
import { AgentAvatar } from './agent-avatar'
|
|
21
|
+
import { toast } from 'sonner'
|
|
20
22
|
|
|
21
23
|
interface Props {
|
|
22
24
|
agent: Agent
|
|
23
25
|
isDefault?: boolean
|
|
24
26
|
isRunning?: boolean
|
|
27
|
+
isOnline?: boolean
|
|
25
28
|
isSelected?: boolean
|
|
26
29
|
onSetDefault?: (id: string) => void
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
export function AgentCard({ agent, isDefault, isRunning, isSelected, onSetDefault }: Props) {
|
|
32
|
+
export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, onSetDefault }: Props) {
|
|
30
33
|
const setEditingAgentId = useAppStore((s) => s.setEditingAgentId)
|
|
31
34
|
const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
|
|
32
35
|
const loadSessions = useAppStore((s) => s.loadSessions)
|
|
@@ -34,12 +37,18 @@ export function AgentCard({ agent, isDefault, isRunning, isSelected, onSetDefaul
|
|
|
34
37
|
const setCurrentSession = useAppStore((s) => s.setCurrentSession)
|
|
35
38
|
const setActiveView = useAppStore((s) => s.setActiveView)
|
|
36
39
|
const setMessages = useChatStore((s) => s.setMessages)
|
|
40
|
+
const togglePinAgent = useAppStore((s) => s.togglePinAgent)
|
|
37
41
|
const [running, setRunning] = useState(false)
|
|
38
42
|
const [dialogOpen, setDialogOpen] = useState(false)
|
|
39
43
|
const [taskInput, setTaskInput] = useState('')
|
|
40
44
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
41
45
|
const approvals = useApprovalStore((s) => s.approvals)
|
|
42
46
|
const pendingApprovalCount = Object.values(approvals).filter((a) => a.agentId === agent.id).length
|
|
47
|
+
const [heartbeatPulse, setHeartbeatPulse] = useState(false)
|
|
48
|
+
useWs(`heartbeat:agent:${agent.id}`, () => {
|
|
49
|
+
setHeartbeatPulse(true)
|
|
50
|
+
setTimeout(() => setHeartbeatPulse(false), 1500)
|
|
51
|
+
})
|
|
43
52
|
|
|
44
53
|
const handleClick = () => {
|
|
45
54
|
setEditingAgentId(agent.id)
|
|
@@ -74,11 +83,13 @@ export function AgentCard({ agent, isDefault, isRunning, isSelected, onSetDefaul
|
|
|
74
83
|
const { id: _id, createdAt: _ca, updatedAt: _ua, ...rest } = agent
|
|
75
84
|
await createAgent({ ...rest, name: agent.name + ' (Copy)' })
|
|
76
85
|
await loadAgents()
|
|
86
|
+
toast.success('Agent duplicated')
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
const handleDelete = async () => {
|
|
80
90
|
await deleteAgent(agent.id)
|
|
81
91
|
await loadAgents()
|
|
92
|
+
toast.success('Agent moved to trash')
|
|
82
93
|
setConfirmDelete(false)
|
|
83
94
|
}
|
|
84
95
|
|
|
@@ -93,6 +104,21 @@ export function AgentCard({ agent, isDefault, isRunning, isSelected, onSetDefaul
|
|
|
93
104
|
: 'bg-transparent border border-transparent hover:bg-white/[0.05] hover:border-white/[0.08]'}`}
|
|
94
105
|
>
|
|
95
106
|
{isSelected && <div className="card-select-indicator" />}
|
|
107
|
+
{/* Pin/star button */}
|
|
108
|
+
<button
|
|
109
|
+
onClick={(e) => {
|
|
110
|
+
e.stopPropagation()
|
|
111
|
+
togglePinAgent(agent.id)
|
|
112
|
+
toast.success(agent.pinned ? 'Agent unpinned' : 'Agent pinned')
|
|
113
|
+
}}
|
|
114
|
+
aria-label={agent.pinned ? 'Unpin agent' : 'Pin agent'}
|
|
115
|
+
className={`absolute top-3 right-10 p-1 rounded-[6px] transition-all bg-transparent border-none cursor-pointer hover:bg-white/[0.06]
|
|
116
|
+
${agent.pinned ? 'opacity-100 text-amber-400' : 'opacity-0 group-hover:opacity-60 hover:!opacity-100 text-text-3'}`}
|
|
117
|
+
>
|
|
118
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill={agent.pinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
119
|
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
120
|
+
</svg>
|
|
121
|
+
</button>
|
|
96
122
|
{/* Three-dot dropdown */}
|
|
97
123
|
<DropdownMenu>
|
|
98
124
|
<DropdownMenuTrigger asChild>
|
|
@@ -111,9 +137,12 @@ export function AgentCard({ agent, isDefault, isRunning, isSelected, onSetDefaul
|
|
|
111
137
|
</DropdownMenuTrigger>
|
|
112
138
|
<DropdownMenuContent align="end" className="min-w-[140px]">
|
|
113
139
|
<DropdownMenuItem onClick={handleClick}>Edit</DropdownMenuItem>
|
|
140
|
+
<DropdownMenuItem onClick={() => { togglePinAgent(agent.id); toast.success(agent.pinned ? 'Agent unpinned' : 'Agent pinned') }}>
|
|
141
|
+
{agent.pinned ? 'Unpin' : 'Pin'}
|
|
142
|
+
</DropdownMenuItem>
|
|
114
143
|
<DropdownMenuItem onClick={handleDuplicate}>Duplicate</DropdownMenuItem>
|
|
115
144
|
{!isDefault && onSetDefault && (
|
|
116
|
-
<DropdownMenuItem onClick={() => onSetDefault(agent.id)}>Set Default</DropdownMenuItem>
|
|
145
|
+
<DropdownMenuItem onClick={() => { onSetDefault(agent.id); toast.success(`${agent.name} set as default`) }}>Set Default</DropdownMenuItem>
|
|
117
146
|
)}
|
|
118
147
|
<DropdownMenuSeparator />
|
|
119
148
|
<DropdownMenuItem
|
|
@@ -126,10 +155,13 @@ export function AgentCard({ agent, isDefault, isRunning, isSelected, onSetDefaul
|
|
|
126
155
|
</DropdownMenu>
|
|
127
156
|
|
|
128
157
|
<div className="flex items-center gap-2.5">
|
|
129
|
-
<AgentAvatar
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
158
|
+
<AgentAvatar
|
|
159
|
+
seed={agent.avatarSeed}
|
|
160
|
+
name={agent.name}
|
|
161
|
+
size={28}
|
|
162
|
+
status={isRunning ? 'busy' : isOnline ? 'online' : undefined}
|
|
163
|
+
heartbeatPulse={heartbeatPulse}
|
|
164
|
+
/>
|
|
133
165
|
<span className="font-display text-[14px] font-600 truncate flex-1 tracking-[-0.01em]">{agent.name}</span>
|
|
134
166
|
{pendingApprovalCount > 0 && (
|
|
135
167
|
<span className="shrink-0 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-700">
|
|
@@ -146,15 +178,16 @@ export function AgentCard({ agent, isDefault, isRunning, isSelected, onSetDefaul
|
|
|
146
178
|
onClick={handleRunClick}
|
|
147
179
|
disabled={running}
|
|
148
180
|
className="shrink-0 text-[10px] font-600 uppercase tracking-wider px-2.5 py-1 rounded-[6px] cursor-pointer
|
|
149
|
-
transition-all border-none bg-
|
|
181
|
+
transition-all border-none bg-accent-bright/20 text-[#818CF8] hover:bg-accent-bright/30 disabled:opacity-40"
|
|
150
182
|
style={{ fontFamily: 'inherit' }}
|
|
151
183
|
>
|
|
152
184
|
{running ? '...' : 'Run'}
|
|
153
185
|
</button>
|
|
154
186
|
)}
|
|
155
187
|
{agent.isOrchestrator && (
|
|
156
|
-
<span className="shrink-0 text-[10px] font-600 uppercase tracking-wider text-amber-400/80 bg-amber-400/[0.08] px-2 py-0.5 rounded-[6px]">
|
|
157
|
-
|
|
188
|
+
<span className="shrink-0 text-[10px] font-600 uppercase tracking-wider text-amber-400/80 bg-amber-400/[0.08] px-2 py-0.5 rounded-[6px] flex items-center gap-1">
|
|
189
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M16 3h5v5"/><path d="M21 3l-7 7"/><path d="M8 21H3v-5"/><path d="M3 21l7-7"/></svg>
|
|
190
|
+
delegates
|
|
158
191
|
</span>
|
|
159
192
|
)}
|
|
160
193
|
</div>
|
|
@@ -168,19 +201,19 @@ export function AgentCard({ agent, isDefault, isRunning, isSelected, onSetDefaul
|
|
|
168
201
|
)}
|
|
169
202
|
</div>
|
|
170
203
|
<div className="flex items-center gap-3 mt-1.5 text-[11px] text-text-3/50">
|
|
171
|
-
{
|
|
204
|
+
{agent.lastUsedAt ? (
|
|
172
205
|
<span>Last used: {(() => {
|
|
173
|
-
const days = Math.floor((Date.now() -
|
|
206
|
+
const days = Math.floor((Date.now() - agent.lastUsedAt) / 86400000)
|
|
174
207
|
return days === 0 ? 'today' : `${days}d ago`
|
|
175
208
|
})()}</span>
|
|
176
|
-
) :
|
|
209
|
+
) : agent.updatedAt ? (
|
|
177
210
|
<span>Updated: {(() => {
|
|
178
211
|
const days = Math.floor((Date.now() - agent.updatedAt) / 86400000)
|
|
179
212
|
return days === 0 ? 'today' : `${days}d ago`
|
|
180
213
|
})()}</span>
|
|
181
214
|
) : null}
|
|
182
|
-
{
|
|
183
|
-
<span>Cost: ${
|
|
215
|
+
{agent.totalCost != null && agent.totalCost > 0 && (
|
|
216
|
+
<span>Cost: ${agent.totalCost.toFixed(2)}</span>
|
|
184
217
|
)}
|
|
185
218
|
</div>
|
|
186
219
|
</div>
|
|
@@ -214,7 +247,7 @@ export function AgentCard({ agent, isDefault, isRunning, isSelected, onSetDefaul
|
|
|
214
247
|
<button
|
|
215
248
|
onClick={handleConfirmRun}
|
|
216
249
|
disabled={!taskInput.trim()}
|
|
217
|
-
className="px-4 py-2 rounded-[10px] border-none bg-
|
|
250
|
+
className="px-4 py-2 rounded-[10px] border-none bg-accent-bright text-white text-[13px] font-600 cursor-pointer disabled:opacity-30 transition-all hover:brightness-110"
|
|
218
251
|
style={{ fontFamily: 'inherit' }}
|
|
219
252
|
>
|
|
220
253
|
Run
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
4
4
|
import { useAppStore } from '@/stores/use-app-store'
|
|
5
5
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
6
6
|
import { fetchMessages } from '@/lib/sessions'
|
|
7
7
|
import type { Agent, Session } from '@/types'
|
|
8
8
|
import { AgentAvatar } from './agent-avatar'
|
|
9
|
+
import { toast } from 'sonner'
|
|
9
10
|
|
|
10
11
|
interface Props {
|
|
11
12
|
inSidebar?: boolean
|
|
@@ -21,9 +22,23 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
|
|
|
21
22
|
const setMessages = useChatStore((s) => s.setMessages)
|
|
22
23
|
const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
|
|
23
24
|
const tasks = useAppStore((s) => s.tasks)
|
|
25
|
+
const togglePinAgent = useAppStore((s) => s.togglePinAgent)
|
|
26
|
+
const appSettings = useAppStore((s) => s.appSettings)
|
|
27
|
+
const updateSettings = useAppStore((s) => s.updateSettings)
|
|
24
28
|
const streamingSessionId = useChatStore((s) => s.streamingSessionId)
|
|
29
|
+
const chatFilter = useAppStore((s) => s.chatFilter ?? 'all')
|
|
30
|
+
const setChatFilter = useAppStore((s) => s.setChatFilter)
|
|
25
31
|
const [search, setSearch] = useState('')
|
|
26
32
|
|
|
33
|
+
// FLIP animation refs
|
|
34
|
+
const rowRefs = useRef<Map<string, HTMLElement>>(new Map())
|
|
35
|
+
const previousTopRef = useRef<Map<string, number>>(new Map())
|
|
36
|
+
|
|
37
|
+
const setRowRef = useCallback((id: string, el: HTMLElement | null) => {
|
|
38
|
+
if (el) rowRefs.current.set(id, el)
|
|
39
|
+
else rowRefs.current.delete(id)
|
|
40
|
+
}, [])
|
|
41
|
+
|
|
27
42
|
useEffect(() => { loadAgents() }, [loadAgents])
|
|
28
43
|
|
|
29
44
|
// Build agent list sorted by last activity in their thread session
|
|
@@ -51,6 +66,41 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
|
|
|
51
66
|
return set
|
|
52
67
|
}, [tasks])
|
|
53
68
|
|
|
69
|
+
// Apply chatFilter
|
|
70
|
+
const filteredAgents = useMemo(() => {
|
|
71
|
+
if (chatFilter === 'all') return sortedAgents
|
|
72
|
+
const now = Date.now()
|
|
73
|
+
return sortedAgents.filter((a) => {
|
|
74
|
+
const threadSession = a.threadSessionId ? sessions[a.threadSessionId] as Session | undefined : undefined
|
|
75
|
+
const isRunning = runningAgentIds.has(a.id) || (threadSession?.active ?? false)
|
|
76
|
+
const isStreaming = streamingSessionId === a.threadSessionId
|
|
77
|
+
if (chatFilter === 'active') return isRunning || isStreaming
|
|
78
|
+
// 'recent' — activity within 24h
|
|
79
|
+
const lastActive = threadSession?.lastActiveAt || a.updatedAt
|
|
80
|
+
return now - lastActive < 86_400_000
|
|
81
|
+
})
|
|
82
|
+
}, [sortedAgents, chatFilter, sessions, runningAgentIds, streamingSessionId])
|
|
83
|
+
|
|
84
|
+
// FLIP: animate row position changes
|
|
85
|
+
useLayoutEffect(() => {
|
|
86
|
+
const prevTop = previousTopRef.current
|
|
87
|
+
for (const agent of filteredAgents) {
|
|
88
|
+
const el = rowRefs.current.get(agent.id)
|
|
89
|
+
if (!el) continue
|
|
90
|
+
const newTop = el.getBoundingClientRect().top
|
|
91
|
+
const oldTop = prevTop.get(agent.id)
|
|
92
|
+
if (oldTop !== undefined && oldTop !== newTop) {
|
|
93
|
+
const delta = oldTop - newTop
|
|
94
|
+
el.animate(
|
|
95
|
+
[{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }],
|
|
96
|
+
{ duration: 300, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
prevTop.set(agent.id, newTop)
|
|
100
|
+
}
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
}, [filteredAgents.map((a) => a.id).join(',')])
|
|
103
|
+
|
|
54
104
|
const handleSelect = async (agent: Agent) => {
|
|
55
105
|
await setCurrentAgent(agent.id)
|
|
56
106
|
// Load messages for the thread
|
|
@@ -84,7 +134,7 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
|
|
|
84
134
|
{!inSidebar && (
|
|
85
135
|
<button
|
|
86
136
|
onClick={() => setAgentSheetOpen(true)}
|
|
87
|
-
className="mt-3 px-8 py-3 rounded-[14px] border-none bg-
|
|
137
|
+
className="mt-3 px-8 py-3 rounded-[14px] border-none bg-accent-bright text-white
|
|
88
138
|
text-[14px] font-600 cursor-pointer active:scale-95 transition-all duration-200
|
|
89
139
|
shadow-[0_4px_16px_rgba(99,102,241,0.2)]"
|
|
90
140
|
style={{ fontFamily: 'inherit' }}
|
|
@@ -98,6 +148,24 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
|
|
|
98
148
|
|
|
99
149
|
return (
|
|
100
150
|
<div className="flex-1 overflow-y-auto">
|
|
151
|
+
{/* Filter control */}
|
|
152
|
+
{sortedAgents.length > 2 && (
|
|
153
|
+
<div className="flex items-center gap-1 px-4 pt-2.5 pb-1">
|
|
154
|
+
{(['all', 'active', 'recent'] as const).map((f) => (
|
|
155
|
+
<button
|
|
156
|
+
key={f}
|
|
157
|
+
type="button"
|
|
158
|
+
onClick={() => setChatFilter(f)}
|
|
159
|
+
data-active={chatFilter === f || undefined}
|
|
160
|
+
className="label-mono px-2.5 py-1 rounded-[6px] border-none cursor-pointer transition-colors
|
|
161
|
+
data-[active]:bg-accent-soft data-[active]:text-accent-bright
|
|
162
|
+
bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]"
|
|
163
|
+
>
|
|
164
|
+
{f}
|
|
165
|
+
</button>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
101
169
|
{(sortedAgents.length > 5 || search) && (
|
|
102
170
|
<div className="px-4 py-2.5">
|
|
103
171
|
<input
|
|
@@ -112,26 +180,27 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
|
|
|
112
180
|
</div>
|
|
113
181
|
)}
|
|
114
182
|
<div className="flex flex-col gap-0.5 px-2 pb-4">
|
|
115
|
-
{
|
|
183
|
+
{filteredAgents.map((agent) => {
|
|
116
184
|
const threadSession = agent.threadSessionId ? sessions[agent.threadSessionId] as Session | undefined : undefined
|
|
117
185
|
const lastMsg = threadSession?.messages?.at(-1)
|
|
118
186
|
const isActive = currentAgentId === agent.id
|
|
119
|
-
const
|
|
187
|
+
const heartbeatOn = agent.heartbeatEnabled === true && (agent.tools?.length ?? 0) > 0
|
|
188
|
+
const recentlyActive = (threadSession?.lastActiveAt ?? 0) > Date.now() - 30 * 60 * 1000
|
|
189
|
+
const isWorking = runningAgentIds.has(agent.id) || (threadSession?.active ?? false) || heartbeatOn || recentlyActive
|
|
120
190
|
const isTyping = streamingSessionId === agent.threadSessionId
|
|
121
191
|
const preview = lastMsg?.text?.slice(0, 80)?.replace(/\n/g, ' ') || ''
|
|
122
192
|
|
|
123
193
|
return (
|
|
124
|
-
<
|
|
194
|
+
<div
|
|
125
195
|
key={agent.id}
|
|
126
|
-
|
|
127
|
-
className={`w-full text-left py-3 px-3.5 rounded-[12px] cursor-pointer transition-all duration-150 border-none
|
|
196
|
+
ref={(el) => setRowRef(agent.id, el)}
|
|
197
|
+
className={`group/row relative w-full text-left py-3 px-3.5 rounded-[12px] cursor-pointer transition-all duration-150 border-none
|
|
128
198
|
${isActive
|
|
129
199
|
? 'bg-accent-soft/80 border border-accent-bright/20'
|
|
130
200
|
: 'bg-transparent hover:bg-white/[0.02]'}`}
|
|
131
|
-
|
|
201
|
+
onClick={() => handleSelect(agent)}
|
|
132
202
|
>
|
|
133
203
|
<div className="flex items-center gap-2.5">
|
|
134
|
-
{/* Avatar with status dot */}
|
|
135
204
|
<div className="relative shrink-0">
|
|
136
205
|
<AgentAvatar seed={agent.avatarSeed || null} name={agent.name} size={36} />
|
|
137
206
|
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-bg ${
|
|
@@ -146,6 +215,50 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
|
|
|
146
215
|
<span className="text-[10px] text-text-3/60 font-mono shrink-0">
|
|
147
216
|
{agent.model ? agent.model.split('/').pop()?.split(':')[0] : agent.provider}
|
|
148
217
|
</span>
|
|
218
|
+
{/* Set as default agent */}
|
|
219
|
+
{(() => {
|
|
220
|
+
const isDefault = appSettings.defaultAgentId === agent.id
|
|
221
|
+
return (
|
|
222
|
+
<button
|
|
223
|
+
onClick={async (e) => {
|
|
224
|
+
e.stopPropagation()
|
|
225
|
+
if (isDefault) {
|
|
226
|
+
await updateSettings({ defaultAgentId: null })
|
|
227
|
+
toast.success('Default agent cleared')
|
|
228
|
+
} else {
|
|
229
|
+
await updateSettings({ defaultAgentId: agent.id })
|
|
230
|
+
toast.success(`${agent.name} set as default`)
|
|
231
|
+
}
|
|
232
|
+
}}
|
|
233
|
+
aria-label={isDefault ? 'Remove as default' : 'Set as default agent'}
|
|
234
|
+
title={isDefault ? 'Default agent — click to clear' : 'Set as default agent'}
|
|
235
|
+
className={`shrink-0 p-1 rounded-[6px] transition-all bg-transparent border-none cursor-pointer hover:bg-white/[0.06]
|
|
236
|
+
${isDefault ? 'opacity-100 text-accent-bright' : 'opacity-0 group-hover/row:opacity-60 hover:!opacity-100 text-text-3'}`}
|
|
237
|
+
style={{ fontFamily: 'inherit' }}
|
|
238
|
+
>
|
|
239
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill={isDefault ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
240
|
+
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
241
|
+
{isDefault && <path d="M9 22V12h6v10" fill="rgba(0,0,0,0.3)" stroke="none" />}
|
|
242
|
+
</svg>
|
|
243
|
+
</button>
|
|
244
|
+
)
|
|
245
|
+
})()}
|
|
246
|
+
{/* Pin button — inline after model label */}
|
|
247
|
+
<button
|
|
248
|
+
onClick={(e) => {
|
|
249
|
+
e.stopPropagation()
|
|
250
|
+
togglePinAgent(agent.id)
|
|
251
|
+
toast.success(agent.pinned ? 'Agent unpinned' : 'Agent pinned')
|
|
252
|
+
}}
|
|
253
|
+
aria-label={agent.pinned ? 'Unpin agent' : 'Pin agent'}
|
|
254
|
+
className={`shrink-0 p-1 rounded-[6px] transition-all bg-transparent border-none cursor-pointer hover:bg-white/[0.06]
|
|
255
|
+
${agent.pinned ? 'opacity-100 text-amber-400' : 'opacity-0 group-hover/row:opacity-60 hover:!opacity-100 text-text-3'}`}
|
|
256
|
+
style={{ fontFamily: 'inherit' }}
|
|
257
|
+
>
|
|
258
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill={agent.pinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
259
|
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
260
|
+
</svg>
|
|
261
|
+
</button>
|
|
149
262
|
</div>
|
|
150
263
|
{isTyping ? (
|
|
151
264
|
<div className="text-[12px] text-accent-bright/70 mt-0.5 flex items-center gap-1.5">
|
|
@@ -163,7 +276,7 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
|
|
|
163
276
|
) : null}
|
|
164
277
|
</div>
|
|
165
278
|
</div>
|
|
166
|
-
</
|
|
279
|
+
</div>
|
|
167
280
|
)
|
|
168
281
|
})}
|
|
169
282
|
</div>
|
|
@@ -6,6 +6,8 @@ import { api } from '@/lib/api-client'
|
|
|
6
6
|
import { AgentCard } from './agent-card'
|
|
7
7
|
import { TrashList } from './trash-list'
|
|
8
8
|
import { useApprovalStore } from '@/stores/use-approval-store'
|
|
9
|
+
import { Skeleton } from '@/components/shared/skeleton'
|
|
10
|
+
import { EmptyState } from '@/components/shared/empty-state'
|
|
9
11
|
|
|
10
12
|
interface Props {
|
|
11
13
|
inSidebar?: boolean
|
|
@@ -49,7 +51,8 @@ export function AgentList({ inSidebar }: Props) {
|
|
|
49
51
|
} catch { /* ignore */ }
|
|
50
52
|
}, [mainSession, loadSessions])
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
const [loaded, setLoaded] = useState(Object.keys(agents).length > 0)
|
|
55
|
+
useEffect(() => { loadAgents().then(() => setLoaded(true)) }, [])
|
|
53
56
|
|
|
54
57
|
// Compute which agents are "running" (have active sessions)
|
|
55
58
|
const runningAgentIds = useMemo(() => {
|
|
@@ -60,6 +63,27 @@ export function AgentList({ inSidebar }: Props) {
|
|
|
60
63
|
return ids
|
|
61
64
|
}, [sessions])
|
|
62
65
|
|
|
66
|
+
// Re-evaluate online status periodically (Date.now() can't be called in useMemo directly)
|
|
67
|
+
const [now, setNow] = useState(() => Date.now())
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const id = setInterval(() => setNow(Date.now()), 60_000)
|
|
70
|
+
return () => clearInterval(id)
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
// Agents that are "online": heartbeat enabled + tools, or recently active (within 30min)
|
|
74
|
+
const onlineAgentIds = useMemo(() => {
|
|
75
|
+
const ids = new Set<string>()
|
|
76
|
+
const recentThreshold = now - 30 * 60 * 1000
|
|
77
|
+
for (const a of Object.values(agents)) {
|
|
78
|
+
if (a.heartbeatEnabled === true && (a.tools?.length ?? 0) > 0) { ids.add(a.id); continue }
|
|
79
|
+
// Check if any session for this agent was active in the last 30 minutes
|
|
80
|
+
for (const s of Object.values(sessions)) {
|
|
81
|
+
if (s.agentId === a.id && (s.lastActiveAt ?? 0) > recentThreshold) { ids.add(a.id); break }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return ids
|
|
85
|
+
}, [agents, sessions, now])
|
|
86
|
+
|
|
63
87
|
// Approval counts per agent
|
|
64
88
|
const approvalsByAgent = useMemo(() => {
|
|
65
89
|
const counts: Record<string, number> = {}
|
|
@@ -126,28 +150,35 @@ export function AgentList({ inSidebar }: Props) {
|
|
|
126
150
|
}
|
|
127
151
|
|
|
128
152
|
if (!filtered.length && !search) {
|
|
153
|
+
// Show skeleton cards while loading
|
|
154
|
+
if (!loaded) {
|
|
155
|
+
return (
|
|
156
|
+
<div className="flex-1 flex flex-col gap-1 px-2 pt-4">
|
|
157
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
158
|
+
<div key={i} className="py-3.5 px-4 rounded-[14px] border border-transparent">
|
|
159
|
+
<div className="flex items-center gap-2.5">
|
|
160
|
+
<Skeleton className="rounded-full" width={28} height={28} />
|
|
161
|
+
<Skeleton className="rounded-[6px]" width={120} height={14} />
|
|
162
|
+
</div>
|
|
163
|
+
<Skeleton className="rounded-[6px] mt-2" width="80%" height={12} />
|
|
164
|
+
<Skeleton className="rounded-[6px] mt-1.5" width={80} height={11} />
|
|
165
|
+
</div>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
129
170
|
return (
|
|
130
|
-
<
|
|
131
|
-
|
|
171
|
+
<EmptyState
|
|
172
|
+
icon={
|
|
132
173
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-accent-bright">
|
|
133
174
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
|
134
175
|
<circle cx="12" cy="7" r="4" />
|
|
135
176
|
</svg>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
{!inSidebar
|
|
140
|
-
|
|
141
|
-
onClick={() => setAgentSheetOpen(true)}
|
|
142
|
-
className="mt-3 px-8 py-3 rounded-[14px] border-none bg-[#6366F1] text-white
|
|
143
|
-
text-[14px] font-600 cursor-pointer active:scale-95 transition-all duration-200
|
|
144
|
-
shadow-[0_4px_16px_rgba(99,102,241,0.2)]"
|
|
145
|
-
style={{ fontFamily: 'inherit' }}
|
|
146
|
-
>
|
|
147
|
-
+ New Agent
|
|
148
|
-
</button>
|
|
149
|
-
)}
|
|
150
|
-
</div>
|
|
177
|
+
}
|
|
178
|
+
title="No agents yet"
|
|
179
|
+
subtitle="Create AI agents and orchestrators"
|
|
180
|
+
action={!inSidebar ? { label: '+ New Agent', onClick: () => setAgentSheetOpen(true) } : undefined}
|
|
181
|
+
/>
|
|
151
182
|
)
|
|
152
183
|
}
|
|
153
184
|
|
|
@@ -212,7 +243,7 @@ export function AgentList({ inSidebar }: Props) {
|
|
|
212
243
|
<div className="flex flex-col gap-1 px-2 pb-4">
|
|
213
244
|
{filtered.map((p) => (
|
|
214
245
|
<div key={p.id} ref={(el) => { if (el) cardRefs.current.set(p.id, el); else cardRefs.current.delete(p.id) }}>
|
|
215
|
-
<AgentCard agent={p} isDefault={p.id === defaultAgentId} isRunning={runningAgentIds.has(p.id)} isSelected={p.id === selectedAgentId} onSetDefault={handleSetDefault} />
|
|
246
|
+
<AgentCard agent={p} isDefault={p.id === defaultAgentId} isRunning={runningAgentIds.has(p.id)} isOnline={onlineAgentIds.has(p.id)} isSelected={p.id === selectedAgentId} onSetDefault={handleSetDefault} />
|
|
216
247
|
</div>
|
|
217
248
|
))}
|
|
218
249
|
</div>
|