@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
@@ -1,11 +1,14 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useCallback, useState } from 'react'
3
+ import { useEffect, useCallback, useState, useRef, useMemo } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { useWs } from '@/hooks/use-ws'
6
- import { updateTask } from '@/lib/tasks'
6
+ import { updateTask, bulkUpdateTasks } from '@/lib/tasks'
7
7
  import { TaskColumn } from './task-column'
8
+ import { Skeleton } from '@/components/shared/skeleton'
9
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
8
10
  import type { BoardTaskStatus } from '@/types'
11
+ import { toast } from 'sonner'
9
12
 
10
13
  const ACTIVE_COLUMNS: BoardTaskStatus[] = ['backlog', 'queued', 'running', 'completed', 'failed']
11
14
 
@@ -16,8 +19,98 @@ export function TaskBoard() {
16
19
  const setTaskSheetOpen = useAppStore((s) => s.setTaskSheetOpen)
17
20
  const setEditingTaskId = useAppStore((s) => s.setEditingTaskId)
18
21
  const agents = useAppStore((s) => s.agents)
22
+ const projects = useAppStore((s) => s.projects)
23
+ const loadProjects = useAppStore((s) => s.loadProjects)
24
+ const activeProjectFilter = useAppStore((s) => s.activeProjectFilter)
25
+ const setActiveProjectFilter = useAppStore((s) => s.setActiveProjectFilter)
19
26
  const showArchived = useAppStore((s) => s.showArchivedTasks)
20
27
  const setShowArchived = useAppStore((s) => s.setShowArchivedTasks)
28
+
29
+ // Bulk selection
30
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
31
+ const selectionMode = selectedIds.size > 0
32
+
33
+ const toggleSelect = useCallback((id: string) => {
34
+ setSelectedIds((prev) => {
35
+ const next = new Set(prev)
36
+ if (next.has(id)) next.delete(id)
37
+ else next.add(id)
38
+ return next
39
+ })
40
+ }, [])
41
+
42
+ const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
43
+
44
+ const selectAllInColumn = useCallback((status: BoardTaskStatus) => {
45
+ const ids = Object.values(tasks)
46
+ .filter((t) => t.status === status)
47
+ .map((t) => t.id)
48
+ setSelectedIds((prev) => {
49
+ const next = new Set(prev)
50
+ ids.forEach((id) => next.add(id))
51
+ return next
52
+ })
53
+ }, [tasks])
54
+
55
+ // Bulk action handlers
56
+ const [bulkActing, setBulkActing] = useState(false)
57
+ const handleBulkStatus = useCallback(async (status: BoardTaskStatus) => {
58
+ if (selectedIds.size === 0) return
59
+ setBulkActing(true)
60
+ try {
61
+ await bulkUpdateTasks([...selectedIds], { status })
62
+ await loadTasks()
63
+ toast.success(`Moved ${selectedIds.size} task(s) to ${status}`)
64
+ clearSelection()
65
+ } catch {
66
+ toast.error('Bulk update failed')
67
+ } finally {
68
+ setBulkActing(false)
69
+ }
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, [selectedIds])
72
+
73
+ const handleBulkAgent = useCallback(async (agentId: string) => {
74
+ if (selectedIds.size === 0) return
75
+ setBulkActing(true)
76
+ try {
77
+ await bulkUpdateTasks([...selectedIds], { agentId })
78
+ await loadTasks()
79
+ const name = agents[agentId]?.name || 'agent'
80
+ toast.success(`Assigned ${selectedIds.size} task(s) to ${name}`)
81
+ clearSelection()
82
+ } catch {
83
+ toast.error('Bulk assign failed')
84
+ } finally {
85
+ setBulkActing(false)
86
+ }
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
+ }, [selectedIds, agents])
89
+
90
+ const handleBulkProject = useCallback(async (projectId: string | null) => {
91
+ if (selectedIds.size === 0) return
92
+ setBulkActing(true)
93
+ try {
94
+ await bulkUpdateTasks([...selectedIds], { projectId })
95
+ await loadTasks()
96
+ toast.success(projectId ? `Assigned ${selectedIds.size} task(s) to project` : `Cleared project from ${selectedIds.size} task(s)`)
97
+ clearSelection()
98
+ } catch {
99
+ toast.error('Bulk assign failed')
100
+ } finally {
101
+ setBulkActing(false)
102
+ }
103
+ // eslint-disable-next-line react-hooks/exhaustive-deps
104
+ }, [selectedIds])
105
+
106
+ // Bulk action bar dropdowns
107
+ const [bulkAgentOpen, setBulkAgentOpen] = useState(false)
108
+ const [bulkProjectOpen, setBulkProjectOpen] = useState(false)
109
+ const [bulkStatusOpen, setBulkStatusOpen] = useState(false)
110
+ const bulkAgentRef = useRef<HTMLDivElement>(null)
111
+ const bulkProjectRef = useRef<HTMLDivElement>(null)
112
+ const bulkStatusRef = useRef<HTMLDivElement>(null)
113
+
21
114
  // URL-based filter state
22
115
  const [filterAgentId, setFilterAgentId] = useState<string>(() => {
23
116
  if (typeof window === 'undefined') return ''
@@ -28,18 +121,30 @@ export function TaskBoard() {
28
121
  return new URLSearchParams(window.location.search).get('tag') || ''
29
122
  })
30
123
 
124
+ // Seed activeProjectFilter from URL on mount
125
+ useEffect(() => {
126
+ if (typeof window === 'undefined') return
127
+ const urlProject = new URLSearchParams(window.location.search).get('project')
128
+ if (urlProject && !activeProjectFilter) {
129
+ setActiveProjectFilter(urlProject)
130
+ }
131
+ // eslint-disable-next-line react-hooks/exhaustive-deps
132
+ }, [])
133
+
31
134
  // Sync filters to URL
32
135
  useEffect(() => {
33
136
  if (typeof window === 'undefined') return
34
137
  const params = new URLSearchParams()
35
138
  if (filterAgentId) params.set('agent', filterAgentId)
36
139
  if (filterTag) params.set('tag', filterTag)
140
+ if (activeProjectFilter) params.set('project', activeProjectFilter)
37
141
  const qs = params.toString()
38
142
  const newUrl = `${window.location.pathname}${qs ? `?${qs}` : ''}`
39
143
  window.history.replaceState(null, '', newUrl)
40
- }, [filterAgentId, filterTag])
144
+ }, [filterAgentId, filterTag, activeProjectFilter])
41
145
 
42
- useEffect(() => { loadTasks(); loadAgents() }, [])
146
+ const [loaded, setLoaded] = useState(Object.keys(tasks).length > 0)
147
+ useEffect(() => { Promise.all([loadTasks(), loadAgents(), loadProjects()]).then(() => setLoaded(true)) }, [])
43
148
  useWs('tasks', loadTasks, 5000)
44
149
 
45
150
  // Collect all unique tags across tasks
@@ -47,42 +152,200 @@ export function TaskBoard() {
47
152
 
48
153
  const columns: BoardTaskStatus[] = showArchived ? [...ACTIVE_COLUMNS, 'archived'] : ACTIVE_COLUMNS
49
154
 
50
- const tasksByStatus = (status: BoardTaskStatus) =>
155
+ const tasksByStatus = useCallback((status: BoardTaskStatus) =>
51
156
  Object.values(tasks)
52
157
  .filter((t) => t.status === status
53
158
  && (!filterAgentId || t.agentId === filterAgentId)
54
- && (!filterTag || (t.tags && t.tags.includes(filterTag))))
55
- .sort((a, b) => b.updatedAt - a.updatedAt)
159
+ && (!filterTag || (t.tags && t.tags.includes(filterTag)))
160
+ && (!activeProjectFilter || t.projectId === activeProjectFilter))
161
+ .sort((a, b) => b.updatedAt - a.updatedAt),
162
+ [tasks, filterAgentId, filterTag, activeProjectFilter])
56
163
 
57
164
  const handleDrop = useCallback(async (taskId: string, newStatus: BoardTaskStatus) => {
58
165
  const task = tasks[taskId]
59
166
  if (!task || task.status === newStatus) return
60
167
  await updateTask(taskId, { status: newStatus })
61
168
  await loadTasks()
62
- }, [tasks, loadTasks])
169
+ // eslint-disable-next-line react-hooks/exhaustive-deps
170
+ }, [tasks])
63
171
 
