@swarmclawai/swarmclaw 0.6.8 → 0.7.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 (166) hide show
  1. package/README.md +70 -45
  2. package/next.config.ts +31 -6
  3. package/package.json +3 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +1 -0
  5. package/src/app/api/agents/route.ts +18 -5
  6. package/src/app/api/approvals/route.ts +22 -0
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
  9. package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
  10. package/src/app/api/memory/route.ts +36 -5
  11. package/src/app/api/notifications/route.ts +3 -0
  12. package/src/app/api/plugins/install/route.ts +57 -5
  13. package/src/app/api/plugins/marketplace/route.ts +73 -22
  14. package/src/app/api/plugins/route.ts +61 -1
  15. package/src/app/api/plugins/ui/route.ts +34 -0
  16. package/src/app/api/settings/route.ts +62 -0
  17. package/src/app/api/setup/doctor/route.ts +22 -5
  18. package/src/app/api/tasks/[id]/approve/route.ts +4 -3
  19. package/src/app/api/tasks/[id]/route.ts +11 -3
  20. package/src/app/api/tasks/route.ts +8 -2
  21. package/src/app/globals.css +27 -0
  22. package/src/app/page.tsx +10 -5
  23. package/src/cli/index.js +13 -0
  24. package/src/components/activity/activity-feed.tsx +9 -2
  25. package/src/components/agents/agent-avatar.tsx +5 -1
  26. package/src/components/agents/agent-card.tsx +55 -9
  27. package/src/components/agents/agent-sheet.tsx +86 -29
  28. package/src/components/agents/inspector-panel.tsx +1 -1
  29. package/src/components/auth/access-key-gate.tsx +63 -54
  30. package/src/components/auth/user-picker.tsx +37 -32
  31. package/src/components/chat/chat-area.tsx +11 -0
  32. package/src/components/chat/chat-header.tsx +69 -25
  33. package/src/components/chat/chat-tool-toggles.tsx +2 -2
  34. package/src/components/chat/code-block.tsx +3 -1
  35. package/src/components/chat/exec-approval-card.tsx +8 -1
  36. package/src/components/chat/message-bubble.tsx +164 -4
  37. package/src/components/chat/message-list.tsx +30 -4
  38. package/src/components/chat/session-approval-card.tsx +80 -0
  39. package/src/components/chat/streaming-bubble.tsx +6 -5
  40. package/src/components/chat/thinking-indicator.tsx +48 -12
  41. package/src/components/chat/tool-request-banner.tsx +39 -20
  42. package/src/components/chatrooms/chatroom-list.tsx +11 -4
  43. package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
  44. package/src/components/connectors/connector-list.tsx +33 -11
  45. package/src/components/connectors/connector-sheet.tsx +29 -6
  46. package/src/components/home/home-view.tsx +20 -14
  47. package/src/components/input/chat-input.tsx +22 -1
  48. package/src/components/knowledge/knowledge-list.tsx +17 -18
  49. package/src/components/knowledge/knowledge-sheet.tsx +9 -5
  50. package/src/components/layout/app-layout.tsx +73 -21
  51. package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
  52. package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
  53. package/src/components/memory/memory-list.tsx +20 -13
  54. package/src/components/plugins/plugin-list.tsx +213 -59
  55. package/src/components/plugins/plugin-sheet.tsx +119 -24
  56. package/src/components/projects/project-list.tsx +17 -9
  57. package/src/components/providers/provider-list.tsx +21 -6
  58. package/src/components/providers/provider-sheet.tsx +42 -25
  59. package/src/components/runs/run-list.tsx +17 -13
  60. package/src/components/schedules/schedule-card.tsx +10 -3
  61. package/src/components/schedules/schedule-list.tsx +2 -2
  62. package/src/components/schedules/schedule-sheet.tsx +19 -7
  63. package/src/components/secrets/secret-sheet.tsx +7 -2
  64. package/src/components/secrets/secrets-list.tsx +18 -5
  65. package/src/components/sessions/new-session-sheet.tsx +183 -376
  66. package/src/components/sessions/session-card.tsx +10 -2
  67. package/src/components/settings/gateway-connection-panel.tsx +9 -8
  68. package/src/components/shared/command-palette.tsx +13 -5
  69. package/src/components/shared/empty-state.tsx +20 -8
  70. package/src/components/shared/notification-center.tsx +134 -86
  71. package/src/components/shared/profile-sheet.tsx +4 -0
  72. package/src/components/shared/settings/plugin-manager.tsx +360 -135
  73. package/src/components/shared/settings/section-capability-policy.tsx +3 -3
  74. package/src/components/shared/settings/section-runtime-loop.tsx +144 -0
  75. package/src/components/skills/clawhub-browser.tsx +1 -0
  76. package/src/components/skills/skill-list.tsx +31 -12
  77. package/src/components/skills/skill-sheet.tsx +20 -7
  78. package/src/components/tasks/approvals-panel.tsx +170 -66
  79. package/src/components/tasks/task-board.tsx +20 -12
  80. package/src/components/tasks/task-card.tsx +21 -7
  81. package/src/components/tasks/task-column.tsx +4 -3
  82. package/src/components/tasks/task-list.tsx +1 -1
  83. package/src/components/tasks/task-sheet.tsx +130 -1
  84. package/src/components/ui/dialog.tsx +1 -0
  85. package/src/components/ui/sheet.tsx +1 -0
  86. package/src/components/usage/metrics-dashboard.tsx +66 -64
  87. package/src/components/wallets/wallet-panel.tsx +65 -41
  88. package/src/components/wallets/wallet-section.tsx +9 -3
  89. package/src/components/webhooks/webhook-list.tsx +21 -12
  90. package/src/components/webhooks/webhook-sheet.tsx +13 -3
  91. package/src/lib/approval-display.test.ts +45 -0
  92. package/src/lib/approval-display.ts +62 -0
  93. package/src/lib/clipboard.ts +38 -0
  94. package/src/lib/memory.ts +8 -0
  95. package/src/lib/providers/claude-cli.ts +5 -3
  96. package/src/lib/providers/index.ts +67 -21
  97. package/src/lib/runtime-loop.ts +3 -2
  98. package/src/lib/server/approvals.ts +150 -0
  99. package/src/lib/server/chat-execution.ts +223 -62
  100. package/src/lib/server/clawhub-client.ts +82 -6
  101. package/src/lib/server/connectors/manager.ts +27 -1
  102. package/src/lib/server/cost.test.ts +73 -0
  103. package/src/lib/server/cost.ts +165 -34
  104. package/src/lib/server/daemon-state.ts +42 -0
  105. package/src/lib/server/data-dir.ts +18 -1
  106. package/src/lib/server/integrity-monitor.ts +208 -0
  107. package/src/lib/server/llm-response-cache.test.ts +102 -0
  108. package/src/lib/server/llm-response-cache.ts +227 -0
  109. package/src/lib/server/main-agent-loop.ts +1 -1
  110. package/src/lib/server/main-session.ts +6 -3
  111. package/src/lib/server/mcp-conformance.test.ts +18 -0
  112. package/src/lib/server/mcp-conformance.ts +233 -0
  113. package/src/lib/server/memory-db.ts +180 -17
  114. package/src/lib/server/memory-retrieval.test.ts +56 -0
  115. package/src/lib/server/orchestrator-lg.ts +4 -1
  116. package/src/lib/server/orchestrator.ts +4 -3
  117. package/src/lib/server/plugins.ts +650 -142
  118. package/src/lib/server/process-manager.ts +18 -0
  119. package/src/lib/server/queue.ts +253 -11
  120. package/src/lib/server/runtime-settings.ts +9 -0
  121. package/src/lib/server/session-run-manager.test.ts +23 -0
  122. package/src/lib/server/session-run-manager.ts +11 -1
  123. package/src/lib/server/session-tools/canvas.ts +85 -50
  124. package/src/lib/server/session-tools/chatroom.ts +130 -127
  125. package/src/lib/server/session-tools/connector.ts +233 -454
  126. package/src/lib/server/session-tools/context-mgmt.ts +87 -105
  127. package/src/lib/server/session-tools/crud.ts +84 -7
  128. package/src/lib/server/session-tools/delegate.ts +351 -752
  129. package/src/lib/server/session-tools/discovery.ts +198 -0
  130. package/src/lib/server/session-tools/edit_file.ts +82 -0
  131. package/src/lib/server/session-tools/file-send.test.ts +39 -0
  132. package/src/lib/server/session-tools/file.ts +257 -425
  133. package/src/lib/server/session-tools/git.ts +87 -47
  134. package/src/lib/server/session-tools/http.ts +85 -33
  135. package/src/lib/server/session-tools/index.ts +205 -160
  136. package/src/lib/server/session-tools/memory.ts +152 -265
  137. package/src/lib/server/session-tools/monitor.ts +126 -0
  138. package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
  139. package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
  140. package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
  141. package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
  142. package/src/lib/server/session-tools/platform.ts +86 -0
  143. package/src/lib/server/session-tools/plugin-creator.ts +239 -0
  144. package/src/lib/server/session-tools/sample-ui.ts +97 -0
  145. package/src/lib/server/session-tools/sandbox.ts +175 -148
  146. package/src/lib/server/session-tools/schedule.ts +66 -31
  147. package/src/lib/server/session-tools/session-info.ts +104 -410
  148. package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
  149. package/src/lib/server/session-tools/shell.ts +171 -143
  150. package/src/lib/server/session-tools/subagent.ts +77 -77
  151. package/src/lib/server/session-tools/wallet.ts +182 -106
  152. package/src/lib/server/session-tools/web.ts +179 -349
  153. package/src/lib/server/storage.ts +24 -0
  154. package/src/lib/server/stream-agent-chat.ts +301 -244
  155. package/src/lib/server/task-quality-gate.test.ts +44 -0
  156. package/src/lib/server/task-quality-gate.ts +67 -0
  157. package/src/lib/server/task-validation.test.ts +78 -0
  158. package/src/lib/server/task-validation.ts +67 -2
  159. package/src/lib/server/tool-aliases.ts +68 -0
  160. package/src/lib/server/tool-capability-policy.ts +23 -5
  161. package/src/lib/tasks.ts +7 -1
  162. package/src/lib/tool-definitions.ts +23 -23
  163. package/src/lib/validation/schemas.ts +12 -0
  164. package/src/lib/view-routes.ts +2 -24
  165. package/src/stores/use-app-store.ts +23 -1
  166. package/src/types/index.ts +121 -7
