@swarmclawai/swarmclaw 1.8.0 → 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 +10 -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/home/page.tsx +3 -0
- package/src/app/missions/page.tsx +37 -4
- 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 +3 -0
- package/src/components/operations/operations-pulse-panel.tsx +184 -0
- package/src/components/quality/quality-workspace.tsx +3 -0
- 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/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
|
|
|
@@ -142,6 +143,8 @@ export function HomeLaunchpad({
|
|
|
142
143
|
</div>
|
|
143
144
|
</div>
|
|
144
145
|
|
|
146
|
+
<OperationsPulsePanel className="mt-6" compact />
|
|
147
|
+
|
|
145
148
|
<div className="mt-6 grid gap-3 lg:grid-cols-3">
|
|
146
149
|
<PathCard
|
|
147
150
|
kicker="Self-hosted assistant"
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
4
4
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { MainContent } from '@/components/layout/main-content'
|
|
7
|
+
import { OperationsPulsePanel } from '@/components/operations/operations-pulse-panel'
|
|
7
8
|
import { RunList } from '@/components/runs/run-list'
|
|
8
9
|
import { PageLoader } from '@/components/ui/page-loader'
|
|
9
10
|
import { useWs } from '@/hooks/use-ws'
|
|
@@ -310,6 +311,8 @@ export function QualityWorkspace() {
|
|
|
310
311
|
|
|
311
312
|
{activeTab === 'overview' && (
|
|
312
313
|
<div className="flex flex-col gap-6">
|
|
314
|
+
<OperationsPulsePanel defaultRange="7d" compact />
|
|
315
|
+
|
|
313
316
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
314
317
|
<StatTile
|
|
315
318
|
label="Needs Attention"
|
|
@@ -5,7 +5,8 @@ import { api } from '@/lib/app/api-client'
|
|
|
5
5
|
import { useNow } from '@/hooks/use-now'
|
|
6
6
|
import { useWs } from '@/hooks/use-ws'
|
|
7
7
|
import { BottomSheet } from '@/components/shared/bottom-sheet'
|
|
8
|
-
import
|
|
8
|
+
import { EvidenceShelf } from '@/components/evidence/evidence-shelf'
|
|
9
|
+
import type { EvidenceArtifact, RunBrief, RunEventRecord, SessionRunRecord, SessionRunStatus } from '@/types'
|
|
9
10
|
import { PageLoader } from '@/components/ui/page-loader'
|
|
10
11
|
import { formatElapsed } from '@/lib/format-display'
|
|
11
12
|
import { GroundingPanel } from '@/components/knowledge/grounding-panel'
|
|
@@ -41,7 +42,11 @@ export function RunList() {
|
|
|
41
42
|
const [query, setQuery] = useState('')
|
|
42
43
|
const [selected, setSelected] = useState<SessionRunRecord | null>(null)
|
|
43
44
|
const [selectedEvents, setSelectedEvents] = useState<RunEventRecord[]>([])
|
|
45
|
+
const [selectedBrief, setSelectedBrief] = useState<RunBrief | null>(null)
|
|
46
|
+
const [selectedArtifacts, setSelectedArtifacts] = useState<EvidenceArtifact[]>([])
|
|
44
47
|
const [eventsLoading, setEventsLoading] = useState(false)
|
|
48
|
+
const [briefLoading, setBriefLoading] = useState(false)
|
|
49
|
+
const [artifactsLoading, setArtifactsLoading] = useState(false)
|
|
45
50
|
|
|
46
51
|
const fetchRuns = useCallback(async () => {
|
|
47
52
|
try {
|
|
@@ -61,29 +66,42 @@ export function RunList() {
|
|
|
61
66
|
useEffect(() => {
|
|
62
67
|
if (!selected) return
|
|
63
68
|
let cancelled = false
|
|
64
|
-
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
void Promise.allSettled([
|
|
70
|
+
api<RunEventRecord[]>('GET', `/runs/${selected.id}/events?limit=200`),
|
|
71
|
+
api<RunBrief>('GET', `/runs/${selected.id}/brief`),
|
|
72
|
+
api<EvidenceArtifact[]>('GET', `/artifacts?runId=${encodeURIComponent(selected.id)}`),
|
|
73
|
+
]).then(([eventsResult, briefResult, artifactsResult]) => {
|
|
74
|
+
if (cancelled) return
|
|
75
|
+
setSelectedEvents(eventsResult.status === 'fulfilled' && Array.isArray(eventsResult.value) ? eventsResult.value : [])
|
|
76
|
+
setSelectedBrief(briefResult.status === 'fulfilled' ? briefResult.value : null)
|
|
77
|
+
setSelectedArtifacts(artifactsResult.status === 'fulfilled' && Array.isArray(artifactsResult.value) ? artifactsResult.value : [])
|
|
78
|
+
}).finally(() => {
|
|
79
|
+
if (cancelled) return
|
|
80
|
+
setEventsLoading(false)
|
|
81
|
+
setBriefLoading(false)
|
|
82
|
+
setArtifactsLoading(false)
|
|
83
|
+
})
|
|
75
84
|
return () => { cancelled = true }
|
|
76
85
|
}, [selected])
|
|
77
86
|
|
|
78
87
|
const closeSelected = useCallback(() => {
|
|
79
88
|
setSelected(null)
|
|
80
89
|
setSelectedEvents([])
|
|
90
|
+
setSelectedBrief(null)
|
|
91
|
+
setSelectedArtifacts([])
|
|
81
92
|
setEventsLoading(false)
|
|
93
|
+
setBriefLoading(false)
|
|
94
|
+
setArtifactsLoading(false)
|
|
82
95
|
}, [])
|
|
83
96
|
|
|
84
97
|
const openSelected = useCallback((run: SessionRunRecord) => {
|
|
85
98
|
setSelected(run)
|
|
99
|
+
setSelectedEvents([])
|
|
100
|
+
setSelectedBrief(null)
|
|
101
|
+
setSelectedArtifacts([])
|
|
86
102
|
setEventsLoading(true)
|
|
103
|
+
setBriefLoading(true)
|
|
104
|
+
setArtifactsLoading(true)
|
|
87
105
|
}, [])
|
|
88
106
|
|
|
89
107
|
const sources = useMemo(() => {
|
|
@@ -242,6 +260,61 @@ export function RunList() {
|
|
|
242
260
|
<p className="text-[12px] text-text-3/60 font-mono">{selected.id}</p>
|
|
243
261
|
</div>
|
|
244
262
|
|
|
263
|
+
{/* Brief */}
|
|
264
|
+
<div className="mb-6">
|
|
265
|
+
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Brief</label>
|
|
266
|
+
{briefLoading ? (
|
|
267
|
+
<div className="rounded-[12px] border border-white/[0.04] bg-white/[0.02] p-4 text-[11px] text-text-3/60">
|
|
268
|
+
Loading brief...
|
|
269
|
+
</div>
|
|
270
|
+
) : selectedBrief ? (
|
|
271
|
+
<div className="rounded-[12px] border border-white/[0.05] bg-white/[0.025] p-4">
|
|
272
|
+
<div className="text-[13px] font-700 text-text">{selectedBrief.title}</div>
|
|
273
|
+
<p className="mt-1 text-[12px] leading-relaxed text-text-3/70">{selectedBrief.objective}</p>
|
|
274
|
+
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
|
275
|
+
<div className="rounded-[10px] border border-white/[0.04] bg-white/[0.02] px-3 py-2">
|
|
276
|
+
<div className="text-[10px] uppercase tracking-[0.08em] text-text-3/55">Owner</div>
|
|
277
|
+
<div className="mt-1 text-[11px] text-text-2">{selectedBrief.owner ? `${selectedBrief.owner.type}:${selectedBrief.owner.id}` : selectedBrief.source}</div>
|
|
278
|
+
</div>
|
|
279
|
+
<div className="rounded-[10px] border border-white/[0.04] bg-white/[0.02] px-3 py-2">
|
|
280
|
+
<div className="text-[10px] uppercase tracking-[0.08em] text-text-3/55">Usage</div>
|
|
281
|
+
<div className="mt-1 text-[11px] text-text-2">
|
|
282
|
+
{selectedBrief.usage.inputTokens ?? 0} in / {selectedBrief.usage.outputTokens ?? 0} out
|
|
283
|
+
{selectedBrief.usage.estimatedCost != null ? ` - $${selectedBrief.usage.estimatedCost.toFixed(4)}` : ''}
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
{selectedBrief.warnings.length > 0 && (
|
|
288
|
+
<div className="mt-3 flex flex-col gap-1.5">
|
|
289
|
+
{selectedBrief.warnings.map((warning) => (
|
|
290
|
+
<div key={warning} className="rounded-[9px] border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-200">
|
|
291
|
+
{warning}
|
|
292
|
+
</div>
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
{selectedBrief.timeline.length > 0 && (
|
|
297
|
+
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
298
|
+
{selectedBrief.timeline.slice(0, 5).map((item, index) => (
|
|
299
|
+
<span key={`${item.label}:${item.at}:${index}`} className="rounded-full bg-white/[0.05] px-2 py-1 text-[10px] font-700 text-text-3/80">
|
|
300
|
+
{item.label} {new Date(item.at).toLocaleTimeString()}
|
|
301
|
+
</span>
|
|
302
|
+
))}
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
{selectedBrief.evidence.length > 0 && (
|
|
306
|
+
<div className="mt-3 text-[11px] text-text-3/65">
|
|
307
|
+
{selectedBrief.evidence.length} brief evidence item{selectedBrief.evidence.length === 1 ? '' : 's'} found.
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
) : (
|
|
312
|
+
<div className="rounded-[12px] border border-white/[0.04] bg-white/[0.02] p-4 text-[11px] text-text-3/60">
|
|
313
|
+
No brief available for this run.
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
|
|
245
318
|
{/* Timing */}
|
|
246
319
|
<div className="mb-6 space-y-2">
|
|
247
320
|
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">Timing</label>
|
|
@@ -308,6 +381,15 @@ export function RunList() {
|
|
|
308
381
|
</div>
|
|
309
382
|
)}
|
|
310
383
|
|
|
384
|
+
<div className="mb-6">
|
|
385
|
+
<EvidenceShelf
|
|
386
|
+
artifacts={selectedArtifacts}
|
|
387
|
+
loading={artifactsLoading}
|
|
388
|
+
title="Evidence Shelf"
|
|
389
|
+
emptyLabel="No linked artifacts, files, reports, or citations for this run."
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
311
393
|
<div className="mb-2">
|
|
312
394
|
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Replay</label>
|
|
313
395
|
<div className="rounded-[12px] border border-white/[0.04] bg-white/[0.02] max-h-[260px] overflow-auto">
|