64
172
  const archivedCount = Object.values(tasks).filter((t) => t.status === 'archived').length
65
173
 
174
+ // Task counts per project (non-archived)
175
+ const projectTaskCounts: Record<string, number> = {}
176
+ for (const t of Object.values(tasks)) {
177
+ if (t.projectId && t.status !== 'archived') {
178
+ projectTaskCounts[t.projectId] = (projectTaskCounts[t.projectId] || 0) + 1
179
+ }
180
+ }
181
+
182
+ // Summary stats
183
+ const stats = useMemo(() => {
184
+ const all = Object.values(tasks).filter((t) => t.status !== 'archived')
185
+ return {
186
+ total: all.length,
187
+ running: all.filter((t) => t.status === 'running').length,
188
+ completed: all.filter((t) => t.status === 'completed').length,
189
+ failed: all.filter((t) => t.status === 'failed').length,
190
+ overdue: all.filter((t) => t.dueAt && t.dueAt < Date.now() && t.status !== 'completed').length,
191
+ }
192
+ }, [tasks])
193
+
194
+ // Custom dropdown state
195
+ const [projectDropdownOpen, setProjectDropdownOpen] = useState(false)
196
+ const projectDropdownRef = useRef<HTMLDivElement>(null)
197
+ const [agentDropdownOpen, setAgentDropdownOpen] = useState(false)
198
+ const agentDropdownRef = useRef<HTMLDivElement>(null)
199
+ useEffect(() => {
200
+ if (!projectDropdownOpen && !agentDropdownOpen && !bulkAgentOpen && !bulkProjectOpen && !bulkStatusOpen) return
201
+ const onClickOutside = (e: MouseEvent) => {
202
+ if (projectDropdownOpen && projectDropdownRef.current && !projectDropdownRef.current.contains(e.target as Node)) {
203
+ setProjectDropdownOpen(false)
204
+ }
205
+ if (agentDropdownOpen && agentDropdownRef.current && !agentDropdownRef.current.contains(e.target as Node)) {
206
+ setAgentDropdownOpen(false)
207
+ }
208
+ if (bulkAgentOpen && bulkAgentRef.current && !bulkAgentRef.current.contains(e.target as Node)) {
209
+ setBulkAgentOpen(false)
210
+ }
211
+ if (bulkProjectOpen && bulkProjectRef.current && !bulkProjectRef.current.contains(e.target as Node)) {
212
+ setBulkProjectOpen(false)
213
+ }
214
+ if (bulkStatusOpen && bulkStatusRef.current && !bulkStatusRef.current.contains(e.target as Node)) {
215
+ setBulkStatusOpen(false)
216
+ }
217
+ }
218
+ document.addEventListener('mousedown', onClickOutside)
219
+ return () => document.removeEventListener('mousedown', onClickOutside)
220
+ }, [projectDropdownOpen, agentDropdownOpen, bulkAgentOpen, bulkProjectOpen, bulkStatusOpen])
221
+
222
+ // Escape key to clear selection
223
+ useEffect(() => {
224
+ if (!selectionMode) return
225
+ const handler = (e: KeyboardEvent) => {
226
+ if (e.key === 'Escape') clearSelection()
227
+ }
228
+ window.addEventListener('keydown', handler)
229
+ return () => window.removeEventListener('keydown', handler)
230
+ // eslint-disable-next-line react-hooks/exhaustive-deps
231
+ }, [selectionMode])
232
+
66
233
  return (
67
234
  <div className="flex-1 flex flex-col h-full overflow-hidden">
68
235
  <div className="flex items-center justify-between px-8 pt-6 pb-4 shrink-0">
69
236
  <div>
70
237
  <h1 className="font-display text-[28px] font-800 tracking-[-0.03em]">Task Board</h1>
71
- <p className="text-[13px] text-text-3 mt-1">Create tasks and assign agents to run them</p>
238
+ <div className="flex items-center gap-3 mt-1">
239
+ <p className="text-[13px] text-text-3">
240
+ {stats.total} task{stats.total !== 1 ? 's' : ''}
241
+ </p>
242
+ {stats.running > 0 && (
243
+ <span className="inline-flex items-center gap-1 text-[11px] font-600 text-blue-400">
244
+ <span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
245
+ {stats.running} running
246
+ </span>
247
+ )}
248
+ {stats.overdue > 0 && (
249
+ <span className="text-[11px] font-600 text-red-400">
250
+ {stats.overdue} overdue
251
+ </span>
252
+ )}
253
+ </div>
72
254
  </div>
73
255
  <div className="flex items-center gap-3">
74
- <select
75
- value={filterAgentId}
76
- onChange={(e) => setFilterAgentId(e.target.value)}
77
- className="px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border
78
- bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.03] appearance-none"
79
- style={{ fontFamily: 'inherit', minWidth: 130 }}
80
- >
81
- <option value="">All Agents</option>
82
- {Object.values(agents).map((a) => (
83
- <option key={a.id} value={a.id}>{a.name}</option>
84
- ))}
85
- </select>
256
+ <div className="relative" ref={agentDropdownRef}>
257
+ <button
258
+ onClick={() => setAgentDropdownOpen(!agentDropdownOpen)}
259
+ className={`flex items-center gap-2 px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border
260
+ ${filterAgentId
261
+ ? 'bg-white/[0.06] border-white/[0.1] text-text-2'
262
+ : 'bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.03]'}`}
263
+ style={{ fontFamily: 'inherit', minWidth: 130 }}
264
+ >
265
+ {filterAgentId && agents[filterAgentId] ? (
266
+ <>
267
+ <AgentAvatar seed={agents[filterAgentId].avatarSeed || null} name={agents[filterAgentId].name} size={18} />
268
+ {agents[filterAgentId].name}
269
+ </>
270
+ ) : 'All Agents'}
271
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" className="ml-auto opacity-50">
272
+ <path d="M2.5 4L5 6.5L7.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
273
+ </svg>
274
+ </button>
275
+ {agentDropdownOpen && (
276
+ <div className="absolute top-full right-0 mt-1 min-w-[200px] py-1 rounded-[12px] border border-white/[0.08] bg-surface-2 shadow-lg z-50">
277
+ <button
278
+ onClick={() => { setFilterAgentId(''); setAgentDropdownOpen(false) }}
279
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-600 cursor-pointer border-none text-left transition-colors
280
+ ${!filterAgentId ? 'bg-white/[0.06] text-text' : 'bg-transparent text-text-3 hover:bg-white/[0.04]'}`}
281
+ style={{ fontFamily: 'inherit' }}
282
+ >
283
+ All Agents
284
+ </button>
285
+ {Object.values(agents).sort((a, b) => a.name.localeCompare(b.name)).map((a) => (
286
+ <button
287
+ key={a.id}
288
+ onClick={() => { setFilterAgentId(a.id); setAgentDropdownOpen(false) }}
289
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-600 cursor-pointer border-none text-left transition-colors
290
+ ${filterAgentId === a.id ? 'bg-white/[0.06] text-text' : 'bg-transparent text-text-3 hover:bg-white/[0.04]'}`}
291
+ style={{ fontFamily: 'inherit' }}
292
+ >
293
+ <AgentAvatar seed={a.avatarSeed || null} name={a.name} size={20} />
294
+ {a.name}
295
+ </button>
296
+ ))}
297
+ </div>
298
+ )}
299
+ </div>
300
+ {Object.keys(projects).length > 0 && (
301
+ <div className="relative" ref={projectDropdownRef}>
302
+ <button
303
+ onClick={() => setProjectDropdownOpen(!projectDropdownOpen)}
304
+ className={`flex items-center gap-2 px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border
305
+ ${activeProjectFilter
306
+ ? 'bg-white/[0.06] border-white/[0.1] text-text-2'
307
+ : 'bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.03]'}`}
308
+ style={{ fontFamily: 'inherit', minWidth: 130 }}
309
+ >
310
+ {activeProjectFilter && projects[activeProjectFilter] ? (
311
+ <>
312
+ <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: projects[activeProjectFilter].color || '#6366F1' }} />
313
+ {projects[activeProjectFilter].name}
314
+ </>
315
+ ) : 'All Projects'}
316
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" className="ml-auto opacity-50">
317
+ <path d="M2.5 4L5 6.5L7.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
318
+ </svg>
319
+ </button>
320
+ {projectDropdownOpen && (
321
+ <div className="absolute top-full right-0 mt-1 min-w-[180px] py-1 rounded-[12px] border border-white/[0.08] bg-surface-2 shadow-lg z-50">
322
+ <button
323
+ onClick={() => { setActiveProjectFilter(null); setProjectDropdownOpen(false) }}
324
+ className={`w-full flex items-center gap-2 px-3 py-2 text-[13px] font-600 cursor-pointer border-none text-left transition-colors
325
+ ${!activeProjectFilter ? 'bg-white/[0.06] text-text' : 'bg-transparent text-text-3 hover:bg-white/[0.04]'}`}
326
+ style={{ fontFamily: 'inherit' }}
327
+ >
328
+ All Projects
329
+ </button>
330
+ {Object.values(projects).map((p) => (
331
+ <button
332
+ key={p.id}
333
+ onClick={() => { setActiveProjectFilter(p.id); setProjectDropdownOpen(false) }}
334
+ className={`w-full flex items-center gap-2 px-3 py-2 text-[13px] font-600 cursor-pointer border-none text-left transition-colors
335
+ ${activeProjectFilter === p.id ? 'bg-white/[0.06] text-text' : 'bg-transparent text-text-3 hover:bg-white/[0.04]'}`}
336
+ style={{ fontFamily: 'inherit' }}
337
+ >
338
+ <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: p.color || '#6366F1' }} />
339
+ {p.name}
340
+ {(projectTaskCounts[p.id] ?? 0) > 0 && (
341
+ <span className="ml-auto text-[11px] text-text-3/60">{projectTaskCounts[p.id]}</span>
342
+ )}
343
+ </button>
344
+ ))}
345
+ </div>
346
+ )}
347
+ </div>
348
+ )}
86
349
  {allTags.length > 0 && (
87
350
  <select
88
351
  value={filterTag}
@@ -105,14 +368,14 @@ export function TaskBoard() {
105
368
  : 'bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.03]'}`}
106
369
  style={{ fontFamily: 'inherit' }}
107
370
  >
108
- {showArchived ? 'Hide' : 'Show'} Archived{!showArchived && archivedCount > 0 ? '' : ''}
371
+ {showArchived ? 'Hide' : 'Show'} Archived{!showArchived && archivedCount > 0 ? ` (${archivedCount})` : ''}
109
372
  </button>
110
373
  <button
111
374
  onClick={() => {
112
375
  setEditingTaskId(null)
113
376
  setTaskSheetOpen(true)
114
377
  }}
115
- className="px-5 py-2.5 rounded-[12px] border-none bg-[#6366F1] text-white text-[14px] font-600 cursor-pointer
378
+ className="px-5 py-2.5 rounded-[12px] border-none bg-accent-bright text-white text-[14px] font-600 cursor-pointer
116
379
  hover:brightness-110 active:scale-[0.97] transition-all shadow-[0_2px_12px_rgba(99,102,241,0.2)]"
117
380
  style={{ fontFamily: 'inherit' }}
118
381
  >
@@ -121,11 +384,170 @@ export function TaskBoard() {
121
384
  </div>
122
385
  </div>
123
386
 
387
+ {activeProjectFilter && projects[activeProjectFilter] && (
388
+ <div className="flex items-center gap-2 px-8 pb-3">
389
+ <span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] bg-white/[0.04] border border-white/[0.06] text-[12px] font-600 text-text-2">
390
+ <span className="w-2 h-2 rounded-full" style={{ backgroundColor: projects[activeProjectFilter].color || '#6366F1' }} />
391
+ {projects[activeProjectFilter].name}
392
+ <button
393
+ onClick={() => setActiveProjectFilter(null)}
394
+ className="ml-1 text-text-3 hover:text-text cursor-pointer border-none bg-transparent p-0 text-[14px] leading-none"
395
+ >
396
+ &times;
397
+ </button>
398
+ </span>
399
+ </div>
400
+ )}
401
+
124
402
  <div className="flex-1 flex gap-5 px-8 pb-6 overflow-x-auto overflow-y-hidden">
125
- {columns.map((status) => (
126
- <TaskColumn key={status} status={status} tasks={tasksByStatus(status)} onDrop={handleDrop} />
127
- ))}
403
+ {!loaded ? (
404
+ ACTIVE_COLUMNS.map((status) => (
405
+ <div key={status} className="flex flex-col gap-3 min-w-[260px] flex-1">
406
+ <Skeleton className="rounded-[10px]" width="100%" height={32} />
407
+ {Array.from({ length: 2 }).map((_, i) => (
408
+ <Skeleton key={i} className="rounded-[12px]" width="100%" height={80} />
409
+ ))}
410
+ </div>
411
+ ))
412
+ ) : (
413
+ columns.map((status) => (
414
+ <TaskColumn
415
+ key={status}
416
+ status={status}
417
+ tasks={tasksByStatus(status)}
418
+ onDrop={handleDrop}
419
+ selectionMode={selectionMode}
420
+ selectedIds={selectedIds}
421
+ onToggleSelect={toggleSelect}
422
+ onSelectAll={() => selectAllInColumn(status)}
423
+ />
424
+ ))
425
+ )}
128
426
  </div>
427
+
428
+ {/* Bulk action bar */}
429
+ {selectionMode && (
430
+ <div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-3 rounded-[16px] bg-surface-2/95 backdrop-blur-xl border border-white/[0.1] shadow-[0_8px_40px_rgba(0,0,0,0.5)] z-50">
431
+ <span className="text-[13px] font-600 text-text mr-2">
432
+ {selectedIds.size} selected
433
+ </span>
434
+ <div className="w-px h-5 bg-white/[0.08]" />
435
+
436
+ {/* Move to status */}
437
+ <div className="relative" ref={bulkStatusRef}>
438
+ <button
439
+ onClick={() => { setBulkStatusOpen(!bulkStatusOpen); setBulkAgentOpen(false); setBulkProjectOpen(false) }}
440
+ disabled={bulkActing}
441
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] text-[12px] font-600 text-text-2 bg-white/[0.06] border-none cursor-pointer hover:bg-white/[0.1] transition-colors disabled:opacity-50"
442
+ style={{ fontFamily: 'inherit' }}
443
+ >
444
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
445
+ Move
446
+ </button>
447
+ {bulkStatusOpen && (
448
+ <div className="absolute bottom-full left-0 mb-1 min-w-[140px] py-1 rounded-[10px] border border-white/[0.08] bg-surface-2 shadow-lg">
449
+ {ACTIVE_COLUMNS.map((s) => (
450
+ <button
451
+ key={s}
452
+ onClick={() => { handleBulkStatus(s); setBulkStatusOpen(false) }}
453
+ className="w-full flex items-center gap-2 px-3 py-2 text-[12px] font-600 cursor-pointer border-none text-left bg-transparent text-text-3 hover:bg-white/[0.06] hover:text-text transition-colors"
454
+ style={{ fontFamily: 'inherit' }}
455
+ >
456
+ {s.charAt(0).toUpperCase() + s.slice(1)}
457
+ </button>
458
+ ))}
459
+ </div>
460
+ )}
461
+ </div>
462
+
463
+ {/* Assign agent */}
464
+ <div className="relative" ref={bulkAgentRef}>
465
+ <button
466
+ onClick={() => { setBulkAgentOpen(!bulkAgentOpen); setBulkStatusOpen(false); setBulkProjectOpen(false) }}
467
+ disabled={bulkActing}
468
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] text-[12px] font-600 text-text-2 bg-white/[0.06] border-none cursor-pointer hover:bg-white/[0.1] transition-colors disabled:opacity-50"
469
+ style={{ fontFamily: 'inherit' }}
470
+ >
471
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /></svg>
472
+ Agent
473
+ </button>
474
+ {bulkAgentOpen && (
475
+ <div className="absolute bottom-full left-0 mb-1 min-w-[180px] max-h-[200px] overflow-y-auto py-1 rounded-[10px] border border-white/[0.08] bg-surface-2 shadow-lg">
476
+ {Object.values(agents).sort((a, b) => a.name.localeCompare(b.name)).map((a) => (
477
+ <button
478
+ key={a.id}
479
+ onClick={() => { handleBulkAgent(a.id); setBulkAgentOpen(false) }}
480
+ className="w-full flex items-center gap-2 px-3 py-2 text-[12px] font-600 cursor-pointer border-none text-left bg-transparent text-text-3 hover:bg-white/[0.06] hover:text-text transition-colors"
481
+ style={{ fontFamily: 'inherit' }}
482
+ >
483
+ <AgentAvatar seed={a.avatarSeed || null} name={a.name} size={16} />
484
+ {a.name}
485
+ </button>
486
+ ))}
487
+ </div>
488
+ )}
489
+ </div>
490
+
491
+ {/* Assign project */}
492
+ {Object.keys(projects).length > 0 && (
493
+ <div className="relative" ref={bulkProjectRef}>
494
+ <button
495
+ onClick={() => { setBulkProjectOpen(!bulkProjectOpen); setBulkStatusOpen(false); setBulkAgentOpen(false) }}
496
+ disabled={bulkActing}
497
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] text-[12px] font-600 text-text-2 bg-white/[0.06] border-none cursor-pointer hover:bg-white/[0.1] transition-colors disabled:opacity-50"
498
+ style={{ fontFamily: 'inherit' }}
499
+ >
500
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-7-7H4a2 2 0 0 0-2 2v17Z" /><path d="M14 2v7h7" /></svg>
501
+ Project
502
+ </button>
503
+ {bulkProjectOpen && (
504
+ <div className="absolute bottom-full left-0 mb-1 min-w-[160px] max-h-[200px] overflow-y-auto py-1 rounded-[10px] border border-white/[0.08] bg-surface-2 shadow-lg">
505
+ <button
506
+ onClick={() => { handleBulkProject(null); setBulkProjectOpen(false) }}
507
+ className="w-full flex items-center gap-2 px-3 py-2 text-[12px] font-600 cursor-pointer border-none text-left bg-transparent text-text-3 hover:bg-white/[0.06] hover:text-text transition-colors"
508
+ style={{ fontFamily: 'inherit' }}
509
+ >
510
+ No project
511
+ </button>
512
+ {Object.values(projects).map((p) => (
513
+ <button
514
+ key={p.id}
515
+ onClick={() => { handleBulkProject(p.id); setBulkProjectOpen(false) }}
516
+ className="w-full flex items-center gap-2 px-3 py-2 text-[12px] font-600 cursor-pointer border-none text-left bg-transparent text-text-3 hover:bg-white/[0.06] hover:text-text transition-colors"
517
+ style={{ fontFamily: 'inherit' }}
518
+ >
519
+ <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: p.color || '#6366F1' }} />
520
+ {p.name}
521
+ </button>
522
+ ))}
523
+ </div>
524
+ )}
525
+ </div>
526
+ )}
527
+
528
+ <div className="w-px h-5 bg-white/[0.08]" />
529
+
530
+ {/* Archive selected */}
531
+ <button
532
+ onClick={() => handleBulkStatus('archived')}
533
+ disabled={bulkActing}
534
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] text-[12px] font-600 text-amber-400 bg-amber-500/10 border-none cursor-pointer hover:bg-amber-500/20 transition-colors disabled:opacity-50"
535
+ style={{ fontFamily: 'inherit' }}
536
+ >
537
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M21 8v13H3V8" /><path d="M1 3h22v5H1z" /><path d="M10 12h4" /></svg>
538
+ Archive
539
+ </button>
540
+
541
+ {/* Clear selection */}
542
+ <button
543
+ onClick={clearSelection}
544
+ className="p-1.5 rounded-[8px] text-text-3 hover:text-text hover:bg-white/[0.06] border-none bg-transparent cursor-pointer transition-colors"
545
+ title="Clear selection (Esc)"
546
+ >
547
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
548
+ </button>
549
+ </div>
550
+ )}
129
551
  </div>
130
552
  )
131
553
  }
@@ -14,8 +14,16 @@ function timeAgo(ts: number) {
14
14
  return `${Math.floor(diff / 86400_000)}d ago`
15
15
  }
16
16
 
17
- export function TaskCard({ task }: { task: BoardTask }) {
17
+ interface TaskCardProps {
18
+ task: BoardTask
19
+ selectionMode?: boolean
20
+ selected?: boolean
21
+ onToggleSelect?: (id: string) => void
22
+ }
23
+
24
+ export function TaskCard({ task, selectionMode, selected, onToggleSelect }: TaskCardProps) {
18
25
  const agents = useAppStore((s) => s.agents)
26
+ const projects = useAppStore((s) => s.projects)
19
27
  const setEditingTaskId = useAppStore((s) => s.setEditingTaskId)
20
28
  const setTaskSheetOpen = useAppStore((s) => s.setTaskSheetOpen)
21
29
  const loadTasks = useAppStore((s) => s.loadTasks)
@@ -24,6 +32,7 @@ export function TaskCard({ task }: { task: BoardTask }) {
24
32
  const [dragging, setDragging] = useState(false)
25
33
 
26
34
  const agent = agents[task.agentId]
35
+ const project = task.projectId ? projects[task.projectId] : null
27
36
 
28
37
  const isBlocked = Array.isArray(task.blockedBy) && task.blockedBy.length > 0
29
38
  const isOverdue = task.dueAt && task.dueAt < Date.now() && task.status !== 'completed' && task.status !== 'archived'
@@ -65,17 +74,39 @@ export function TaskCard({ task }: { task: BoardTask }) {
65
74
 
66
75
  return (
67
76
  <div
68
- draggable
69
- onDragStart={handleDragStart}
70
- onDragEnd={handleDragEnd}
71
- onClick={() => {
72
- setEditingTaskId(task.id)
73
- setTaskSheetOpen(true)
77
+ draggable={!selectionMode}
78
+ onDragStart={selectionMode ? undefined : handleDragStart}
79
+ onDragEnd={selectionMode ? undefined : handleDragEnd}
80
+ onClick={(e) => {
81
+ if (selectionMode && onToggleSelect) {
82
+ e.stopPropagation()
83
+ onToggleSelect(task.id)
84
+ } else {
85
+ setEditingTaskId(task.id)
86
+ setTaskSheetOpen(true)
87
+ }
74
88
  }}
75
- className={`p-4 rounded-[14px] border border-white/[0.06] border-l-[3px] ${borderColor} bg-surface hover:bg-surface-2 cursor-grab active:cursor-grabbing
76
- transition-all group ${dragging ? 'opacity-40 scale-[0.97]' : ''}`}
89
+ className={`p-4 rounded-[14px] border border-l-[3px] ${borderColor} bg-surface hover:bg-surface-2 transition-all group
90
+ ${selectionMode ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'}
91
+ ${dragging ? 'opacity-40 scale-[0.97]' : ''}
92
+ ${selected ? 'border-accent-bright/40 bg-accent-bright/[0.04] ring-1 ring-accent-bright/20' : 'border-white/[0.06]'}`}
77
93
  >
78
94
  <div className="flex items-start gap-3 mb-3">
95
+ {/* Selection checkbox */}
96
+ {(selectionMode || selected) && (
97
+ <button
98
+ onClick={(e) => { e.stopPropagation(); onToggleSelect?.(task.id) }}
99
+ className={`w-5 h-5 rounded-[6px] border-2 flex items-center justify-center shrink-0 mt-0.5 cursor-pointer transition-all
100
+ ${selected
101
+ ? 'bg-accent-bright border-accent-bright'
102
+ : 'bg-transparent border-white/[0.2] hover:border-white/[0.4]'}`}
103
+ style={{ padding: 0, fontFamily: 'inherit' }}
104
+ >
105
+ {selected && (
106
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round"><path d="M20 6L9 17l-5-5" /></svg>
107
+ )}
108
+ </button>
109
+ )}
79
110
  {isBlocked && (
80
111
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-rose-400 shrink-0 mt-0.5">
81
112
  <title>{`Blocked by ${task.blockedBy?.length} task(s)`}</title>
@@ -152,6 +183,12 @@ export function TaskCard({ task }: { task: BoardTask }) {
152
183
  {agent.name}
153
184
  </span>
154
185
  )}
186
+ {project && (
187
+ <span className="inline-flex items-center gap-1 px-2 py-1 rounded-[6px] bg-white/[0.04] text-text-2 text-[11px] font-600">
188
+ <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: project.color || '#6366F1' }} />
189
+ {project.name}
190
+ </span>
191
+ )}
155
192
  <span className="text-[11px] text-text-3">{timeAgo(task.updatedAt)}</span>
156
193
  {task.comments && task.comments.length > 0 && (
157
194
  <span className="flex items-center gap-1 text-[11px] text-text-3">