@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.
Files changed (37) hide show
  1. package/README.md +10 -0
  2. package/next.config.ts +38 -8
  3. package/package.json +2 -2
  4. package/scripts/run-next-build.mjs +51 -3
  5. package/src/app/api/artifacts/route.ts +15 -0
  6. package/src/app/api/clawhub/install/route.ts +4 -4
  7. package/src/app/api/dirs/route.ts +8 -5
  8. package/src/app/api/files/open/route.ts +3 -3
  9. package/src/app/api/files/serve/route.ts +2 -2
  10. package/src/app/api/operations/pulse/route.ts +9 -0
  11. package/src/app/api/runs/[id]/brief/route.ts +12 -0
  12. package/src/app/api/runs/[id]/events/route.ts +4 -13
  13. package/src/app/api/runs/[id]/route.ts +2 -6
  14. package/src/app/api/runs/route.ts +3 -43
  15. package/src/app/home/page.tsx +3 -0
  16. package/src/app/missions/page.tsx +37 -4
  17. package/src/cli/index.js +15 -0
  18. package/src/cli/spec.js +13 -0
  19. package/src/components/connectors/connector-list.tsx +36 -20
  20. package/src/components/evidence/evidence-shelf.tsx +97 -0
  21. package/src/components/home/home-launchpad.tsx +3 -0
  22. package/src/components/operations/operations-pulse-panel.tsx +184 -0
  23. package/src/components/quality/quality-workspace.tsx +3 -0
  24. package/src/components/runs/run-list.tsx +94 -12
  25. package/src/lib/connectors/connector-readiness.ts +127 -0
  26. package/src/lib/server/artifacts/artifact-resolver.test.ts +98 -0
  27. package/src/lib/server/artifacts/artifact-resolver.ts +241 -0
  28. package/src/lib/server/operations/operation-pulse.test.ts +108 -0
  29. package/src/lib/server/operations/operation-pulse.ts +197 -0
  30. package/src/lib/server/resolve-workspace-path.ts +10 -10
  31. package/src/lib/server/runs/run-brief.test.ts +92 -0
  32. package/src/lib/server/runs/run-brief.ts +107 -0
  33. package/src/lib/server/runs/unified-run-queries.ts +84 -0
  34. package/src/types/artifact.ts +28 -0
  35. package/src/types/index.ts +3 -0
  36. package/src/types/operations.ts +39 -0
  37. 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 missingRoute = !connector.agentId && !connector.chatroomId
40
- const needsSetup = !hasConnectorCredentials(connector) || !!connector.qrDataUrl || missingRoute
41
- if (needsSetup) return 'needs-setup'
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 missingRoute = !chatroom && !agent
278
- const issues = [
279
- !hasCredentials ? { label: 'Credentials missing', tone: 'text-red-400 bg-red-500/10' } : null,
280
- c.qrDataUrl ? { label: 'QR required', tone: 'text-amber-400 bg-amber-500/10' } : null,
281
- missingRoute ? { label: 'Routing missing', tone: 'text-amber-300 bg-amber-500/10' } : null,
282
- ].filter(Boolean) as Array<{ label: string; tone: string }>
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 type { RunEventRecord, SessionRunRecord, SessionRunStatus } from '@/types'
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
- api<RunEventRecord[]>('GET', `/runs/${selected.id}/events?limit=200`)
65
- .then((events) => {
66
- if (cancelled) return
67
- setSelectedEvents(Array.isArray(events) ? events : [])
68
- })
69
- .catch(() => {
70
- if (!cancelled) setSelectedEvents([])
71
- })
72
- .finally(() => {
73
- if (!cancelled) setEventsLoading(false)
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">