@swarmclawai/swarmclaw 1.7.3 → 1.8.1
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 +20 -0
- package/next.config.ts +38 -8
- package/package.json +2 -2
- package/scripts/run-next-build.mjs +51 -3
- package/src/app/api/artifacts/route.ts +15 -0
- package/src/app/api/clawhub/install/route.ts +4 -4
- package/src/app/api/dirs/route.ts +8 -5
- package/src/app/api/files/open/route.ts +3 -3
- package/src/app/api/files/serve/route.ts +2 -2
- package/src/app/api/operations/pulse/route.ts +9 -0
- package/src/app/api/runs/[id]/brief/route.ts +12 -0
- package/src/app/api/runs/[id]/events/route.ts +4 -13
- package/src/app/api/runs/[id]/route.ts +2 -6
- package/src/app/api/runs/route.ts +3 -43
- package/src/app/api/s/[token]/raw/route.ts +1 -1
- package/src/app/home/page.tsx +11 -1
- package/src/app/missions/page.tsx +182 -3
- package/src/app/s/[token]/page.tsx +173 -48
- package/src/cli/index.js +15 -0
- package/src/cli/spec.js +13 -0
- package/src/components/connectors/connector-list.tsx +36 -20
- package/src/components/evidence/evidence-shelf.tsx +97 -0
- package/src/components/home/home-launchpad.tsx +52 -2
- package/src/components/missions/mission-template-install-dialog.tsx +33 -1
- package/src/components/operations/operations-pulse-panel.tsx +184 -0
- package/src/components/quality/quality-workspace.tsx +34 -6
- package/src/components/runs/run-list.tsx +94 -12
- package/src/lib/connectors/connector-readiness.ts +127 -0
- package/src/lib/server/artifacts/artifact-resolver.test.ts +98 -0
- package/src/lib/server/artifacts/artifact-resolver.ts +241 -0
- package/src/lib/server/operations/operation-pulse.test.ts +108 -0
- package/src/lib/server/operations/operation-pulse.ts +197 -0
- package/src/lib/server/resolve-workspace-path.ts +10 -10
- package/src/lib/server/runs/run-brief.test.ts +92 -0
- package/src/lib/server/runs/run-brief.ts +107 -0
- package/src/lib/server/runs/unified-run-queries.ts +84 -0
- package/src/lib/server/sharing/share-resolver.test.ts +129 -0
- package/src/lib/server/sharing/share-resolver.ts +48 -3
- package/src/types/artifact.ts +28 -0
- package/src/types/index.ts +3 -0
- package/src/types/operations.ts +39 -0
- package/src/types/run-brief.ts +41 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
4
|
import { useAppStore } from '@/stores/use-app-store'
|
|
5
5
|
import type { Connector } from '@/types'
|
|
6
|
+
import { getConnectorReadiness, hasConnectorCredentials } from '@/lib/connectors/connector-readiness'
|
|
6
7
|
import {
|
|
7
8
|
ConnectorPlatformIcon,
|
|
8
9
|
ConnectorPlatformBadge,
|
|
@@ -27,20 +28,10 @@ function relativeTime(ts: number): string {
|
|
|
27
28
|
|
|
28
29
|
type ConnectorGroup = 'needs-setup' | 'attention' | 'healthy'
|
|
29
30
|
|
|
30
|
-
function hasConnectorCredentials(connector: Connector): boolean {
|
|
31
|
-
return connector.platform === 'whatsapp'
|
|
32
|
-
|| connector.platform === 'openclaw'
|
|
33
|
-
|| connector.platform === 'signal'
|
|
34
|
-
|| (connector.platform === 'bluebubbles' && (!!connector.credentialId || !!connector.config?.password))
|
|
35
|
-
|| !!connector.credentialId
|
|
36
|
-
}
|
|
37
|
-
|
|
38
31
|
function getConnectorGroup(connector: Connector): ConnectorGroup {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (connector.status === 'running' && !connector.lastError) return 'healthy'
|
|
43
|
-
return 'attention'
|
|
32
|
+
const readiness = getConnectorReadiness(connector)
|
|
33
|
+
if (readiness.state === 'needs_setup') return 'needs-setup'
|
|
34
|
+
return readiness.state
|
|
44
35
|
}
|
|
45
36
|
|
|
46
37
|
export function ConnectorList({ inSidebar }: { inSidebar?: boolean }) {
|
|
@@ -274,12 +265,14 @@ export function ConnectorList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
274
265
|
const isToggling = toggling === c.id
|
|
275
266
|
const hasCredentials = hasConnectorCredentials(c)
|
|
276
267
|
const lastMsg = c.presence?.lastMessageAt
|
|
277
|
-
const
|
|
278
|
-
const issues =
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
268
|
+
const readiness = getConnectorReadiness(c)
|
|
269
|
+
const issues = readiness.checks
|
|
270
|
+
.filter((check) => check.status !== 'ready')
|
|
271
|
+
.map((check) => ({
|
|
272
|
+
label: check.label,
|
|
273
|
+
detail: check.detail,
|
|
274
|
+
tone: check.status === 'error' ? 'text-red-400 bg-red-500/10' : 'text-amber-300 bg-amber-500/10',
|
|
275
|
+
}))
|
|
283
276
|
|
|
284
277
|
return (
|
|
285
278
|
<div
|
|
@@ -359,7 +352,7 @@ export function ConnectorList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
359
352
|
{issues.length > 0 ? (
|
|
360
353
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
361
354
|
{issues.map((issue) => (
|
|
362
|
-
<span key={issue.label} className={`px-2 py-1 rounded-[7px] text-[10px] font-700 ${issue.tone}`}>
|
|
355
|
+
<span key={issue.label} title={issue.detail} className={`px-2 py-1 rounded-[7px] text-[10px] font-700 ${issue.tone}`}>
|
|
363
356
|
{issue.label}
|
|
364
357
|
</span>
|
|
365
358
|
))}
|
|
@@ -370,6 +363,21 @@ export function ConnectorList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
370
363
|
</div>
|
|
371
364
|
)}
|
|
372
365
|
|
|
366
|
+
<div className="mb-3 rounded-[10px] border border-white/[0.04] bg-white/[0.02] px-3 py-2">
|
|
367
|
+
<div className="text-[10px] font-700 uppercase tracking-[0.08em] text-text-3/55">Readiness</div>
|
|
368
|
+
<div className="mt-1 text-[11px] text-text-2">{readiness.summary}</div>
|
|
369
|
+
<div className="mt-2 flex flex-col gap-1">
|
|
370
|
+
{readiness.checks.slice(0, 3).map((check) => (
|
|
371
|
+
<div key={check.id} className="flex items-start justify-between gap-2 text-[10px]">
|
|
372
|
+
<span className="text-text-3/65">{check.label}</span>
|
|
373
|
+
<span className={`max-w-[150px] break-words text-right ${check.status === 'ready' ? 'text-emerald-300' : check.status === 'error' ? 'text-red-300' : 'text-amber-300'}`}>
|
|
374
|
+
{check.detail}
|
|
375
|
+
</span>
|
|
376
|
+
</div>
|
|
377
|
+
))}
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
373
381
|
<div className="flex items-center gap-2 mt-auto pt-2 border-t border-white/[0.04]">
|
|
374
382
|
{c.lastError ? (
|
|
375
383
|
<span className="text-[10px] text-red-400 truncate flex-1">
|
|
@@ -382,6 +390,14 @@ export function ConnectorList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
382
390
|
)}
|
|
383
391
|
|
|
384
392
|
<div className="flex gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
393
|
+
<a
|
|
394
|
+
href={readiness.doctorHref}
|
|
395
|
+
target="_blank"
|
|
396
|
+
rel="noreferrer"
|
|
397
|
+
className="px-2 py-1 rounded-[6px] text-[10px] font-600 transition-all opacity-0 group-hover:opacity-100 bg-white/[0.05] text-text-3 hover:bg-white/[0.08] hover:text-text-2"
|
|
398
|
+
>
|
|
399
|
+
Doctor
|
|
400
|
+
</a>
|
|
385
401
|
{c.status === 'error' && hasCredentials && (
|
|
386
402
|
<button
|
|
387
403
|
onClick={(e) => handleReconnect(e, c)}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ExternalLink, FileText } from 'lucide-react'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
import type { EvidenceArtifact } from '@/types'
|
|
6
|
+
|
|
7
|
+
function formatKind(kind: EvidenceArtifact['kind']): string {
|
|
8
|
+
return kind.split('_').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatTimestamp(at: number | null | undefined): string {
|
|
12
|
+
if (!at) return ''
|
|
13
|
+
return new Date(at).toLocaleString()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function EvidenceShelf({
|
|
17
|
+
artifacts,
|
|
18
|
+
loading = false,
|
|
19
|
+
title = 'Evidence Shelf',
|
|
20
|
+
emptyLabel = 'No linked evidence yet.',
|
|
21
|
+
className,
|
|
22
|
+
}: {
|
|
23
|
+
artifacts: EvidenceArtifact[]
|
|
24
|
+
loading?: boolean
|
|
25
|
+
title?: string
|
|
26
|
+
emptyLabel?: string
|
|
27
|
+
className?: string
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<section className={cn('rounded-[12px] border border-white/[0.06] bg-white/[0.025] p-4', className)}>
|
|
31
|
+
<div className="mb-3 flex items-center justify-between gap-3">
|
|
32
|
+
<div>
|
|
33
|
+
<div className="text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/55">{title}</div>
|
|
34
|
+
<div className="mt-1 text-[12px] text-text-3/65">{artifacts.length} linked artifact{artifacts.length === 1 ? '' : 's'}</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
{loading ? (
|
|
38
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-3 text-[11px] text-text-3/60">
|
|
39
|
+
Loading evidence...
|
|
40
|
+
</div>
|
|
41
|
+
) : artifacts.length === 0 ? (
|
|
42
|
+
<div className="rounded-[10px] border border-dashed border-white/[0.08] bg-white/[0.02] px-3 py-3 text-[11px] text-text-3/60">
|
|
43
|
+
{emptyLabel}
|
|
44
|
+
</div>
|
|
45
|
+
) : (
|
|
46
|
+
<div className="flex max-h-[280px] flex-col gap-2 overflow-y-auto">
|
|
47
|
+
{artifacts.map((artifact) => {
|
|
48
|
+
const href = artifact.url || artifact.href || null
|
|
49
|
+
const content = (
|
|
50
|
+
<>
|
|
51
|
+
<span className="flex min-w-0 flex-1 items-start gap-2">
|
|
52
|
+
<FileText size={14} className="mt-0.5 shrink-0 text-text-3/70" />
|
|
53
|
+
<span className="min-w-0">
|
|
54
|
+
<span className="flex flex-wrap items-center gap-2">
|
|
55
|
+
<span className="truncate text-[12px] font-700 text-text">{artifact.title}</span>
|
|
56
|
+
<span className="rounded-full bg-white/[0.05] px-2 py-0.5 text-[9px] font-800 uppercase tracking-[0.08em] text-text-3/70">
|
|
57
|
+
{formatKind(artifact.kind)}
|
|
58
|
+
</span>
|
|
59
|
+
</span>
|
|
60
|
+
{(artifact.description || artifact.preview) && (
|
|
61
|
+
<span className="mt-1 line-clamp-2 block text-[11px] leading-relaxed text-text-3/68">
|
|
62
|
+
{artifact.description || artifact.preview}
|
|
63
|
+
</span>
|
|
64
|
+
)}
|
|
65
|
+
<span className="mt-1 block text-[10px] text-text-3/45">
|
|
66
|
+
{artifact.source.label || artifact.source.id}
|
|
67
|
+
{artifact.createdAt ? ` - ${formatTimestamp(artifact.createdAt)}` : ''}
|
|
68
|
+
</span>
|
|
69
|
+
</span>
|
|
70
|
+
</span>
|
|
71
|
+
{href && <ExternalLink size={13} className="mt-0.5 shrink-0 text-text-3/65" />}
|
|
72
|
+
</>
|
|
73
|
+
)
|
|
74
|
+
return href ? (
|
|
75
|
+
<a
|
|
76
|
+
key={`${artifact.kind}:${artifact.id}`}
|
|
77
|
+
href={href}
|
|
78
|
+
target={href.startsWith('/api/') || href.startsWith('http') ? '_blank' : undefined}
|
|
79
|
+
rel={href.startsWith('http') ? 'noreferrer' : undefined}
|
|
80
|
+
className="flex items-start gap-2 rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2.5 transition-colors hover:bg-white/[0.05]"
|
|
81
|
+
>
|
|
82
|
+
{content}
|
|
83
|
+
</a>
|
|
84
|
+
) : (
|
|
85
|
+
<div
|
|
86
|
+
key={`${artifact.kind}:${artifact.id}`}
|
|
87
|
+
className="flex items-start gap-2 rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2.5"
|
|
88
|
+
>
|
|
89
|
+
{content}
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</section>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
4
|
+
import { OperationsPulsePanel } from '@/components/operations/operations-pulse-panel'
|
|
4
5
|
import { LaunchActionCard } from '@/components/shared/launch-action-card'
|
|
5
6
|
import type { Agent } from '@/types'
|
|
6
7
|
|
|
@@ -73,6 +74,9 @@ type Props = {
|
|
|
73
74
|
onReviewApprovals: () => void
|
|
74
75
|
onInspectFailedRuns: () => void
|
|
75
76
|
onStartReleaseQaMission: () => void
|
|
77
|
+
onStartLaunchSprintMission: () => void
|
|
78
|
+
onStartCostAuditMission: () => void
|
|
79
|
+
onStartConnectorSmokeMission: () => void
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
export function HomeLaunchpad({
|
|
@@ -92,12 +96,15 @@ export function HomeLaunchpad({
|
|
|
92
96
|
onReviewApprovals,
|
|
93
97
|
onInspectFailedRuns,
|
|
94
98
|
onStartReleaseQaMission,
|
|
99
|
+
onStartLaunchSprintMission,
|
|
100
|
+
onStartCostAuditMission,
|
|
101
|
+
onStartConnectorSmokeMission,
|
|
95
102
|
}: Props) {
|
|
96
103
|
return (
|
|
97
104
|
<div className="max-w-[980px] mx-auto px-6 py-10">
|
|
98
105
|
<div className="rounded-[20px] border border-white/[0.06] bg-white/[0.025] p-6">
|
|
99
106
|
<div className="inline-flex rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-[11px] font-700 uppercase tracking-[0.16em] text-text-3/70">
|
|
100
|
-
|
|
107
|
+
Mission Command
|
|
101
108
|
</div>
|
|
102
109
|
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
103
110
|
<div className="max-w-[620px]">
|
|
@@ -136,6 +143,8 @@ export function HomeLaunchpad({
|
|
|
136
143
|
</div>
|
|
137
144
|
</div>
|
|
138
145
|
|
|
146
|
+
<OperationsPulsePanel className="mt-6" compact />
|
|
147
|
+
|
|
139
148
|
<div className="mt-6 grid gap-3 lg:grid-cols-3">
|
|
140
149
|
<PathCard
|
|
141
150
|
kicker="Self-hosted assistant"
|
|
@@ -159,13 +168,54 @@ export function HomeLaunchpad({
|
|
|
159
168
|
kicker="Autonomous mission"
|
|
160
169
|
title="Run with budgets"
|
|
161
170
|
description="Start a mission template for release QA, research, support triage, cost audit, or failed-run review with reports and caps."
|
|
162
|
-
primaryLabel="
|
|
171
|
+
primaryLabel="Release QA"
|
|
163
172
|
secondaryLabel="Quality Center"
|
|
164
173
|
onPrimary={onStartReleaseQaMission}
|
|
165
174
|
onSecondary={onRunEvalSuite}
|
|
166
175
|
/>
|
|
167
176
|
</div>
|
|
168
177
|
|
|
178
|
+
<div className="mt-6 rounded-[18px] border border-white/[0.06] bg-white/[0.025] p-4">
|
|
179
|
+
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
180
|
+
<div>
|
|
181
|
+
<div className="text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/55">Mission starters</div>
|
|
182
|
+
<p className="mt-1 max-w-[620px] text-[12px] leading-relaxed text-text-3/68">
|
|
183
|
+
Jump directly into the workflows that produce reusable evidence and shareable reports.
|
|
184
|
+
</p>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="flex flex-wrap gap-2">
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={onStartReleaseQaMission}
|
|
190
|
+
className="rounded-[10px] border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-[12px] font-display font-700 text-emerald-200 hover:bg-emerald-500/15"
|
|
191
|
+
>
|
|
192
|
+
Release QA
|
|
193
|
+
</button>
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
onClick={onStartLaunchSprintMission}
|
|
197
|
+
className="rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-[12px] font-display font-700 text-text-2 hover:bg-white/[0.08]"
|
|
198
|
+
>
|
|
199
|
+
Launch Sprint
|
|
200
|
+
</button>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={onStartCostAuditMission}
|
|
204
|
+
className="rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-[12px] font-display font-700 text-text-2 hover:bg-white/[0.08]"
|
|
205
|
+
>
|
|
206
|
+
Cost Audit
|
|
207
|
+
</button>
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
onClick={onStartConnectorSmokeMission}
|
|
211
|
+
className="rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-[12px] font-display font-700 text-text-2 hover:bg-white/[0.08]"
|
|
212
|
+
>
|
|
213
|
+
Connector Smoke
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
169
219
|
<div className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
170
220
|
<LaunchActionCard
|
|
171
221
|
title={firstAgent ? 'Open First Agent Chat' : 'Open Agents'}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { api } from '@/lib/app/api-client'
|
|
4
5
|
import { HintTip } from '@/components/shared/hint-tip'
|
|
5
6
|
import { AdvancedSettingsSection } from '@/components/shared/advanced-settings-section'
|
|
6
7
|
import { inputClass } from '@/components/shared/form-styles'
|
|
@@ -28,6 +29,7 @@ interface Props {
|
|
|
28
29
|
sessions: Session[]
|
|
29
30
|
onClose: () => void
|
|
30
31
|
onInstall: (template: MissionTemplate, input: InstantiateInput) => Promise<void>
|
|
32
|
+
onSessionCreated?: (session: Session) => void
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
function formatDuration(sec: number | null | undefined): string {
|
|
@@ -48,7 +50,7 @@ function intOrNull(s: string): number | null {
|
|
|
48
50
|
return n == null ? null : Math.round(n)
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
export function MissionTemplateInstallDialog({ template, sessions, onClose, onInstall }: Props) {
|
|
53
|
+
export function MissionTemplateInstallDialog({ template, sessions, onClose, onInstall, onSessionCreated }: Props) {
|
|
52
54
|
const [title, setTitle] = useState('')
|
|
53
55
|
const [goal, setGoal] = useState('')
|
|
54
56
|
const [criteriaText, setCriteriaText] = useState('')
|
|
@@ -134,6 +136,26 @@ export function MissionTemplateInstallDialog({ template, sessions, onClose, onIn
|
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
const createDriverSession = async () => {
|
|
140
|
+
if (!template) return
|
|
141
|
+
setBusy(true)
|
|
142
|
+
try {
|
|
143
|
+
const session = await api<Session>('POST', '/chats', {
|
|
144
|
+
name: `${template.name} mission driver`,
|
|
145
|
+
sessionType: 'human',
|
|
146
|
+
heartbeatEnabled: true,
|
|
147
|
+
heartbeatIntervalSec: 300,
|
|
148
|
+
})
|
|
149
|
+
setRootSessionId(session.id)
|
|
150
|
+
onSessionCreated?.(session)
|
|
151
|
+
toast.success('Mission driver chat created')
|
|
152
|
+
} catch (error) {
|
|
153
|
+
toast.error(error instanceof Error ? error.message : 'Unable to create mission driver chat')
|
|
154
|
+
} finally {
|
|
155
|
+
setBusy(false)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
137
159
|
return (
|
|
138
160
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
139
161
|
<div
|
|
@@ -197,6 +219,16 @@ export function MissionTemplateInstallDialog({ template, sessions, onClose, onIn
|
|
|
197
219
|
</option>
|
|
198
220
|
))}
|
|
199
221
|
</select>
|
|
222
|
+
{sessions.length === 0 && (
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
onClick={() => void createDriverSession()}
|
|
226
|
+
disabled={busy}
|
|
227
|
+
className="mt-2 self-start rounded-[10px] border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-[12px] font-700 text-emerald-200 hover:bg-emerald-500/15 disabled:opacity-40"
|
|
228
|
+
>
|
|
229
|
+
Create mission driver chat
|
|
230
|
+
</button>
|
|
231
|
+
)}
|
|
200
232
|
</label>
|
|
201
233
|
</div>
|
|
202
234
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { AlertTriangle, CheckCircle2, Clock, PlugZap, RefreshCw } from 'lucide-react'
|
|
6
|
+
import { api } from '@/lib/app/api-client'
|
|
7
|
+
import { cn } from '@/lib/utils'
|
|
8
|
+
import type { OperationPulse, OperationPulseAction, OperationPulseRange, OperationPulseSeverity } from '@/types'
|
|
9
|
+
|
|
10
|
+
const SEVERITY_CLASS: Record<OperationPulseSeverity, string> = {
|
|
11
|
+
high: 'border-rose-500/20 bg-rose-500/[0.06] text-rose-200',
|
|
12
|
+
medium: 'border-amber-500/20 bg-amber-500/[0.06] text-amber-200',
|
|
13
|
+
low: 'border-white/[0.06] bg-white/[0.025] text-text-2',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatRelative(at: number | null, generatedAt: number): string {
|
|
17
|
+
if (!at) return 'recent'
|
|
18
|
+
const diff = Math.max(0, generatedAt - at)
|
|
19
|
+
if (diff < 60_000) return 'just now'
|
|
20
|
+
if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`
|
|
21
|
+
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`
|
|
22
|
+
return `${Math.round(diff / 86_400_000)}d ago`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function kpiTone(value: number, danger = false): string {
|
|
26
|
+
if (value <= 0) return 'text-text'
|
|
27
|
+
return danger ? 'text-rose-300' : 'text-amber-300'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function Kpi({ label, value, danger = false }: { label: string; value: number; danger?: boolean }) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="min-w-[110px] rounded-[12px] border border-white/[0.06] bg-white/[0.025] px-3 py-2">
|
|
33
|
+
<div className="text-[10px] font-700 uppercase tracking-[0.1em] text-text-3/55">{label}</div>
|
|
34
|
+
<div className={cn('mt-1 font-display text-[22px] font-700 tracking-normal', kpiTone(value, danger))}>{value}</div>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function actionIcon(action: OperationPulseAction) {
|
|
40
|
+
if (action.severity === 'high') return <AlertTriangle size={15} />
|
|
41
|
+
if (action.kind === 'connector') return <PlugZap size={15} />
|
|
42
|
+
if (action.kind === 'mission') return <Clock size={15} />
|
|
43
|
+
return <CheckCircle2 size={15} />
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function OperationsPulsePanel({
|
|
47
|
+
defaultRange = '24h',
|
|
48
|
+
className,
|
|
49
|
+
compact = false,
|
|
50
|
+
}: {
|
|
51
|
+
defaultRange?: OperationPulseRange
|
|
52
|
+
className?: string
|
|
53
|
+
compact?: boolean
|
|
54
|
+
}) {
|
|
55
|
+
const router = useRouter()
|
|
56
|
+
const [range, setRange] = useState<OperationPulseRange>(defaultRange)
|
|
57
|
+
const [pulse, setPulse] = useState<OperationPulse | null>(null)
|
|
58
|
+
const [loading, setLoading] = useState(true)
|
|
59
|
+
const [refreshing, setRefreshing] = useState(false)
|
|
60
|
+
|
|
61
|
+
const loadPulse = useCallback(async (nextRange = range, silent = false) => {
|
|
62
|
+
if (silent) setRefreshing(true)
|
|
63
|
+
else setLoading(true)
|
|
64
|
+
try {
|
|
65
|
+
const next = await api<OperationPulse>('GET', `/operations/pulse?range=${nextRange}`)
|
|
66
|
+
setPulse(next)
|
|
67
|
+
} catch {
|
|
68
|
+
setPulse(null)
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false)
|
|
71
|
+
setRefreshing(false)
|
|
72
|
+
}
|
|
73
|
+
}, [range])
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
void loadPulse(range)
|
|
77
|
+
}, [loadPulse, range])
|
|
78
|
+
|
|
79
|
+
const actions = pulse?.actions || []
|
|
80
|
+
const stable = useMemo(() => {
|
|
81
|
+
if (!pulse) return false
|
|
82
|
+
return pulse.kpis.failedRuns === 0
|
|
83
|
+
&& pulse.kpis.pendingApprovals === 0
|
|
84
|
+
&& pulse.kpis.connectorAttention === 0
|
|
85
|
+
&& pulse.kpis.budgetWarnings === 0
|
|
86
|
+
}, [pulse])
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<section className={cn('rounded-[16px] border border-white/[0.06] bg-white/[0.025] p-4', className)}>
|
|
90
|
+
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
91
|
+
<div>
|
|
92
|
+
<div className="text-[10px] font-700 uppercase tracking-[0.16em] text-accent-bright/70">Operations Pulse</div>
|
|
93
|
+
<h2 className="mt-1 font-display text-[16px] font-700 tracking-normal text-text">What needs operator attention next</h2>
|
|
94
|
+
<p className="mt-1 max-w-[680px] text-[12px] leading-relaxed text-text-3/68">
|
|
95
|
+
Missions, runs, approvals, connector readiness, and budget pressure rolled into one triage queue.
|
|
96
|
+
</p>
|
|
97
|
+
</div>
|
|
98
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
99
|
+
{(['24h', '7d'] as const).map((item) => (
|
|
100
|
+
<button
|
|
101
|
+
key={item}
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={() => setRange(item)}
|
|
104
|
+
className={cn(
|
|
105
|
+
'rounded-[9px] px-2.5 py-1.5 text-[11px] font-700 transition-colors',
|
|
106
|
+
range === item ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.04] text-text-3 hover:bg-white/[0.08] hover:text-text-2',
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
{item}
|
|
110
|
+
</button>
|
|
111
|
+
))}
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={() => void loadPulse(range, true)}
|
|
115
|
+
className="inline-flex items-center gap-1.5 rounded-[9px] border border-white/[0.08] bg-white/[0.04] px-2.5 py-1.5 text-[11px] font-700 text-text-2 hover:bg-white/[0.08]"
|
|
116
|
+
>
|
|
117
|
+
<RefreshCw size={13} className={refreshing ? 'animate-spin' : ''} />
|
|
118
|
+
Refresh
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{loading ? (
|
|
124
|
+
<div className="mt-4 rounded-[12px] border border-white/[0.05] bg-white/[0.02] px-3 py-4 text-[12px] text-text-3/60">
|
|
125
|
+
Loading pulse...
|
|
126
|
+
</div>
|
|
127
|
+
) : !pulse ? (
|
|
128
|
+
<div className="mt-4 rounded-[12px] border border-rose-500/20 bg-rose-500/[0.06] px-3 py-3 text-[12px] text-rose-200">
|
|
129
|
+
Operations pulse is unavailable.
|
|
130
|
+
</div>
|
|
131
|
+
) : (
|
|
132
|
+
<>
|
|
133
|
+
<div className={cn('mt-4 grid gap-2', compact ? 'grid-cols-2 md:grid-cols-3 xl:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6')}>
|
|
134
|
+
<Kpi label="Missions" value={pulse.kpis.activeMissions} />
|
|
135
|
+
<Kpi label="Running" value={pulse.kpis.runningRuns} />
|
|
136
|
+
<Kpi label="Failed" value={pulse.kpis.failedRuns} danger />
|
|
137
|
+
<Kpi label="Approvals" value={pulse.kpis.pendingApprovals} />
|
|
138
|
+
<Kpi label="Connectors" value={pulse.kpis.connectorAttention} danger />
|
|
139
|
+
<Kpi label="Budgets" value={pulse.kpis.budgetWarnings} />
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div className="mt-4">
|
|
143
|
+
{stable || actions.length === 0 ? (
|
|
144
|
+
<div className="rounded-[12px] border border-emerald-500/15 bg-emerald-500/[0.05] px-3 py-3 text-[12px] text-emerald-200">
|
|
145
|
+
No current blockers in the selected window.
|
|
146
|
+
</div>
|
|
147
|
+
) : (
|
|
148
|
+
<div className="grid gap-2 lg:grid-cols-2">
|
|
149
|
+
{actions.map((action) => (
|
|
150
|
+
<button
|
|
151
|
+
key={action.id}
|
|
152
|
+
type="button"
|
|
153
|
+
onClick={() => router.push(action.href)}
|
|
154
|
+
className={cn('rounded-[12px] border px-3 py-3 text-left transition-colors hover:bg-white/[0.06]', SEVERITY_CLASS[action.severity])}
|
|
155
|
+
>
|
|
156
|
+
<div className="flex items-start gap-2">
|
|
157
|
+
<span className="mt-0.5 shrink-0">{actionIcon(action)}</span>
|
|
158
|
+
<span className="min-w-0 flex-1">
|
|
159
|
+
<span className="flex items-center justify-between gap-3">
|
|
160
|
+
<span className="truncate text-[12px] font-800 text-text">{action.title}</span>
|
|
161
|
+
<span className="shrink-0 text-[10px] text-text-3/55">{formatRelative(action.createdAt, pulse.generatedAt)}</span>
|
|
162
|
+
</span>
|
|
163
|
+
<span className="mt-1 line-clamp-2 block text-[12px] leading-relaxed text-text-3/72">{action.summary}</span>
|
|
164
|
+
{action.evidence.length > 0 && (
|
|
165
|
+
<span className="mt-2 flex flex-wrap gap-1.5">
|
|
166
|
+
{action.evidence.slice(0, 2).map((item) => (
|
|
167
|
+
<span key={item} className="rounded-full bg-white/[0.06] px-2 py-0.5 text-[10px] font-700 text-text-3/80">
|
|
168
|
+
{item}
|
|
169
|
+
</span>
|
|
170
|
+
))}
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
</span>
|
|
174
|
+
</div>
|
|
175
|
+
</button>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
</>
|
|
181
|
+
)}
|
|
182
|
+
</section>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { useRouter, useSearchParams } from 'next/navigation'
|
|
4
5
|
import { toast } from 'sonner'
|
|
5
6
|
import { MainContent } from '@/components/layout/main-content'
|
|
7
|
+
import { OperationsPulsePanel } from '@/components/operations/operations-pulse-panel'
|
|
6
8
|
import { RunList } from '@/components/runs/run-list'
|
|
7
9
|
import { PageLoader } from '@/components/ui/page-loader'
|
|
8
10
|
import { useWs } from '@/hooks/use-ws'
|
|
@@ -104,6 +106,8 @@ function EmptyState({ title, description }: { title: string; description: string
|
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
export function QualityWorkspace() {
|
|
109
|
+
const router = useRouter()
|
|
110
|
+
const searchParams = useSearchParams()
|
|
107
111
|
const agents = useAppStore((s) => s.agents)
|
|
108
112
|
const agentOptions = useMemo(
|
|
109
113
|
() => Object.values(agents).filter((agent) => !agent.trashedAt),
|
|
@@ -125,6 +129,20 @@ export function QualityWorkspace() {
|
|
|
125
129
|
const [evalBusy, setEvalBusy] = useState<string | null>(null)
|
|
126
130
|
const [approvalBusy, setApprovalBusy] = useState<string | null>(null)
|
|
127
131
|
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const tab = searchParams.get('tab') as QualityTab | null
|
|
134
|
+
if (tab && TABS.some((item) => item.id === tab)) setActiveTab(tab)
|
|
135
|
+
}, [searchParams])
|
|
136
|
+
|
|
137
|
+
const selectTab = useCallback((tab: QualityTab) => {
|
|
138
|
+
setActiveTab(tab)
|
|
139
|
+
router.replace(`/quality?tab=${tab}`, { scroll: false })
|
|
140
|
+
}, [router])
|
|
141
|
+
|
|
142
|
+
const openMissionTemplate = useCallback((templateId: string) => {
|
|
143
|
+
router.push(`/missions?template=${encodeURIComponent(templateId)}`)
|
|
144
|
+
}, [router])
|
|
145
|
+
|
|
128
146
|
const loadQualityData = useCallback(async (opts: { silent?: boolean } = {}) => {
|
|
129
147
|
if (opts.silent) setRefreshing(true)
|
|
130
148
|
else setLoading(true)
|
|
@@ -278,7 +296,7 @@ export function QualityWorkspace() {
|
|
|
278
296
|
<button
|
|
279
297
|
key={tab.id}
|
|
280
298
|
type="button"
|
|
281
|
-
onClick={() =>
|
|
299
|
+
onClick={() => selectTab(tab.id)}
|
|
282
300
|
className={cn(
|
|
283
301
|
'min-w-fit rounded-[9px] px-3 py-2 text-[12px] font-700 transition-colors',
|
|
284
302
|
activeTab === tab.id
|
|
@@ -293,6 +311,8 @@ export function QualityWorkspace() {
|
|
|
293
311
|
|
|
294
312
|
{activeTab === 'overview' && (
|
|
295
313
|
<div className="flex flex-col gap-6">
|
|
314
|
+
<OperationsPulsePanel defaultRange="7d" compact />
|
|
315
|
+
|
|
296
316
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
297
317
|
<StatTile
|
|
298
318
|
label="Needs Attention"
|
|
@@ -328,9 +348,10 @@ export function QualityWorkspace() {
|
|
|
328
348
|
<p className="mt-1 text-[12px] text-text-3/65">Shortest path to unblock operator review.</p>
|
|
329
349
|
</div>
|
|
330
350
|
<div className="flex flex-wrap gap-2">
|
|
331
|
-
<button onClick={() =>
|
|
332
|
-
<button onClick={() =>
|
|
333
|
-
<button onClick={() =>
|
|
351
|
+
<button onClick={() => openMissionTemplate('release-candidate-qa')} className="rounded-[9px] border border-emerald-500/25 bg-emerald-500/10 px-2.5 py-1.5 text-[11px] font-700 text-emerald-200 hover:bg-emerald-500/15">Start QA Mission</button>
|
|
352
|
+
<button onClick={() => selectTab('evals')} className="rounded-[9px] border border-white/[0.08] px-2.5 py-1.5 text-[11px] font-700 text-text-2 hover:bg-white/[0.05]">Eval Lab</button>
|
|
353
|
+
<button onClick={() => selectTab('approvals')} className="rounded-[9px] border border-white/[0.08] px-2.5 py-1.5 text-[11px] font-700 text-text-2 hover:bg-white/[0.05]">Approvals</button>
|
|
354
|
+
<button onClick={() => selectTab('runs')} className="rounded-[9px] border border-white/[0.08] px-2.5 py-1.5 text-[11px] font-700 text-text-2 hover:bg-white/[0.05]">Runs</button>
|
|
334
355
|
</div>
|
|
335
356
|
</div>
|
|
336
357
|
{runHealth.recentFailures.length === 0 && approvalGroups.totalPending === 0 && evalSummary.failedRuns === 0 ? (
|
|
@@ -340,7 +361,7 @@ export function QualityWorkspace() {
|
|
|
340
361
|
{runHealth.recentFailures.slice(0, 4).map((run) => (
|
|
341
362
|
<button
|
|
342
363
|
key={run.id}
|
|
343
|
-
onClick={() =>
|
|
364
|
+
onClick={() => selectTab('runs')}
|
|
344
365
|
className="rounded-[12px] border border-rose-500/20 bg-rose-500/[0.04] px-3 py-3 text-left transition-colors hover:bg-rose-500/[0.07]"
|
|
345
366
|
>
|
|
346
367
|
<div className="text-[11px] font-700 uppercase tracking-[0.1em] text-rose-300">Failed Run</div>
|
|
@@ -351,7 +372,7 @@ export function QualityWorkspace() {
|
|
|
351
372
|
{approvalGroups.categories.slice(0, 4).map((group) => (
|
|
352
373
|
<button
|
|
353
374
|
key={group.category}
|
|
354
|
-
onClick={() =>
|
|
375
|
+
onClick={() => selectTab('approvals')}
|
|
355
376
|
className="rounded-[12px] border border-amber-500/20 bg-amber-500/[0.04] px-3 py-3 text-left transition-colors hover:bg-amber-500/[0.07]"
|
|
356
377
|
>
|
|
357
378
|
<div className="text-[11px] font-700 uppercase tracking-[0.1em] text-amber-300">Approval</div>
|
|
@@ -435,6 +456,13 @@ export function QualityWorkspace() {
|
|
|
435
456
|
</div>
|
|
436
457
|
</div>
|
|
437
458
|
)}
|
|
459
|
+
<button
|
|
460
|
+
type="button"
|
|
461
|
+
onClick={() => openMissionTemplate('release-candidate-qa')}
|
|
462
|
+
className="mt-3 w-full rounded-[10px] border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-[12px] font-800 text-emerald-200 transition-colors hover:bg-emerald-500/15"
|
|
463
|
+
>
|
|
464
|
+
Start Release QA Mission
|
|
465
|
+
</button>
|
|
438
466
|
<button
|
|
439
467
|
type="button"
|
|
440
468
|
disabled={!selectedAgentId || !selectedScenarioId || !!evalBusy}
|