@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
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useState, useCallback } from 'react'
4
4
  import { TaskCard } from './task-card'
5
+ import { createTask } from '@/lib/tasks'
6
+ import { useAppStore } from '@/stores/use-app-store'
5
7
  import type { BoardTask, BoardTaskStatus } from '@/types'
6
8
 
7
9
  const COLUMN_CONFIG: Record<BoardTaskStatus, { label: string; color: string; dot: string }> = {
@@ -17,11 +19,18 @@ interface Props {
17
19
  status: BoardTaskStatus
18
20
  tasks: BoardTask[]
19
21
  onDrop: (taskId: string, newStatus: BoardTaskStatus) => void
22
+ selectionMode?: boolean
23
+ selectedIds?: Set<string>
24
+ onToggleSelect?: (id: string) => void
25
+ onSelectAll?: () => void
20
26
  }
21
27
 
22
- export function TaskColumn({ status, tasks, onDrop }: Props) {
28
+ export function TaskColumn({ status, tasks, onDrop, selectionMode, selectedIds, onToggleSelect, onSelectAll }: Props) {
23
29
  const config = COLUMN_CONFIG[status]
24
30
  const [dragOver, setDragOver] = useState(false)
31
+ const [quickAddValue, setQuickAddValue] = useState('')
32
+ const [adding, setAdding] = useState(false)
33
+ const loadTasks = useAppStore((s) => s.loadTasks)
25
34
 
26
35
  const handleDragOver = useCallback((e: React.DragEvent) => {
27
36
  e.preventDefault()
@@ -42,6 +51,21 @@ export function TaskColumn({ status, tasks, onDrop }: Props) {
42
51
  }
43
52
  }, [onDrop, status])
44
53
 
54
+ const handleQuickAdd = async () => {
55
+ const title = quickAddValue.trim()
56
+ if (!title || adding) return
57
+ setAdding(true)
58
+ try {
59
+ await createTask({ title, description: '', agentId: '', status })
60
+ await loadTasks()
61
+ setQuickAddValue('')
62
+ } finally {
63
+ setAdding(false)
64
+ }
65
+ }
66
+
67
+ const selectedCount = tasks.filter((t) => selectedIds?.has(t.id)).length
68
+
45
69
  return (
46
70
  <div
47
71
  className={`flex-1 min-w-[240px] max-w-[320px] flex flex-col rounded-[16px] transition-colors duration-150 ${
@@ -51,14 +75,49 @@ export function TaskColumn({ status, tasks, onDrop }: Props) {
51
75
  onDragLeave={handleDragLeave}
52
76
  onDrop={handleDrop}
53
77
  >
54
- <div className="flex items-center gap-2.5 px-2 mb-4">
78
+ <div className="flex items-center gap-2.5 px-2 mb-3">
55
79
  <div className={`w-2 h-2 rounded-full ${config.dot}`} />
56
80
  <span className={`font-display text-[13px] font-600 ${config.color}`}>{config.label}</span>
57
81
  <span className="text-[12px] text-text-3 ml-auto">{tasks.length}</span>
82
+ {selectionMode && tasks.length > 0 && (
83
+ <button
84
+ onClick={onSelectAll}
85
+ className={`text-[10px] font-600 px-1.5 py-0.5 rounded-[5px] cursor-pointer border-none transition-colors
86
+ ${selectedCount === tasks.length && selectedCount > 0
87
+ ? 'bg-accent-bright/20 text-accent-bright'
88
+ : 'bg-white/[0.04] text-text-3 hover:bg-white/[0.08]'}`}
89
+ style={{ fontFamily: 'inherit' }}
90
+ >
91
+ {selectedCount === tasks.length && selectedCount > 0 ? 'All' : 'Select all'}
92
+ </button>
93
+ )}
58
94
  </div>
95
+
96
+ {/* Quick add input */}
97
+ {(status === 'backlog' || status === 'queued') && (
98
+ <div className="px-1 mb-2">
99
+ <input
100
+ type="text"
101
+ value={quickAddValue}
102
+ onChange={(e) => setQuickAddValue(e.target.value)}
103
+ onKeyDown={(e) => { if (e.key === 'Enter') handleQuickAdd() }}
104
+ placeholder={`+ Add to ${config.label.toLowerCase()}...`}
105
+ className="w-full px-3 py-2 rounded-[10px] bg-white/[0.02] border border-dashed border-white/[0.08] text-[12px] text-text placeholder:text-text-3/30 outline-none focus:border-white/[0.15] focus:bg-white/[0.03] transition-colors"
106
+ style={{ fontFamily: 'inherit' }}
107
+ disabled={adding}
108
+ />
109
+ </div>
110
+ )}
111
+
59
112
  <div className="flex flex-col gap-3 flex-1 overflow-y-auto pr-1 px-1 pb-2">
60
113
  {tasks.map((task) => (
61
- <TaskCard key={task.id} task={task} />
114
+ <TaskCard
115
+ key={task.id}
116
+ task={task}
117
+ selectionMode={selectionMode}
118
+ selected={selectedIds?.has(task.id)}
119
+ onToggleSelect={onToggleSelect}
120
+ />
62
121
  ))}
63
122
  {tasks.length === 0 && (
64
123
  <div className={`text-[12px] text-text-3/50 text-center py-8 rounded-[12px] border border-dashed transition-colors ${
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/use-app-store'
5
5
  import { useWs } from '@/hooks/use-ws'
6
6
  import { api } from '@/lib/api-client'
7
7
  import type { BoardTaskStatus } from '@/types'
8
+ import { EmptyState } from '@/components/shared/empty-state'
8
9
 
9
10
  const STATUS_DOT: Record<BoardTaskStatus, string> = {
10
11
  backlog: 'bg-white/20',
@@ -88,9 +89,16 @@ export function TaskList({ inSidebar }: { inSidebar?: boolean }) {
88
89
  )}
89
90
 
90
91
  {filtered.length === 0 && (
91
- <div className="text-center text-text-3 text-[13px] py-12 px-6">
92
- {sorted.length === 0 ? 'No tasks yet' : 'No matching tasks'}
93
- </div>
92
+ <EmptyState
93
+ icon={
94
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="text-accent-bright">
95
+ <rect x="3" y="3" width="18" height="18" rx="2" fill="currentColor" opacity="0.2" />
96
+ <path d="M9 11l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
97
+ </svg>
98
+ }
99
+ title={sorted.length === 0 ? 'No tasks yet' : 'No matching tasks'}
100
+ subtitle={sorted.length === 0 ? 'Create tasks and assign agents to run them' : 'Try adjusting your search'}
101
+ />
94
102
  )}
95
103
  {filtered.map((task) => {
96
104
  const agent = agents[task.agentId]
@@ -101,7 +109,7 @@ export function TaskList({ inSidebar }: { inSidebar?: boolean }) {
101
109
  setEditingTaskId(task.id)
102
110
  setTaskSheetOpen(true)
103
111
  }}
104
- className="w-full text-left px-5 py-3.5 border-none bg-transparent cursor-pointer hover:bg-white/[0.03] transition-all"
112
+ className="w-full text-left py-3.5 px-4 rounded-[14px] border border-transparent bg-transparent cursor-pointer hover:bg-white/[0.03] transition-all"
105
113
  style={{ fontFamily: 'inherit' }}
106
114
  >
107
115
  <div className="flex items-center gap-2.5">
@@ -4,8 +4,12 @@ import { useEffect, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { createTask, updateTask, archiveTask, unarchiveTask } from '@/lib/tasks'
6
6
  import { BottomSheet } from '@/components/shared/bottom-sheet'
7
+ import { AgentPickerList } from '@/components/shared/agent-picker-list'
7
8
  import { DirBrowser } from '@/components/shared/dir-browser'
9
+ import { SheetFooter } from '@/components/shared/sheet-footer'
10
+ import { inputClass } from '@/components/shared/form-styles'
8
11
  import type { BoardTask, TaskComment } from '@/types'
12
+ import { SectionLabel } from '@/components/shared/section-label'
9
13
 
10
14
  function fmtTime(ts: number) {
11
15
  const d = new Date(ts)
@@ -25,6 +29,10 @@ export function TaskSheet() {
25
29
  const agents = useAppStore((s) => s.agents)
26
30
  const loadAgents = useAppStore((s) => s.loadAgents)
27
31
 
32
+ const projects = useAppStore((s) => s.projects)
33
+ const loadProjects = useAppStore((s) => s.loadProjects)
34
+ const activeProjectFilter = useAppStore((s) => s.activeProjectFilter)
35
+
28
36
  const appSettings = useAppStore((s) => s.appSettings)
29
37
  const loadSettings = useAppStore((s) => s.loadSettings)
30
38
 
@@ -36,6 +44,7 @@ export function TaskSheet() {
36
44
  const [uploading, setUploading] = useState(false)
37
45
  const [cwd, setCwd] = useState('')
38
46
  const [file, setFile] = useState<string | null>(null)
47
+ const [projectId, setProjectId] = useState('')
39
48
  const [tags, setTags] = useState<string[]>([])
40
49
  const [tagInput, setTagInput] = useState('')
41
50
  const [blockedBy, setBlockedBy] = useState<string[]>([])
@@ -43,16 +52,18 @@ export function TaskSheet() {
43
52
  const [customFields, setCustomFields] = useState<Record<string, string | number | boolean>>({})
44
53
 
45
54
  const editing = editingId ? tasks[editingId] : null
46
- const agentList = Object.values(agents)
55
+ const agentList = Object.values(agents).sort((a, b) => a.name.localeCompare(b.name))
47
56
 
48
57
  useEffect(() => {
49
58
  if (open) {
50
59
  loadAgents()
60
+ loadProjects()
51
61
  loadSettings()
52
62
  if (editing) {
53
63
  setTitle(editing.title)
54
64
  setDescription(editing.description)
55
65
  setAgentId(editing.agentId)
66
+ setProjectId(editing.projectId || '')
56
67
  setImages(editing.images || [])
57
68
  setCwd(editing.cwd || '')
58
69
  setFile(editing.file || null)
@@ -64,6 +75,7 @@ export function TaskSheet() {
64
75
  setTitle('')
65
76
  setDescription('')
66
77
  setAgentId(agentList[0]?.id || '')
78
+ setProjectId(activeProjectFilter || '')
67
79
  setImages([])
68
80
  setCwd('')
69
81
  setFile(null)
@@ -89,12 +101,14 @@ export function TaskSheet() {
89
101
  }
90
102
 
91
103
  const handleSave = async () => {
92
- const payload: Partial<BoardTask> & { title: string; description: string; agentId: string } = {
93
- title: title.trim() || 'Untitled Task', description, agentId, images,
104
+ // projectId uses null (not undefined) so the API can distinguish "clear" from "not sent"
105
+ // projectId uses null (not undefined) so the API can distinguish "clear" from "not sent"
106
+ const payload = {
107
+ title: title.trim() || 'Untitled Task', description, agentId, projectId: projectId || null, images,
94
108
  cwd: cwd || undefined, file: file || undefined,
95
109
  tags, blockedBy, dueAt: dueAt ? new Date(dueAt).getTime() : null,
96
110
  customFields: Object.keys(customFields).length > 0 ? customFields : undefined,
97
- }
111
+ } as Partial<BoardTask> & { title: string; description: string; agentId: string }
98
112
  if (editing) {
99
113
  await updateTask(editing.id, payload)
100
114
  } else {
@@ -159,8 +173,6 @@ export function TaskSheet() {
159
173
  setCommentText('')
160
174
  }
161
175
 
162
- const inputClass = "w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow"
163
-
164
176
  return (
165
177
  <BottomSheet open={open} onClose={onClose}>
166
178
  <div className="mb-10">
@@ -173,7 +185,7 @@ export function TaskSheet() {
173
185
  </div>
174
186
 
175
187
  <div className="mb-8">
176
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Title</label>
188
+ <SectionLabel>Title</SectionLabel>
177
189
  <input
178
190
  type="text"
179
191
  value={title}
@@ -185,7 +197,7 @@ export function TaskSheet() {
185
197
  </div>
186
198
 
187
199
  <div className="mb-8">
188
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Description</label>
200
+ <SectionLabel>Description</SectionLabel>
189
201
  <textarea
190
202
  value={description}
191
203
  onChange={(e) => setDescription(e.target.value)}
@@ -198,9 +210,7 @@ export function TaskSheet() {
198
210
 
199
211
  {/* Images */}
200
212
  <div className="mb-8">
201
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
202
- Images <span className="normal-case tracking-normal font-normal text-text-3">(optional — reference designs, mockups, etc.)</span>
203
- </label>
213
+ <SectionLabel>Images <span className="normal-case tracking-normal font-normal text-text-3">(optional reference designs, mockups, etc.)</span></SectionLabel>
204
214
  {images.length > 0 && (
205
215
  <div className="flex gap-2 flex-wrap mb-3">
206
216
  {images.map((url, i) => (
@@ -229,33 +239,48 @@ export function TaskSheet() {
229
239
  </div>
230
240
 
231
241
  <div className="mb-8">
232
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Agent</label>
233
- {agentList.length > 0 ? (
234
- <div className="flex flex-wrap gap-2">
235
- {agentList.map((p) => (
236
- <button
237
- key={p.id}
238
- onClick={() => setAgentId(p.id)}
239
- className={`px-4 py-3 rounded-[12px] text-[14px] font-600 cursor-pointer transition-all border
240
- ${agentId === p.id
241
- ? 'bg-accent-soft border-accent-bright/25 text-accent-bright'
242
- : 'bg-surface border-white/[0.06] text-text-2 hover:bg-surface-2'}`}
243
- style={{ fontFamily: 'inherit' }}
244
- >
245
- {p.name}
246
- </button>
247
- ))}
248
- </div>
249
- ) : (
250
- <p className="text-[13px] text-text-3">No agents configured. Create one in Agents first.</p>
251
- )}
242
+ <SectionLabel>Agent</SectionLabel>
243
+ <AgentPickerList
244
+ agents={agentList}
245
+ selected={agentId}
246
+ onSelect={(id) => setAgentId(id)}
247
+ />
248
+ </div>
249
+
250
+ {/* Project (optional) */}
251
+ <div className="mb-8">
252
+ <SectionLabel>Project <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span></SectionLabel>
253
+ <div className="flex flex-wrap gap-2">
254
+ <button
255
+ onClick={() => setProjectId('')}
256
+ className={`px-4 py-3 rounded-[12px] text-[14px] font-600 cursor-pointer transition-all border
257
+ ${!projectId
258
+ ? 'bg-accent-soft border-accent-bright/25 text-accent-bright'
259
+ : 'bg-surface border-white/[0.06] text-text-2 hover:bg-surface-2'}`}
260
+ style={{ fontFamily: 'inherit' }}
261
+ >
262
+ None
263
+ </button>
264
+ {Object.values(projects).map((p) => (
265
+ <button
266
+ key={p.id}
267
+ onClick={() => setProjectId(p.id)}
268
+ className={`px-4 py-3 rounded-[12px] text-[14px] font-600 cursor-pointer transition-all border flex items-center gap-2
269
+ ${projectId === p.id
270
+ ? 'bg-accent-soft border-accent-bright/25 text-accent-bright'
271
+ : 'bg-surface border-white/[0.06] text-text-2 hover:bg-surface-2'}`}
272
+ style={{ fontFamily: 'inherit' }}
273
+ >
274
+ <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: p.color || '#6366F1' }} />
275
+ {p.name}
276
+ </button>
277
+ ))}
278
+ </div>
252
279
  </div>
253
280
 
254
281
  {/* Directory (optional) */}
255
282
  <div className="mb-8">
256
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
257
- Directory <span className="normal-case tracking-normal font-normal text-text-3">(optional — project to work in)</span>
258
- </label>
283
+ <SectionLabel>Directory <span className="normal-case tracking-normal font-normal text-text-3">(optional project to work in)</span></SectionLabel>
259
284
  <DirBrowser
260
285
  value={cwd || null}
261
286
  file={file}
@@ -273,9 +298,7 @@ export function TaskSheet() {
273
298
 
274
299
  {/* Tags */}
275
300
  <div className="mb-8">
276
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
277
- Tags <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>
278
- </label>
301
+ <SectionLabel>Tags <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span></SectionLabel>
279
302
  {tags.length > 0 && (
280
303
  <div className="flex flex-wrap gap-1.5 mb-3">
281
304
  {tags.map((tag) => (
@@ -315,9 +338,7 @@ export function TaskSheet() {
315
338
 
316
339
  {/* Dependencies */}
317
340
  <div className="mb-8">
318
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
319
- Blocked By <span className="normal-case tracking-normal font-normal text-text-3">(tasks that must complete first)</span>
320
- </label>
341
+ <SectionLabel>Blocked By <span className="normal-case tracking-normal font-normal text-text-3">(tasks that must complete first)</span></SectionLabel>
321
342
  <select
322
343
  multiple
323
344
  value={blockedBy}
@@ -346,9 +367,7 @@ export function TaskSheet() {
346
367
 
347
368
  {/* Due Date */}
348
369
  <div className="mb-8">
349
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
350
- Due Date <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>
351
- </label>
370
+ <SectionLabel>Due Date <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span></SectionLabel>
352
371
  <input
353
372
  type="date"
354
373
  value={dueAt}
@@ -361,7 +380,7 @@ export function TaskSheet() {
361
380
  {/* Custom Fields */}
362
381
  {appSettings.taskCustomFieldDefs && appSettings.taskCustomFieldDefs.length > 0 && (
363
382
  <div className="mb-8">
364
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Custom Fields</label>
383
+ <SectionLabel>Custom Fields</SectionLabel>
365
384
  <div className="space-y-4">
366
385
  {appSettings.taskCustomFieldDefs.map((def) => (
367
386
  <div key={def.key}>
@@ -396,7 +415,7 @@ export function TaskSheet() {
396
415
 
397
416
  {editing?.result && (
398
417
  <div className="mb-8">
399
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Result</label>
418
+ <SectionLabel>Result</SectionLabel>
400
419
  <div className="p-4 rounded-[14px] border border-white/[0.06] bg-surface text-[13px] text-text-2 whitespace-pre-wrap max-h-[200px] overflow-y-auto">
401
420
  {editing.result}
402
421
  </div>
@@ -405,7 +424,7 @@ export function TaskSheet() {
405
424
 
406
425
  {editing && (editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.cliResumeId) && (
407
426
  <div className="mb-8">
408
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">CLI Sessions</label>
427
+ <SectionLabel>CLI Sessions</SectionLabel>
409
428
  <div className="flex flex-wrap gap-2">
410
429
  {editing.claudeResumeId && (
411
430
  <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface">
@@ -447,9 +466,7 @@ export function TaskSheet() {
447
466
  {/* Comments */}
448
467
  {editing && (
449
468
  <div className="mb-8">
450
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
451
- Comments {editing.comments?.length ? `(${editing.comments.length})` : ''}
452
- </label>
469
+ <SectionLabel>Comments {editing.comments?.length ? `(${editing.comments.length})` : ''}</SectionLabel>
453
470
 
454
471
  {editing.comments && editing.comments.length > 0 && (
455
472
  <div className="space-y-3 mb-4 max-h-[300px] overflow-y-auto">
@@ -489,29 +506,29 @@ export function TaskSheet() {
489
506
  </div>
490
507
  )}
491
508
 
492
- <div className="flex gap-3 pt-2 border-t border-white/[0.04]">
493
- {editing && editing.status !== 'archived' && (
494
- <button onClick={handleArchive} className="py-3.5 px-6 rounded-[14px] border border-white/[0.08] bg-transparent text-text-3 text-[15px] font-600 cursor-pointer hover:bg-white/[0.04] transition-all" style={{ fontFamily: 'inherit' }}>
495
- Archive
496
- </button>
497
- )}
498
- {editing && editing.status === 'archived' && (
499
- <button onClick={handleUnarchive} className="py-3.5 px-6 rounded-[14px] border border-accent-bright/20 bg-transparent text-accent-bright text-[15px] font-600 cursor-pointer hover:bg-accent-bright/10 transition-all" style={{ fontFamily: 'inherit' }}>
500
- Unarchive
501
- </button>
502
- )}
503
- {editing && editing.status === 'backlog' && (
504
- <button onClick={handleQueue} className="py-3.5 px-6 rounded-[14px] border border-amber-500/20 bg-transparent text-amber-400 text-[15px] font-600 cursor-pointer hover:bg-amber-500/10 transition-all" style={{ fontFamily: 'inherit' }}>
505
- Queue
506
- </button>
507
- )}
508
- <button onClick={onClose} className="flex-1 py-3.5 rounded-[14px] border border-white/[0.08] bg-transparent text-text-2 text-[15px] font-600 cursor-pointer hover:bg-surface-2 transition-all" style={{ fontFamily: 'inherit' }}>
509
- Cancel
510
- </button>
511
- <button onClick={handleSave} disabled={!title.trim() || !agentId} className="flex-1 py-3.5 rounded-[14px] border-none bg-[#6366F1] text-white text-[15px] font-600 cursor-pointer active:scale-[0.97] disabled:opacity-30 transition-all shadow-[0_4px_20px_rgba(99,102,241,0.25)] hover:brightness-110" style={{ fontFamily: 'inherit' }}>
512
- {editing ? 'Save' : 'Create'}
513
- </button>
514
- </div>
509
+ <SheetFooter
510
+ onCancel={onClose}
511
+ onSave={handleSave}
512
+ saveLabel={editing ? 'Save' : 'Create'}
513
+ saveDisabled={!title.trim() || !agentId}
514
+ left={<>
515
+ {editing && editing.status !== 'archived' && (
516
+ <button onClick={handleArchive} className="py-3.5 px-6 rounded-[14px] border border-white/[0.08] bg-transparent text-text-3 text-[15px] font-600 cursor-pointer hover:bg-white/[0.04] transition-all" style={{ fontFamily: 'inherit' }}>
517
+ Archive
518
+ </button>
519
+ )}
520
+ {editing && editing.status === 'archived' && (
521
+ <button onClick={handleUnarchive} className="py-3.5 px-6 rounded-[14px] border border-accent-bright/20 bg-transparent text-accent-bright text-[15px] font-600 cursor-pointer hover:bg-accent-bright/10 transition-all" style={{ fontFamily: 'inherit' }}>
522
+ Unarchive
523
+ </button>
524
+ )}
525
+ {editing && editing.status === 'backlog' && (
526
+ <button onClick={handleQueue} className="py-3.5 px-6 rounded-[14px] border border-amber-500/20 bg-transparent text-amber-400 text-[15px] font-600 cursor-pointer hover:bg-amber-500/10 transition-all" style={{ fontFamily: 'inherit' }}>
527
+ Queue
528
+ </button>
529
+ )}
530
+ </>}
531
+ />
515
532
  </BottomSheet>
516
533
  )
517
534
  }
@@ -0,0 +1,52 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { HoverCard as HoverCardPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function HoverCard({
9
+ openDelay = 300,
10
+ closeDelay = 150,
11
+ ...props
12
+ }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
13
+ return (
14
+ <HoverCardPrimitive.Root
15
+ data-slot="hover-card"
16
+ openDelay={openDelay}
17
+ closeDelay={closeDelay}
18
+ {...props}
19
+ />
20
+ )
21
+ }
22
+
23
+ function HoverCardTrigger({
24
+ ...props
25
+ }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
26
+ return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
27
+ }
28
+
29
+ function HoverCardContent({
30
+ className,
31
+ sideOffset = 8,
32
+ children,
33
+ ...props
34
+ }: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
35
+ return (
36
+ <HoverCardPrimitive.Portal>
37
+ <HoverCardPrimitive.Content
38
+ data-slot="hover-card-content"
39
+ sideOffset={sideOffset}
40
+ className={cn(
41
+ "rounded-[12px] w-[260px] z-50 p-3 border border-white/[0.08] shadow-xl backdrop-blur-xl bg-[rgba(16,16,28,0.95)] animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-hover-card-content-transform-origin)",
42
+ className
43
+ )}
44
+ {...props}
45
+ >
46
+ {children}
47
+ </HoverCardPrimitive.Content>
48
+ </HoverCardPrimitive.Portal>
49
+ )
50
+ }
51
+
52
+ export { HoverCard, HoverCardTrigger, HoverCardContent }
@@ -18,6 +18,16 @@ interface TimePoint {
18
18
  cost: number
19
19
  }
20
20
 
21
+ interface ProviderHealthEntry {
22
+ totalRequests: number
23
+ successCount: number
24
+ errorCount: number
25
+ errorRate: number
26
+ avgLatencyMs: number
27
+ lastUsed: number
28
+ models: string[]
29
+ }
30
+
21
31
  interface UsageResponse {
22
32
  records: unknown[]
23
33
  totalTokens: number
@@ -25,6 +35,7 @@ interface UsageResponse {
25
35
  byAgent: Record<string, { tokens: number; cost: number }>
26
36
  byProvider: Record<string, { tokens: number; cost: number }>
27
37
  timeSeries: TimePoint[]
38
+ providerHealth?: Record<string, ProviderHealthEntry>
28
39
  }
29
40
 
30
41
  const RANGES: Range[] = ['24h', '7d', '30d']
@@ -62,6 +73,25 @@ function formatBucketLabel(bucket: string, range: Range): string {
62
73
  return bucket
63
74
  }
64
75
 
76
+ function formatRelativeTime(ts: number): string {
77
+ if (!ts) return 'Never'
78
+ const diff = Date.now() - ts
79
+ const seconds = Math.floor(diff / 1000)
80
+ if (seconds < 60) return 'Just now'
81
+ const minutes = Math.floor(seconds / 60)
82
+ if (minutes < 60) return `${minutes}m ago`
83
+ const hours = Math.floor(minutes / 60)
84
+ if (hours < 24) return `${hours}h ago`
85
+ const days = Math.floor(hours / 24)
86
+ return `${days}d ago`
87
+ }
88
+
89
+ function errorRateColor(rate: number): string {
90
+ if (rate < 0.05) return 'text-emerald-400'
91
+ if (rate < 0.2) return 'text-amber-400'
92
+ return 'text-red-400'
93
+ }
94
+
65
95
  function computeCompletionRate(tasks: Record<string, BoardTask>): number {
66
96
  const all = Object.values(tasks)
67
97
  const eligible = all.filter((t) => t.status !== 'backlog' && t.status !== 'archived')
@@ -245,6 +275,54 @@ export function MetricsDashboard() {
245
275
  )}
246
276
  </ChartCard>
247
277
  </div>
278
+
279
+ {/* Provider Health */}
280
+ {data?.providerHealth && Object.keys(data.providerHealth).length > 0 && (
281
+ <div>
282
+ <h3 className="font-display text-[14px] font-600 text-text-2 mb-3">Provider Health</h3>
283
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
284
+ {Object.entries(data.providerHealth)
285
+ .sort(([, a], [, b]) => b.totalRequests - a.totalRequests)
286
+ .map(([name, h]) => (
287
+ <div
288
+ key={name}
289
+ className="bg-surface-2 rounded-[12px] p-4 border border-white/[0.04] flex flex-col gap-3"
290
+ >
291
+ <div className="flex items-center justify-between">
292
+ <p className="text-[14px] font-600 text-text">{name}</p>
293
+ <span className="text-[11px] text-text-3">{formatRelativeTime(h.lastUsed)}</span>
294
+ </div>
295
+ <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[12px]">
296
+ <span className="text-text-3">Requests</span>
297
+ <span className="text-text font-500 text-right">{h.totalRequests}</span>
298
+ <span className="text-text-3">Error Rate</span>
299
+ <span className={`font-500 text-right ${errorRateColor(h.errorRate)}`}>
300
+ {(h.errorRate * 100).toFixed(1)}%
301
+ </span>
302
+ {h.avgLatencyMs > 0 && (
303
+ <>
304
+ <span className="text-text-3">Avg Latency</span>
305
+ <span className="text-text font-500 text-right">{Math.round(h.avgLatencyMs)}ms</span>
306
+ </>
307
+ )}
308
+ </div>
309
+ {h.models.length > 0 && (
310
+ <div className="flex flex-wrap gap-1.5 pt-1">
311
+ {h.models.map((m) => (
312
+ <span
313
+ key={m}
314
+ className="px-2 py-0.5 rounded-[6px] bg-white/[0.06] text-[11px] text-text-3 font-500"
315
+ >
316
+ {m}
317
+ </span>
318
+ ))}
319
+ </div>
320
+ )}
321
+ </div>
322
+ ))}
323
+ </div>
324
+ </div>
325
+ )}
248
326
  </div>
249
327
  )}
250
328
  </div>
@@ -98,7 +98,7 @@ export function UsageList() {
98
98
  </div>
99
99
  <div className="mt-2 h-1 rounded-full bg-white/[0.04] overflow-hidden">
100
100
  <div
101
- className="h-full rounded-full bg-[#6366F1]/60"
101
+ className="h-full rounded-full bg-accent-bright/60"
102
102
  style={{ width: `${Math.max(pct, 1)}%` }}
103
103
  />
104
104
  </div>
@@ -389,7 +389,7 @@ export function WebhookSheet() {
389
389
  <button
390
390
  onClick={handleSave}
391
391
  disabled={saving}
392
- className="px-8 py-3 rounded-[14px] border-none bg-[#6366F1] text-white text-[14px] font-600 cursor-pointer disabled:opacity-30 transition-all hover:brightness-110"
392
+ className="px-8 py-3 rounded-[14px] border-none bg-accent-bright text-white text-[14px] font-600 cursor-pointer disabled:opacity-30 transition-all hover:brightness-110"
393
393
  style={{ fontFamily: 'inherit' }}
394
394
  >
395
395
  {saving ? 'Saving...' : editing ? 'Update' : 'Create'}