@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.
- package/README.md +70 -45
- package/next.config.ts +31 -6
- package/package.json +3 -2
- package/src/app/api/agents/[id]/thread/route.ts +1 -0
- package/src/app/api/agents/route.ts +18 -5
- package/src/app/api/approvals/route.ts +22 -0
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
- package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
- package/src/app/api/memory/route.ts +36 -5
- package/src/app/api/notifications/route.ts +3 -0
- package/src/app/api/plugins/install/route.ts +57 -5
- package/src/app/api/plugins/marketplace/route.ts +73 -22
- package/src/app/api/plugins/route.ts +61 -1
- package/src/app/api/plugins/ui/route.ts +34 -0
- package/src/app/api/settings/route.ts +62 -0
- package/src/app/api/setup/doctor/route.ts +22 -5
- package/src/app/api/tasks/[id]/approve/route.ts +4 -3
- package/src/app/api/tasks/[id]/route.ts +11 -3
- package/src/app/api/tasks/route.ts +8 -2
- package/src/app/globals.css +27 -0
- package/src/app/page.tsx +10 -5
- package/src/cli/index.js +13 -0
- package/src/components/activity/activity-feed.tsx +9 -2
- package/src/components/agents/agent-avatar.tsx +5 -1
- package/src/components/agents/agent-card.tsx +55 -9
- package/src/components/agents/agent-sheet.tsx +86 -29
- package/src/components/agents/inspector-panel.tsx +1 -1
- package/src/components/auth/access-key-gate.tsx +63 -54
- package/src/components/auth/user-picker.tsx +37 -32
- package/src/components/chat/chat-area.tsx +11 -0
- package/src/components/chat/chat-header.tsx +69 -25
- package/src/components/chat/chat-tool-toggles.tsx +2 -2
- package/src/components/chat/code-block.tsx +3 -1
- package/src/components/chat/exec-approval-card.tsx +8 -1
- package/src/components/chat/message-bubble.tsx +164 -4
- package/src/components/chat/message-list.tsx +30 -4
- package/src/components/chat/session-approval-card.tsx +80 -0
- package/src/components/chat/streaming-bubble.tsx +6 -5
- package/src/components/chat/thinking-indicator.tsx +48 -12
- package/src/components/chat/tool-request-banner.tsx +39 -20
- package/src/components/chatrooms/chatroom-list.tsx +11 -4
- package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
- package/src/components/connectors/connector-list.tsx +33 -11
- package/src/components/connectors/connector-sheet.tsx +29 -6
- package/src/components/home/home-view.tsx +20 -14
- package/src/components/input/chat-input.tsx +22 -1
- package/src/components/knowledge/knowledge-list.tsx +17 -18
- package/src/components/knowledge/knowledge-sheet.tsx +9 -5
- package/src/components/layout/app-layout.tsx +73 -21
- package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
- package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
- package/src/components/memory/memory-list.tsx +20 -13
- package/src/components/plugins/plugin-list.tsx +213 -59
- package/src/components/plugins/plugin-sheet.tsx +119 -24
- package/src/components/projects/project-list.tsx +17 -9
- package/src/components/providers/provider-list.tsx +21 -6
- package/src/components/providers/provider-sheet.tsx +42 -25
- package/src/components/runs/run-list.tsx +17 -13
- package/src/components/schedules/schedule-card.tsx +10 -3
- package/src/components/schedules/schedule-list.tsx +2 -2
- package/src/components/schedules/schedule-sheet.tsx +19 -7
- package/src/components/secrets/secret-sheet.tsx +7 -2
- package/src/components/secrets/secrets-list.tsx +18 -5
- package/src/components/sessions/new-session-sheet.tsx +183 -376
- package/src/components/sessions/session-card.tsx +10 -2
- package/src/components/settings/gateway-connection-panel.tsx +9 -8
- package/src/components/shared/command-palette.tsx +13 -5
- package/src/components/shared/empty-state.tsx +20 -8
- package/src/components/shared/notification-center.tsx +134 -86
- package/src/components/shared/profile-sheet.tsx +4 -0
- package/src/components/shared/settings/plugin-manager.tsx +360 -135
- package/src/components/shared/settings/section-capability-policy.tsx +3 -3
- package/src/components/shared/settings/section-runtime-loop.tsx +144 -0
- package/src/components/skills/clawhub-browser.tsx +1 -0
- package/src/components/skills/skill-list.tsx +31 -12
- package/src/components/skills/skill-sheet.tsx +20 -7
- package/src/components/tasks/approvals-panel.tsx +170 -66
- package/src/components/tasks/task-board.tsx +20 -12
- package/src/components/tasks/task-card.tsx +21 -7
- package/src/components/tasks/task-column.tsx +4 -3
- package/src/components/tasks/task-list.tsx +1 -1
- package/src/components/tasks/task-sheet.tsx +130 -1
- package/src/components/ui/dialog.tsx +1 -0
- package/src/components/ui/sheet.tsx +1 -0
- package/src/components/usage/metrics-dashboard.tsx +66 -64
- package/src/components/wallets/wallet-panel.tsx +65 -41
- package/src/components/wallets/wallet-section.tsx +9 -3
- package/src/components/webhooks/webhook-list.tsx +21 -12
- package/src/components/webhooks/webhook-sheet.tsx +13 -3
- package/src/lib/approval-display.test.ts +45 -0
- package/src/lib/approval-display.ts +62 -0
- package/src/lib/clipboard.ts +38 -0
- package/src/lib/memory.ts +8 -0
- package/src/lib/providers/claude-cli.ts +5 -3
- package/src/lib/providers/index.ts +67 -21
- package/src/lib/runtime-loop.ts +3 -2
- package/src/lib/server/approvals.ts +150 -0
- package/src/lib/server/chat-execution.ts +223 -62
- package/src/lib/server/clawhub-client.ts +82 -6
- package/src/lib/server/connectors/manager.ts +27 -1
- package/src/lib/server/cost.test.ts +73 -0
- package/src/lib/server/cost.ts +165 -34
- package/src/lib/server/daemon-state.ts +42 -0
- package/src/lib/server/data-dir.ts +18 -1
- package/src/lib/server/integrity-monitor.ts +208 -0
- package/src/lib/server/llm-response-cache.test.ts +102 -0
- package/src/lib/server/llm-response-cache.ts +227 -0
- package/src/lib/server/main-agent-loop.ts +1 -1
- package/src/lib/server/main-session.ts +6 -3
- package/src/lib/server/mcp-conformance.test.ts +18 -0
- package/src/lib/server/mcp-conformance.ts +233 -0
- package/src/lib/server/memory-db.ts +180 -17
- package/src/lib/server/memory-retrieval.test.ts +56 -0
- package/src/lib/server/orchestrator-lg.ts +4 -1
- package/src/lib/server/orchestrator.ts +4 -3
- package/src/lib/server/plugins.ts +650 -142
- package/src/lib/server/process-manager.ts +18 -0
- package/src/lib/server/queue.ts +253 -11
- package/src/lib/server/runtime-settings.ts +9 -0
- package/src/lib/server/session-run-manager.test.ts +23 -0
- package/src/lib/server/session-run-manager.ts +11 -1
- package/src/lib/server/session-tools/canvas.ts +85 -50
- package/src/lib/server/session-tools/chatroom.ts +130 -127
- package/src/lib/server/session-tools/connector.ts +233 -454
- package/src/lib/server/session-tools/context-mgmt.ts +87 -105
- package/src/lib/server/session-tools/crud.ts +84 -7
- package/src/lib/server/session-tools/delegate.ts +351 -752
- package/src/lib/server/session-tools/discovery.ts +198 -0
- package/src/lib/server/session-tools/edit_file.ts +82 -0
- package/src/lib/server/session-tools/file-send.test.ts +39 -0
- package/src/lib/server/session-tools/file.ts +257 -425
- package/src/lib/server/session-tools/git.ts +87 -47
- package/src/lib/server/session-tools/http.ts +85 -33
- package/src/lib/server/session-tools/index.ts +205 -160
- package/src/lib/server/session-tools/memory.ts +152 -265
- package/src/lib/server/session-tools/monitor.ts +126 -0
- package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
- package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
- package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
- package/src/lib/server/session-tools/platform.ts +86 -0
- package/src/lib/server/session-tools/plugin-creator.ts +239 -0
- package/src/lib/server/session-tools/sample-ui.ts +97 -0
- package/src/lib/server/session-tools/sandbox.ts +175 -148
- package/src/lib/server/session-tools/schedule.ts +66 -31
- package/src/lib/server/session-tools/session-info.ts +104 -410
- package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
- package/src/lib/server/session-tools/shell.ts +171 -143
- package/src/lib/server/session-tools/subagent.ts +77 -77
- package/src/lib/server/session-tools/wallet.ts +182 -106
- package/src/lib/server/session-tools/web.ts +179 -349
- package/src/lib/server/storage.ts +24 -0
- package/src/lib/server/stream-agent-chat.ts +301 -244
- package/src/lib/server/task-quality-gate.test.ts +44 -0
- package/src/lib/server/task-quality-gate.ts +67 -0
- package/src/lib/server/task-validation.test.ts +78 -0
- package/src/lib/server/task-validation.ts +67 -2
- package/src/lib/server/tool-aliases.ts +68 -0
- package/src/lib/server/tool-capability-policy.ts +23 -5
- package/src/lib/tasks.ts +7 -1
- package/src/lib/tool-definitions.ts +23 -23
- package/src/lib/validation/schemas.ts +12 -0
- package/src/lib/view-routes.ts +2 -24
- package/src/stores/use-app-store.ts +23 -1
- 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
|
-
<
|
|
413
|
+
columns.map((status, idx) => (
|
|
414
|
+
<div
|
|
415
415
|
key={status}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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}
|
|
@@ -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
|
-
|
|
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 & 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
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
<
|
|
240
|
-
<
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
<
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
<
|
|
357
|
-
<
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
{
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
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>
|