@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
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
- export function AgentAvatar({ seed, name, size = 32, className = '' }: Props) {
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
- className={`shrink-0 rounded-full overflow-hidden ${className}`}
23
- style={{ width: size, height: size }}
24
- dangerouslySetInnerHTML={{ __html: svgHtml }}
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 rounded-full flex items-center justify-center bg-accent-soft text-accent-bright font-600 ${className}`}
40
- style={{ width: size, height: size, fontSize: size * 0.38 }}
79
+ className={`relative shrink-0 ${className}`}
80
+ style={{ width: size, height: size }}
41
81
  >
42
- {initials || '?'}
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 seed={agent.avatarSeed} name={agent.name} size={28} />
130
- {isRunning && (
131
- <span className="shrink-0 w-2 h-2 rounded-full bg-emerald-400" style={{ animation: 'pulse 2s ease infinite' }} title="Running" />
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-[#6366F1]/20 text-[#818CF8] hover:bg-[#6366F1]/30 disabled:opacity-40"
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
- orch
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
- {(agent as any).lastUsedAt ? (
204
+ {agent.lastUsedAt ? (
172
205
  <span>Last used: {(() => {
173
- const days = Math.floor((Date.now() - (agent as any).lastUsedAt) / 86400000)
206
+ const days = Math.floor((Date.now() - agent.lastUsedAt) / 86400000)
174
207
  return days === 0 ? 'today' : `${days}d ago`
175
208
  })()}</span>
176
- ) : (agent as any).updatedAt ? (
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
- {(agent as any).totalCost != null && (agent as any).totalCost > 0 && (
183
- <span>Cost: ${((agent as any).totalCost as number).toFixed(2)}</span>
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-[#6366F1] text-white text-[13px] font-600 cursor-pointer disabled:opacity-30 transition-all hover:brightness-110"
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-[#6366F1] text-white
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
- {sortedAgents.map((agent) => {
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 isWorking = runningAgentIds.has(agent.id) || (threadSession?.active ?? false) || (threadSession?.heartbeatEnabled ?? false)
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
- <button
194
+ <div
125
195
  key={agent.id}
126
- onClick={() => handleSelect(agent)}
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
- style={{ fontFamily: 'inherit' }}
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
- </button>
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
- useEffect(() => { loadAgents() }, [])
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
- <div className="flex-1 flex flex-col items-center justify-center gap-4 text-text-3 p-8 text-center">
131
- <div className="w-12 h-12 rounded-[14px] bg-accent-soft flex items-center justify-center mb-1">
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
- </div>
137
- <p className="font-display text-[15px] font-600 text-text-2">No agents yet</p>
138
- <p className="text-[13px] text-text-3/50">Create AI agents and orchestrators</p>
139
- {!inSidebar && (
140
- <button
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>