@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.
Files changed (42) hide show
  1. package/README.md +20 -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/api/s/[token]/raw/route.ts +1 -1
  16. package/src/app/home/page.tsx +11 -1
  17. package/src/app/missions/page.tsx +182 -3
  18. package/src/app/s/[token]/page.tsx +173 -48
  19. package/src/cli/index.js +15 -0
  20. package/src/cli/spec.js +13 -0
  21. package/src/components/connectors/connector-list.tsx +36 -20
  22. package/src/components/evidence/evidence-shelf.tsx +97 -0
  23. package/src/components/home/home-launchpad.tsx +52 -2
  24. package/src/components/missions/mission-template-install-dialog.tsx +33 -1
  25. package/src/components/operations/operations-pulse-panel.tsx +184 -0
  26. package/src/components/quality/quality-workspace.tsx +34 -6
  27. package/src/components/runs/run-list.tsx +94 -12
  28. package/src/lib/connectors/connector-readiness.ts +127 -0
  29. package/src/lib/server/artifacts/artifact-resolver.test.ts +98 -0
  30. package/src/lib/server/artifacts/artifact-resolver.ts +241 -0
  31. package/src/lib/server/operations/operation-pulse.test.ts +108 -0
  32. package/src/lib/server/operations/operation-pulse.ts +197 -0
  33. package/src/lib/server/resolve-workspace-path.ts +10 -10
  34. package/src/lib/server/runs/run-brief.test.ts +92 -0
  35. package/src/lib/server/runs/run-brief.ts +107 -0
  36. package/src/lib/server/runs/unified-run-queries.ts +84 -0
  37. package/src/lib/server/sharing/share-resolver.test.ts +129 -0
  38. package/src/lib/server/sharing/share-resolver.ts +48 -3
  39. package/src/types/artifact.ts +28 -0
  40. package/src/types/index.ts +3 -0
  41. package/src/types/operations.ts +39 -0
  42. 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
 
@@ -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
- v1.6 Launchpad
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="Open Missions"
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={() => setActiveTab(tab.id)}
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={() => setActiveTab('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>
332
- <button onClick={() => setActiveTab('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>
333
- <button onClick={() => setActiveTab('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>
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={() => setActiveTab('runs')}
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={() => setActiveTab('approvals')}
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}