@@ -231,7 +231,7 @@ export function TaskBoard() {
231
231
  }, [selectionMode])
232
232
 
233
233
  return (
234
- <div className="flex-1 flex flex-col h-full overflow-hidden">
234
+ <div className="flex-1 min-h-0 flex flex-col h-full overflow-hidden">
235
235
  <div className="flex items-center justify-between px-8 pt-6 pb-4 shrink-0">
236
236
  <div>
237
237
  <h1 className="font-display text-[28px] font-800 tracking-[-0.03em]">Task Board</h1>
@@ -399,7 +399,7 @@ export function TaskBoard() {
399
399
  </div>
400
400
  )}
401
401
 
402
- <div className="flex-1 flex gap-5 px-8 pb-6 overflow-x-auto overflow-y-hidden">
402
+ <div className="flex-1 min-h-0 flex gap-5 px-8 pb-6 overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x">
403
403
  {!loaded ? (
404
404
  ACTIVE_COLUMNS.map((status) => (
405
405
  <div key={status} className="flex flex-col gap-3 min-w-[260px] flex-1">
@@ -410,17 +410,25 @@ export function TaskBoard() {
410
410
  </div>
411
411
  ))
412
412
  ) : (
413
- columns.map((status) => (
414
- <TaskColumn
413
+ columns.map((status, idx) => (
414
+ <div
415
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
- />
416
+ className="flex flex-col gap-3 min-w-[260px] flex-1"
417
+ style={{
418
+ animation: 'fade-up 0.6s var(--ease-spring) both',
419
+ animationDelay: `${idx * 0.1}s`
420
+ }}
421
+ >
422
+ <TaskColumn
423
+ status={status}
424
+ tasks={tasksByStatus(status)}
425
+ onDrop={handleDrop}
426
+ selectionMode={selectionMode}
427
+ selectedIds={selectedIds}
428
+ onToggleSelect={toggleSelect}
429
+ onSelectAll={() => selectAllInColumn(status)}
430
+ />
431
+ </div>
424
432
  ))
425
433
  )}
426
434
  </div>
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useState, useCallback } from 'react'
3
+ import { useState, useCallback, useEffect } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
6
  import { updateTask, archiveTask } from '@/lib/tasks'
@@ -20,9 +20,10 @@ interface TaskCardProps {
20
20
  selectionMode?: boolean
21
21
  selected?: boolean
22
22
  onToggleSelect?: (id: string) => void
23
+ index?: number
23
24
  }
24
25
 
25
- export function TaskCard({ task, selectionMode, selected, onToggleSelect }: TaskCardProps) {
26
+ export function TaskCard({ task, selectionMode, selected, onToggleSelect, index = 0 }: TaskCardProps) {
26
27
  const agents = useAppStore((s) => s.agents)
27
28
  const projects = useAppStore((s) => s.projects)
28
29
  const setEditingTaskId = useAppStore((s) => s.setEditingTaskId)
@@ -32,6 +33,15 @@ export function TaskCard({ task, selectionMode, selected, onToggleSelect }: Task
32
33
  const setActiveView = useAppStore((s) => s.setActiveView)
33
34
  const [dragging, setDragging] = useState(false)
34
35
  const [confirmArchive, setConfirmArchive] = useState(false)
36
+ const [allowDrag, setAllowDrag] = useState(false)
37
+
38
+ useEffect(() => {
39
+ if (typeof window === 'undefined') return
40
+ const isCoarsePointer = typeof window.matchMedia === 'function'
41
+ ? window.matchMedia('(pointer: coarse)').matches
42
+ : 'ontouchstart' in window
43
+ setAllowDrag(!isCoarsePointer)
44
+ }, [])
35
45
 
36
46
  const tasks = useAppStore((s) => s.tasks)
37
47
  const agent = agents[task.agentId]
@@ -85,9 +95,9 @@ export function TaskCard({ task, selectionMode, selected, onToggleSelect }: Task
85
95
 
86
96
  return (
87
97
  <div
88
- draggable={!selectionMode}
89
- onDragStart={selectionMode ? undefined : handleDragStart}
90
- onDragEnd={selectionMode ? undefined : handleDragEnd}
98
+ draggable={!selectionMode && allowDrag}
99
+ onDragStart={selectionMode || !allowDrag ? undefined : handleDragStart}
100
+ onDragEnd={selectionMode || !allowDrag ? undefined : handleDragEnd}
91
101
  onClick={(e) => {
92
102
  if (selectionMode && onToggleSelect) {
93
103
  e.stopPropagation()
@@ -98,9 +108,13 @@ export function TaskCard({ task, selectionMode, selected, onToggleSelect }: Task
98
108
  }
99
109
  }}
100
110
  className={`py-3 px-4 rounded-[14px] border border-l-[3px] ${borderColor} bg-surface hover:bg-surface-2 transition-all group
101
- ${selectionMode ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'}
111
+ ${selectionMode || !allowDrag ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'} touch-pan-y
102
112
  ${dragging ? 'opacity-40 scale-[0.97]' : ''}
103
- ${selected ? 'border-accent-bright/40 bg-accent-bright/[0.04] ring-1 ring-accent-bright/20' : 'border-white/[0.06]'}`}
113
+ ${selected ? 'border-accent-bright/40 bg-accent-bright/[0.04] ring-1 ring-accent-bright/20 shadow-lg' : 'border-white/[0.06] hover:border-white/[0.12] hover:scale-[1.01] hover:shadow-md'}`}
114
+ style={{
115
+ animation: 'spring-in 0.5s var(--ease-spring) both',
116
+ animationDelay: `${Math.min(index * 0.05, 0.4)}s`
117
+ }}
104
118
  >
105
119
  <div className="flex items-start gap-3 mb-3">
106
120
  {/* Selection checkbox */}
@@ -68,7 +68,7 @@ export function TaskColumn({ status, tasks, onDrop, selectionMode, selectedIds,
68
68
 
69
69
  return (
70
70
  <div
71
- className={`flex-1 min-w-[240px] max-w-[320px] flex flex-col rounded-[16px] transition-colors duration-150 ${
71
+ className={`flex-1 min-w-[240px] max-w-[320px] min-h-0 flex flex-col rounded-[16px] transition-colors duration-150 ${
72
72
  dragOver ? 'bg-accent-bright/[0.04] ring-1 ring-accent-bright/20' : ''
73
73
  }`}
74
74
  onDragOver={handleDragOver}
@@ -109,11 +109,12 @@ export function TaskColumn({ status, tasks, onDrop, selectionMode, selectedIds,
109
109
  </div>
110
110
  )}
111
111
 
112
- <div className="flex flex-col gap-3 flex-1 overflow-y-auto pr-1 px-1 pb-2">
113
- {tasks.map((task) => (
112
+ <div className="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto overscroll-y-contain touch-pan-y pr-1 px-1 pb-2">
113
+ {tasks.map((task, idx) => (
114
114
  <TaskCard
115
115
  key={task.id}
116
116
  task={task}
117
+ index={idx}
117
118
  selectionMode={selectionMode}
118
119
  selected={selectedIds?.has(task.id)}
119
120
  onToggleSelect={onToggleSelect}
@@ -62,7 +62,7 @@ export function TaskList({ inSidebar }: { inSidebar?: boolean }) {
62
62
  }
63
63
 
64
64
  return (
65
- <div className="flex-1 flex flex-col overflow-y-auto">
65
+ <div className="flex-1 min-h-0 flex flex-col overflow-y-auto overscroll-y-contain touch-pan-y">
66
66
  {/* Search + clear */}
67
67
  {sorted.length > 0 && (
68
68
  <div className="px-3 py-2 shrink-0 flex flex-col gap-2">
@@ -10,7 +10,7 @@ import { AgentPickerList } from '@/components/shared/agent-picker-list'
10
10
  import { DirBrowser } from '@/components/shared/dir-browser'
11
11
  import { SheetFooter } from '@/components/shared/sheet-footer'
12
12
  import { inputClass } from '@/components/shared/form-styles'
13
- import type { BoardTask, TaskComment } from '@/types'
13
+ import type { BoardTask, TaskComment, TaskQualityGateConfig } from '@/types'
14
14
  import { SectionLabel } from '@/components/shared/section-label'
15
15
  import { AgentAvatar } from '@/components/agents/agent-avatar'
16
16
 
@@ -22,6 +22,16 @@ function fmtTime(ts: number) {
22
22
  return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
23
23
  }
24
24
 
25
+ function normalizeGateNumber(value: unknown, fallback: number, min: number, max: number): number {
26
+ const parsed = typeof value === 'number'
27
+ ? value
28
+ : typeof value === 'string'
29
+ ? Number.parseInt(value, 10)
30
+ : Number.NaN
31
+ if (!Number.isFinite(parsed)) return fallback
32
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
33
+ }
34
+
25
35
  export function TaskSheet() {
26
36
  const open = useAppStore((s) => s.taskSheetOpen)
27
37
  const setOpen = useAppStore((s) => s.setTaskSheetOpen)
@@ -59,6 +69,12 @@ export function TaskSheet() {
59
69
  const [dueAt, setDueAt] = useState<string>('')
60
70
  const [customFields, setCustomFields] = useState<Record<string, string | number | boolean>>({})
61
71
  const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'critical' | ''>('')
72
+ const [qualityGateEnabled, setQualityGateEnabled] = useState(true)
73
+ const [qualityGateMinResultChars, setQualityGateMinResultChars] = useState(80)
74
+ const [qualityGateMinEvidenceItems, setQualityGateMinEvidenceItems] = useState(2)
75
+ const [qualityGateRequireVerification, setQualityGateRequireVerification] = useState(false)
76
+ const [qualityGateRequireArtifact, setQualityGateRequireArtifact] = useState(false)
77
+ const [qualityGateRequireReport, setQualityGateRequireReport] = useState(false)
62
78
 
63
79
  const editing = editingId ? tasks[editingId] : null
64
80
  const agentList = Object.values(agents).sort((a, b) => a.name.localeCompare(b.name))
@@ -68,6 +84,12 @@ export function TaskSheet() {
68
84
  loadAgents()
69
85
  loadProjects()
70
86
  loadSettings()
87
+ const defaultGateEnabled = appSettings.taskQualityGateEnabled ?? true
88
+ const defaultGateMinResult = normalizeGateNumber(appSettings.taskQualityGateMinResultChars, 80, 10, 2000)
89
+ const defaultGateMinEvidence = normalizeGateNumber(appSettings.taskQualityGateMinEvidenceItems, 2, 0, 8)
90
+ const defaultGateRequireVerification = appSettings.taskQualityGateRequireVerification ?? false
91
+ const defaultGateRequireArtifact = appSettings.taskQualityGateRequireArtifact ?? false
92
+ const defaultGateRequireReport = appSettings.taskQualityGateRequireReport ?? false
71
93
  if (editing) {
72
94
  setTitle(editing.title)
73
95
  setDescription(editing.description)
@@ -83,6 +105,13 @@ export function TaskSheet() {
83
105
  setDueAt(editing.dueAt ? new Date(editing.dueAt).toISOString().slice(0, 10) : '')
84
106
  setCustomFields(editing.customFields || {})
85
107
  setPriority(editing.priority || '')
108
+ const gate = (editing.qualityGate || null) as TaskQualityGateConfig | null
109
+ setQualityGateEnabled(gate?.enabled ?? defaultGateEnabled)
110
+ setQualityGateMinResultChars(normalizeGateNumber(gate?.minResultChars, defaultGateMinResult, 10, 2000))
111
+ setQualityGateMinEvidenceItems(normalizeGateNumber(gate?.minEvidenceItems, defaultGateMinEvidence, 0, 8))
112
+ setQualityGateRequireVerification(gate?.requireVerification ?? defaultGateRequireVerification)
113
+ setQualityGateRequireArtifact(gate?.requireArtifact ?? defaultGateRequireArtifact)
114
+ setQualityGateRequireReport(gate?.requireReport ?? defaultGateRequireReport)
86
115
  } else {
87
116
  setTitle('')
88
117
  setDescription('')
@@ -98,6 +127,12 @@ export function TaskSheet() {
98
127
  setDueAt('')
99
128
  setCustomFields({})
100
129
  setPriority('')
130
+ setQualityGateEnabled(defaultGateEnabled)
131
+ setQualityGateMinResultChars(defaultGateMinResult)
132
+ setQualityGateMinEvidenceItems(defaultGateMinEvidence)
133
+ setQualityGateRequireVerification(defaultGateRequireVerification)
134
+ setQualityGateRequireArtifact(defaultGateRequireArtifact)
135
+ setQualityGateRequireReport(defaultGateRequireReport)
101
136
  }
102
137
  }
103
138
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -116,6 +151,17 @@ export function TaskSheet() {
116
151
  }
117
152
 
118
153
  const handleSave = async () => {
154
+ const qualityGate: TaskQualityGateConfig | null = qualityGateEnabled
155
+ ? {
156
+ enabled: true,
157
+ minResultChars: qualityGateMinResultChars,
158
+ minEvidenceItems: qualityGateMinEvidenceItems,
159
+ requireVerification: qualityGateRequireVerification,
160
+ requireArtifact: qualityGateRequireArtifact,
161
+ requireReport: qualityGateRequireReport,
162
+ }
163
+ : null
164
+
119
165
  // projectId uses null (not undefined) so the API can distinguish "clear" from "not sent"
120
166
  // projectId uses null (not undefined) so the API can distinguish "clear" from "not sent"
121
167
  const payload = {
@@ -124,6 +170,7 @@ export function TaskSheet() {
124
170
  tags, blockedBy, dueAt: dueAt ? new Date(dueAt).getTime() : null,
125
171
  customFields: Object.keys(customFields).length > 0 ? customFields : undefined,
126
172
  priority: priority || undefined,
173
+ qualityGate,
127
174
  } as Partial<BoardTask> & { title: string; description: string; agentId: string }
128
175
  try {
129
176
  if (editing) {
@@ -363,6 +410,19 @@ export function TaskSheet() {
363
410
  </div>
364
411
  )}
365
412
 
413
+ {editing.qualityGate?.enabled && (
414
+ <div className="mb-8">
415
+ <SectionLabel>Quality Gate</SectionLabel>
416
+ <div className="p-4 rounded-[14px] border border-white/[0.06] bg-surface space-y-1.5 text-[12px] text-text-2">
417
+ <p>Min result chars: {editing.qualityGate.minResultChars ?? 80}</p>
418
+ <p>Min evidence signals: {editing.qualityGate.minEvidenceItems ?? 2}</p>
419
+ <p>Verification required: {(editing.qualityGate.requireVerification ?? false) ? 'Yes' : 'No'}</p>
420
+ <p>Artifact required: {(editing.qualityGate.requireArtifact ?? false) ? 'Yes' : 'No'}</p>
421
+ <p>Task report required: {(editing.qualityGate.requireReport ?? false) ? 'Yes' : 'No'}</p>
422
+ </div>
423
+ </div>
424
+ )}
425
+
366
426
  {/* Images (thumbnails only, no remove/upload) */}
367
427
  {editing.images && editing.images.length > 0 && (
368
428
  <div className="mb-8">
@@ -797,6 +857,75 @@ export function TaskSheet() {
797
857
  />
798
858
  </div>
799
859
 
860
+ <div className="mb-8">
861
+ <SectionLabel>Quality Gate</SectionLabel>
862
+ <p className="text-[12px] text-text-3 mb-3">
863
+ Checks that must pass before this task can be marked completed.
864
+ </p>
865
+ <div className="p-4 rounded-[14px] border border-white/[0.06] bg-surface">
866
+ <button
867
+ onClick={() => setQualityGateEnabled((prev) => !prev)}
868
+ className={`relative w-10 h-[22px] rounded-full transition-colors duration-200 cursor-pointer ${qualityGateEnabled ? 'bg-accent' : 'bg-white/[0.12]'}`}
869
+ >
870
+ <span className={`absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200 ${qualityGateEnabled ? 'translate-x-[18px]' : ''}`} />
871
+ </button>
872
+ <span className="ml-2 text-[12px] text-text-2">{qualityGateEnabled ? 'Enabled' : 'Disabled'}</span>
873
+
874
+ {qualityGateEnabled && (
875
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4">
876
+ <div>
877
+ <label className="block text-[11px] text-text-3 mb-1.5">Min Result Chars</label>
878
+ <input
879
+ type="number"
880
+ min={10}
881
+ max={2000}
882
+ value={qualityGateMinResultChars}
883
+ onChange={(e) => setQualityGateMinResultChars(normalizeGateNumber(e.target.value, 80, 10, 2000))}
884
+ className={inputClass}
885
+ style={{ fontFamily: 'inherit' }}
886
+ />
887
+ </div>
888
+ <div>
889
+ <label className="block text-[11px] text-text-3 mb-1.5">Min Evidence Signals</label>
890
+ <input
891
+ type="number"
892
+ min={0}
893
+ max={8}
894
+ value={qualityGateMinEvidenceItems}
895
+ onChange={(e) => setQualityGateMinEvidenceItems(normalizeGateNumber(e.target.value, 2, 0, 8))}
896
+ className={inputClass}
897
+ style={{ fontFamily: 'inherit' }}
898
+ />
899
+ </div>
900
+ <label className="flex items-center gap-2 text-[12px] text-text-2">
901
+ <input
902
+ type="checkbox"
903
+ checked={qualityGateRequireVerification}
904
+ onChange={(e) => setQualityGateRequireVerification(e.target.checked)}
905
+ />
906
+ Require verification evidence (tests/lint/build)
907
+ </label>
908
+ <label className="flex items-center gap-2 text-[12px] text-text-2">
909
+ <input
910
+ type="checkbox"
911
+ checked={qualityGateRequireArtifact}
912
+ onChange={(e) => setQualityGateRequireArtifact(e.target.checked)}
913
+ />
914
+ Require artifact evidence (upload URL or task artifacts)
915
+ </label>
916
+ <label className="flex items-center gap-2 text-[12px] text-text-2 md:col-span-2">
917
+ <input
918
+ type="checkbox"
919
+ checked={qualityGateRequireReport}
920
+ onChange={(e) => setQualityGateRequireReport(e.target.checked)}
921
+ />
922
+ Require generated task report
923
+ </label>
924
+ </div>
925
+ )}
926
+ </div>
927
+ </div>
928
+
800
929
  {/* Custom Fields */}
801
930
  {appSettings.taskCustomFieldDefs && appSettings.taskCustomFieldDefs.length > 0 && (
802
931
  <div className="mb-8">
@@ -64,6 +64,7 @@ function DialogContent({
64
64
  "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
65
65
  className
66
66
  )}
67
+ style={{ animation: 'spring-in 0.4s var(--ease-spring)' }}
67
68
  {...props}
68
69
  >
69
70
  {children}
@@ -71,6 +71,7 @@ function SheetContent({
71
71
  "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
72
72
  className
73
73
  )}
74
+ style={{ animation: 'spring-in 0.4s var(--ease-spring)' }}
74
75
  {...props}
75
76
  >
76
77
  {children}
@@ -67,11 +67,9 @@ function formatDuration(ms: number): string {
67
67
 
68
68
  function formatBucketLabel(bucket: string, range: Range): string {
69
69
  if (range === '24h') {
70
- // "2026-03-01T14" → "14:00"
71
70
  const hour = bucket.split('T')[1]
72
71
  return hour ? `${hour}:00` : bucket
73
72
  }
74
- // "2026-03-01" → "Mar 1"
75
73
  const parts = bucket.split('-')
76
74
  if (parts.length === 3) {
77
75
  const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
@@ -119,11 +117,8 @@ export function MetricsDashboard() {
119
117
  try {
120
118
  const res = await api<UsageResponse>('GET', `/usage?range=${range}`)
121
119
  setData(res)
122
- } catch {
123
- // ignore
124
- } finally {
125
- setLoading(false)
126
- }
120
+ } catch { /* ignore */ }
121
+ setLoading(false)
127
122
  }, [range])
128
123
 
129
124
  useEffect(() => {
@@ -136,7 +131,6 @@ export function MetricsDashboard() {
136
131
  // eslint-disable-next-line react-hooks/exhaustive-deps
137
132
  }, [])
138
133
 
139
- // --- Task metrics ---
140
134
  const [taskMetrics, setTaskMetrics] = useState<{
141
135
  wip: number; completedCount: number; avgCycleMs: number
142
136
  velocity: { bucket: string; count: number }[]
@@ -157,7 +151,6 @@ export function MetricsDashboard() {
157
151
 
158
152
  const completionRate = computeCompletionRate(tasks)
159
153
 
160
- // Prepare chart data
161
154
  const timeSeriesFormatted = (data?.timeSeries ?? []).map((pt) => ({
162
155
  ...pt,
163
156
  label: formatBucketLabel(pt.bucket, range),
@@ -191,13 +184,13 @@ export function MetricsDashboard() {
191
184
 
192
185
  return (
193
186
  <div className="flex-1 flex flex-col h-full overflow-y-auto">
194
- <div className="px-8 pt-6 pb-4 shrink-0">
187
+ <div className="px-8 pt-6 pb-4 shrink-0" style={{ animation: 'fade-up 0.5s var(--ease-spring)' }}>
195
188
  <h1 className="font-display text-[28px] font-700 tracking-[-0.03em]">Usage</h1>
196
189
  <p className="text-[13px] text-text-3 mt-1">Token usage, cost tracking &amp; agent performance</p>
197
190
  </div>
198
191
 
199
192
  {/* Range tabs */}
200
- <div className="px-8 pb-4 shrink-0">
193
+ <div className="px-8 pb-4 shrink-0" style={{ animation: 'fade-up 0.5s var(--ease-spring) 0.05s both' }}>
201
194
  <div className="flex gap-1 bg-surface-2 rounded-[10px] p-1 w-fit">
202
195
  {RANGES.map((r) => (
203
196
  <button
@@ -208,6 +201,7 @@ export function MetricsDashboard() {
208
201
  ? 'bg-accent-soft text-accent-bright'
209
202
  : 'text-text-3 hover:text-text-2'
210
203
  }`}
204
+ style={range === r ? { animation: 'spring-in 0.3s var(--ease-spring)' } : undefined}
211
205
  >
212
206
  {RANGE_LABELS[r]}
213
207
  </button>
@@ -226,31 +220,33 @@ export function MetricsDashboard() {
226
220
  <div className="px-8 pb-8 space-y-6">
227
221
  {/* Stats cards */}
228
222
  <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
229
- <StatCard label="Total Tokens" value={formatTokens(data?.totalTokens ?? 0)} />
230
- <StatCard label="Total Cost" value={formatCost(data?.totalCost ?? 0)} />
231
- <StatCard label="Requests" value={String(data?.records.length ?? 0)} />
232
- <StatCard label="Completion Rate" value={`${completionRate}%`} />
223
+ <StatCard label="Total Tokens" value={formatTokens(data?.totalTokens ?? 0)} index={0} />
224
+ <StatCard label="Total Cost" value={formatCost(data?.totalCost ?? 0)} index={1} />
225
+ <StatCard label="Requests" value={String(data?.records.length ?? 0)} index={2} />
226
+ <StatCard label="Completion Rate" value={`${completionRate}%`} index={3} />
233
227
  </div>
234
228
 
235
229
  {/* Token usage over time */}
236
- <ChartCard title="Token Usage Over Time">
237
- {timeSeriesFormatted.length > 0 ? (
238
- <ResponsiveContainer width="100%" height={280}>
239
- <LineChart data={timeSeriesFormatted} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
240
- <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
241
- <XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
242
- <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={formatTokens} />
243
- <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatTokens(value ?? 0), 'Tokens']} />
244
- <Line type="monotone" dataKey="tokens" stroke="#818CF8" strokeWidth={2} dot={false} activeDot={{ r: 4, fill: '#818CF8' }} />
245
- </LineChart>
246
- </ResponsiveContainer>
247
- ) : (
248
- <EmptyChart />
249
- )}
250
- </ChartCard>
230
+ <div style={{ animation: 'fade-up 0.6s var(--ease-spring) 0.2s both' }}>
231
+ <ChartCard title="Token Usage Over Time">
232
+ {timeSeriesFormatted.length > 0 ? (
233
+ <ResponsiveContainer width="100%" height={280}>
234
+ <LineChart data={timeSeriesFormatted} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
235
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
236
+ <XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
237
+ <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={formatTokens} />
238
+ <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatTokens(value ?? 0), 'Tokens']} />
239
+ <Line type="monotone" dataKey="tokens" stroke="#818CF8" strokeWidth={2} dot={false} activeDot={{ r: 4, fill: '#818CF8' }} />
240
+ </LineChart>
241
+ </ResponsiveContainer>
242
+ ) : (
243
+ <EmptyChart />
244
+ )}
245
+ </ChartCard>
246
+ </div>
251
247
 
252
248
  {/* Cost by provider + cost by agent */}
253
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
249
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6" style={{ animation: 'fade-up 0.6s var(--ease-spring) 0.25s both' }}>
254
250
  <ChartCard title="Cost by Provider">
255
251
  {providerData.length > 0 ? (
256
252
  <ResponsiveContainer width="100%" height={280}>
@@ -294,16 +290,16 @@ export function MetricsDashboard() {
294
290
 
295
291
  {/* Task KPIs */}
296
292
  {taskMetrics && (
297
- <>
293
+ <div style={{ animation: 'fade-up 0.6s var(--ease-spring) 0.3s both' }}>
298
294
  <h3 className="font-display text-[16px] font-700 text-text mt-2">Task Performance</h3>
299
- <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
300
- <StatCard label="Tasks Completed" value={String(taskMetrics.completedCount)} />
301
- <StatCard label="Avg Cycle Time" value={formatDuration(taskMetrics.avgCycleMs)} />
302
- <StatCard label="WIP" value={String(taskMetrics.wip)} />
303
- <StatCard label="Completion Rate" value={`${completionRate}%`} />
295
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
296
+ <StatCard label="Tasks Completed" value={String(taskMetrics.completedCount)} index={0} />
297
+ <StatCard label="Avg Cycle Time" value={formatDuration(taskMetrics.avgCycleMs)} index={1} />
298
+ <StatCard label="WIP" value={String(taskMetrics.wip)} index={2} />
299
+ <StatCard label="Completion Rate" value={`${completionRate}%`} index={3} />
304
300
  </div>
305
301
 
306
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
302
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
307
303
  <ChartCard title="Task Velocity">
308
304
  {taskMetrics.velocity.length > 0 ? (
309
305
  <ResponsiveContainer width="100%" height={280}>
@@ -346,41 +342,44 @@ export function MetricsDashboard() {
346
342
  )}
347
343
  </ChartCard>
348
344
  </div>
349
- </>
345
+ </div>
350
346
  )}
351
347
 
352
348
  {/* Latency by Provider */}
353
- <ChartCard title="Average Latency by Provider (ms)">
354
- {providerData.some(p => (data?.providerHealth?.[p.name]?.avgLatencyMs ?? 0) > 0) ? (
355
- <ResponsiveContainer width="100%" height={280}>
356
- <BarChart data={providerData.map(p => ({ ...p, latency: Math.round(data?.providerHealth?.[p.name]?.avgLatencyMs || 0) }))} layout="vertical" margin={{ top: 5, right: 30, bottom: 5, left: 40 }}>
357
- <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" horizontal={false} />
358
- <XAxis type="number" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
359
- <YAxis dataKey="name" type="category" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} width={80} />
360
- <Tooltip {...tooltipStyle} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
361
- <Bar dataKey="latency" radius={[0, 4, 4, 0]}>
362
- {providerData.map((_entry, index) => (
363
- <Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
364
- ))}
365
- </Bar>
366
- </BarChart>
367
- </ResponsiveContainer>
368
- ) : (
369
- <EmptyChart />
370
- )}
371
- </ChartCard>
349
+ <div style={{ animation: 'fade-up 0.6s var(--ease-spring) 0.35s both' }}>
350
+ <ChartCard title="Average Latency by Provider (ms)">
351
+ {providerData.some(p => (data?.providerHealth?.[p.name]?.avgLatencyMs ?? 0) > 0) ? (
352
+ <ResponsiveContainer width="100%" height={280}>
353
+ <BarChart data={providerData.map(p => ({ ...p, latency: Math.round(data?.providerHealth?.[p.name]?.avgLatencyMs || 0) }))} layout="vertical" margin={{ top: 5, right: 30, bottom: 5, left: 40 }}>
354
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" horizontal={false} />
355
+ <XAxis type="number" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
356
+ <YAxis dataKey="name" type="category" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} width={80} />
357
+ <Tooltip {...tooltipStyle} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
358
+ <Bar dataKey="latency" radius={[0, 4, 4, 0]}>
359
+ {providerData.map((_entry, index) => (
360
+ <Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
361
+ ))}
362
+ </Bar>
363
+ </BarChart>
364
+ </ResponsiveContainer>
365
+ ) : (
366
+ <EmptyChart />
367
+ )}
368
+ </ChartCard>
369
+ </div>
372
370
 
373
371
  {/* Provider Health */}
374
372
  {data?.providerHealth && Object.keys(data.providerHealth).length > 0 && (
375
- <div>
373
+ <div style={{ animation: 'fade-up 0.6s var(--ease-spring) 0.4s both' }}>
376
374
  <h3 className="font-display text-[14px] font-600 text-text-2 mb-3 flex items-center gap-2">Provider Health <HintTip text="API reliability and performance across your configured providers" /></h3>
377
375
  <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
378
376
  {Object.entries(data.providerHealth)
379
377
  .sort(([, a], [, b]) => b.totalRequests - a.totalRequests)
380
- .map(([name, h]) => (
378
+ .map(([name, h], idx) => (
381
379
  <div
382
380
  key={name}
383
- className="bg-surface-2 rounded-[12px] p-4 border border-white/[0.04] flex flex-col gap-3"
381
+ className="bg-surface-2 rounded-[12px] p-4 border border-white/[0.04] flex flex-col gap-3 hover:bg-surface transition-all hover:scale-[1.02]"
382
+ style={{ animation: 'spring-in 0.5s var(--ease-spring) both', animationDelay: `${0.45 + idx * 0.03}s` }}
384
383
  >
385
384
  <div className="flex items-center justify-between">
386
385
  <p className="text-[14px] font-600 text-text">{name}</p>
@@ -423,9 +422,12 @@ export function MetricsDashboard() {
423
422
  )
424
423
  }
425
424
 
426
- function StatCard({ label, value }: { label: string; value: string }) {
425
+ function StatCard({ label, value, index = 0 }: { label: string; value: string; index?: number }) {
427
426
  return (
428
- <div className="bg-surface-2 rounded-[12px] p-4 border border-white/[0.04]">
427
+ <div
428
+ className="bg-surface-2 rounded-[12px] p-4 border border-white/[0.04] hover:bg-surface transition-all hover:scale-[1.02]"
429
+ style={{ animation: 'spring-in 0.6s var(--ease-spring) both', animationDelay: `${0.1 + index * 0.05}s` }}
430
+ >
429
431
  <p className="text-[11px] font-500 text-text-3 uppercase tracking-[0.05em] mb-1">{label}</p>
430
432
  <p className="text-[22px] font-display font-700 tracking-[-0.02em] text-text">{value}</p>
431
433
  </div>
@@ -434,7 +436,7 @@ function StatCard({ label, value }: { label: string; value: string }) {
434
436
 
435
437
  function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
436
438
  return (
437
- <div className="bg-surface-2 rounded-[12px] p-5 border border-white/[0.04]">
439
+ <div className="bg-surface-2 rounded-[12px] p-5 border border-white/[0.04] hover:border-white/[0.1] transition-colors">
438
440
  <h3 className="font-display text-[14px] font-600 text-text-2 mb-4">{title}</h3>
439
441
  {children}
440
442
  </div>