clawport-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. package/vitest.config.ts +17 -0
@@ -0,0 +1,870 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import Link from "next/link";
5
+ import type { Agent, CronJob, CronRun } from "@/lib/types";
6
+ import { formatDuration } from "@/lib/cron-utils";
7
+ import { Skeleton } from "@/components/ui/skeleton";
8
+ import { RefreshCw, BarChart3, Calendar, GitBranch, Copy, Check } from "lucide-react";
9
+ import { ErrorState } from "@/components/ErrorState";
10
+ import { WeeklySchedule } from "@/components/crons/WeeklySchedule";
11
+ import { PipelineGraph } from "@/components/crons/PipelineGraph";
12
+
13
+ /* ─── Time helpers ──────────────────────────────────────────────── */
14
+
15
+ function timeAgo(dateStr: string | null): string {
16
+ if (!dateStr) return "never";
17
+ const d = new Date(dateStr);
18
+ if (isNaN(d.getTime())) return "\u2014";
19
+ const diff = Date.now() - d.getTime();
20
+ const mins = Math.floor(diff / 60000);
21
+ const hrs = Math.floor(diff / 3600000);
22
+ const days = Math.floor(diff / 86400000);
23
+ if (diff < 0) {
24
+ const absDiff = Math.abs(diff);
25
+ const m = Math.floor(absDiff / 60000);
26
+ const h = Math.floor(absDiff / 3600000);
27
+ const dy = Math.floor(absDiff / 86400000);
28
+ if (m < 60) return `in ${m}m`;
29
+ if (h < 24) return `in ${h}h`;
30
+ return `in ${dy}d`;
31
+ }
32
+ if (mins < 1) return "just now";
33
+ if (mins < 60) return `${mins}m ago`;
34
+ if (hrs < 24) return `${hrs}h ago`;
35
+ return `${days}d ago`;
36
+ }
37
+
38
+ function nextRunLabel(dateStr: string | null): string {
39
+ if (!dateStr) return "not scheduled";
40
+ const d = new Date(dateStr);
41
+ if (isNaN(d.getTime())) return "\u2014";
42
+ const diff = d.getTime() - Date.now();
43
+ if (diff < 0) return "overdue";
44
+ const mins = Math.floor(diff / 60000);
45
+ const hrs = Math.floor(diff / 3600000);
46
+ const days = Math.floor(diff / 86400000);
47
+ if (mins < 60) return `in ${mins}m`;
48
+ if (hrs < 24) return `in ${hrs}h`;
49
+ return `in ${days}d`;
50
+ }
51
+
52
+ /* ─── Types ─────────────────────────────────────────────────────── */
53
+
54
+ type Filter = "all" | "ok" | "error" | "idle";
55
+ type Tab = "overview" | "schedule" | "pipelines";
56
+
57
+ const STATUS_DOT: Record<string, string> = {
58
+ ok: "var(--system-green)",
59
+ error: "var(--system-red)",
60
+ idle: "var(--text-tertiary)",
61
+ };
62
+
63
+ const PILLS: { key: Filter; label: string; dotColor: string }[] = [
64
+ { key: "all", label: "All", dotColor: "var(--text-primary)" },
65
+ { key: "ok", label: "OK", dotColor: "var(--system-green)" },
66
+ { key: "error", label: "Errors", dotColor: "var(--system-red)" },
67
+ { key: "idle", label: "Idle", dotColor: "var(--text-tertiary)" },
68
+ ];
69
+
70
+ const TAB_ICONS: Record<Tab, React.ComponentType<{ size: number; className?: string }>> = {
71
+ overview: BarChart3,
72
+ schedule: Calendar,
73
+ pipelines: GitBranch,
74
+ };
75
+
76
+ const TABS: { key: Tab; label: string }[] = [
77
+ { key: "overview", label: "Overview" },
78
+ { key: "schedule", label: "Schedule" },
79
+ { key: "pipelines", label: "Pipelines" },
80
+ ];
81
+
82
+ /* ─── Delivery helpers ─────────────────────────────────────────── */
83
+
84
+ function DeliveryBadge({ cron }: { cron: CronJob }) {
85
+ if (!cron.delivery) {
86
+ return (
87
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>
88
+ No delivery configured
89
+ </span>
90
+ );
91
+ }
92
+
93
+ const { delivery, lastDeliveryStatus } = cron;
94
+ const hasMissingTarget = !delivery.to;
95
+
96
+ if (hasMissingTarget) {
97
+ return (
98
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--system-red)" }}>
99
+ Target missing — add &apos;to&apos; field to delivery config
100
+ </span>
101
+ );
102
+ }
103
+
104
+ const isDelivered = lastDeliveryStatus === "delivered";
105
+ const isUnknown = !lastDeliveryStatus || lastDeliveryStatus === "unknown";
106
+ const color = isDelivered ? "var(--system-green)" : isUnknown ? "var(--system-orange)" : "var(--system-orange)";
107
+ const statusText = isDelivered ? "Delivered" : isUnknown ? "Unknown" : lastDeliveryStatus;
108
+
109
+ // Truncate the "to" field for display
110
+ const toDisplay = delivery.to && delivery.to.length > 20
111
+ ? delivery.to.slice(0, 17) + "..."
112
+ : delivery.to;
113
+
114
+ return (
115
+ <span style={{ fontSize: "var(--text-caption1)" }}>
116
+ <span style={{ color }}>
117
+ {isDelivered ? "\u2713" : "\u25CB"}{" "}
118
+ </span>
119
+ <span style={{ color: "var(--text-secondary)" }}>
120
+ {delivery.channel}
121
+ </span>
122
+ {toDisplay && (
123
+ <span style={{ color: "var(--text-tertiary)", marginLeft: 4 }}>
124
+ {toDisplay}
125
+ </span>
126
+ )}
127
+ <span style={{ color, marginLeft: 8, fontWeight: 500 }}>
128
+ {statusText}
129
+ </span>
130
+ </span>
131
+ );
132
+ }
133
+
134
+ /* ─── Summary Cards ──────────────────────────────────────────── */
135
+
136
+ function HealthCard({ ok, total }: { ok: number; total: number }) {
137
+ const pct = total === 0 ? 100 : Math.round((ok / total) * 100);
138
+ const r = 20;
139
+ const circumference = 2 * Math.PI * r;
140
+ const offset = circumference - (pct / 100) * circumference;
141
+
142
+ return (
143
+ <div
144
+ style={{
145
+ background: "var(--material-regular)",
146
+ border: "1px solid var(--separator)",
147
+ borderRadius: "var(--radius-md)",
148
+ padding: "var(--space-4)",
149
+ }}
150
+ >
151
+ <div className="flex items-center" style={{ gap: "var(--space-3)" }}>
152
+ <svg width="48" height="48" viewBox="0 0 48 48">
153
+ <circle cx="24" cy="24" r={r} fill="none" stroke="var(--fill-tertiary)" strokeWidth="5" />
154
+ <circle
155
+ cx="24" cy="24" r={r} fill="none"
156
+ stroke="var(--system-green)" strokeWidth="5"
157
+ strokeDasharray={circumference} strokeDashoffset={offset}
158
+ strokeLinecap="round" transform="rotate(-90 24 24)"
159
+ style={{ transition: "stroke-dashoffset 600ms var(--ease-smooth)" }}
160
+ />
161
+ <text x="24" y="24" textAnchor="middle" dominantBaseline="central"
162
+ fill="var(--text-primary)" fontSize="11" fontWeight="700">{pct}%</text>
163
+ </svg>
164
+ <div>
165
+ <div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-medium)" }}>
166
+ Health
167
+ </div>
168
+ <div style={{ fontSize: "var(--text-footnote)", color: "var(--text-primary)", fontWeight: "var(--weight-semibold)" }}>
169
+ {ok}/{total} healthy
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ );
175
+ }
176
+
177
+ function AttentionCard({ errors }: { errors: CronJob[] }) {
178
+ const hasErrors = errors.length > 0;
179
+ return (
180
+ <div
181
+ style={{
182
+ background: "var(--material-regular)",
183
+ border: "1px solid var(--separator)",
184
+ borderRadius: "var(--radius-md)",
185
+ padding: "var(--space-4)",
186
+ }}
187
+ >
188
+ <div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-medium)", marginBottom: "var(--space-1)" }}>
189
+ Attention
190
+ </div>
191
+ {hasErrors ? (
192
+ <>
193
+ <div style={{ fontSize: "var(--text-footnote)", color: "var(--system-red)", fontWeight: "var(--weight-semibold)" }}>
194
+ {errors.length} need{errors.length === 1 ? "s" : ""} fix
195
+ </div>
196
+ <div className="truncate" style={{ fontSize: "var(--text-caption2)", color: "var(--text-tertiary)", marginTop: 2 }}>
197
+ {errors[0].name}
198
+ </div>
199
+ </>
200
+ ) : (
201
+ <div className="flex items-center" style={{ gap: "var(--space-1)" }}>
202
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
203
+ <circle cx="8" cy="8" r="7" stroke="var(--system-green)" strokeWidth="1.5" />
204
+ <polyline points="5 8 7 10 11 6" stroke="var(--system-green)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
205
+ </svg>
206
+ <span style={{ fontSize: "var(--text-footnote)", color: "var(--system-green)", fontWeight: "var(--weight-semibold)" }}>
207
+ All clear
208
+ </span>
209
+ </div>
210
+ )}
211
+ </div>
212
+ );
213
+ }
214
+
215
+ function DeliveryCard({ crons }: { crons: CronJob[] }) {
216
+ const withDelivery = crons.filter(c => c.delivery);
217
+ const configured = withDelivery.filter(c => c.delivery?.to);
218
+ const missing = withDelivery.filter(c => !c.delivery?.to);
219
+
220
+ return (
221
+ <div
222
+ style={{
223
+ background: "var(--material-regular)",
224
+ border: "1px solid var(--separator)",
225
+ borderRadius: "var(--radius-md)",
226
+ padding: "var(--space-4)",
227
+ }}
228
+ >
229
+ <div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-medium)", marginBottom: "var(--space-1)" }}>
230
+ Delivery
231
+ </div>
232
+ <div style={{ fontSize: "var(--text-footnote)", color: "var(--text-primary)", fontWeight: "var(--weight-semibold)" }}>
233
+ {configured.length} configured
234
+ </div>
235
+ {missing.length > 0 && (
236
+ <div style={{ fontSize: "var(--text-caption2)", color: "var(--system-orange)", marginTop: 2 }}>
237
+ {missing.length} missing target
238
+ </div>
239
+ )}
240
+ </div>
241
+ );
242
+ }
243
+
244
+ /* ─── Categorized Error Banners ──────────────────────────────── */
245
+
246
+ function ErrorsBanners({
247
+ crons,
248
+ agentMap,
249
+ onCopy,
250
+ copiedId,
251
+ }: {
252
+ crons: CronJob[];
253
+ agentMap: Map<string, Agent>;
254
+ onCopy: (id: string, text: string) => void;
255
+ copiedId: string | null;
256
+ }) {
257
+ // Execution errors: status=error with actual error messages (not delivery target issues)
258
+ const execErrors = crons.filter(c => c.status === "error" && c.lastError && !c.lastError.includes("delivery target is missing"));
259
+ // Config issues: delivery target missing
260
+ const configIssues = crons.filter(c => c.delivery && !c.delivery.to);
261
+
262
+ if (execErrors.length === 0 && configIssues.length === 0) return null;
263
+
264
+ return (
265
+ <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)", marginBottom: "var(--space-4)" }}>
266
+ {/* Execution Errors */}
267
+ {execErrors.length > 0 && (
268
+ <div
269
+ style={{
270
+ background: "rgba(255,69,58,0.04)",
271
+ borderLeft: "3px solid var(--system-red)",
272
+ borderRadius: "var(--radius-sm)",
273
+ padding: "var(--space-3) var(--space-4)",
274
+ }}
275
+ >
276
+ <div style={{ fontSize: "var(--text-footnote)", color: "var(--system-red)", fontWeight: "var(--weight-semibold)", marginBottom: "var(--space-2)" }}>
277
+ {execErrors.length} execution error{execErrors.length !== 1 ? "s" : ""}
278
+ </div>
279
+ <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
280
+ {execErrors.map((cron) => {
281
+ const agent = cron.agentId ? agentMap.get(cron.agentId) : null;
282
+ return (
283
+ <div key={cron.id} className="flex items-center" style={{ gap: "var(--space-2)", fontSize: "var(--text-caption1)", minHeight: 28 }}>
284
+ <span className="flex-shrink-0 rounded-full animate-error-pulse" style={{ width: 6, height: 6, background: "var(--system-red)" }} />
285
+ <span className="flex-shrink-0" style={{ fontWeight: "var(--weight-semibold)", color: "var(--text-primary)" }}>{cron.name}</span>
286
+ {cron.lastError && (
287
+ <span className="truncate" style={{ color: "var(--text-tertiary)", flex: 1, minWidth: 0 }}>{cron.lastError}</span>
288
+ )}
289
+ {cron.consecutiveErrors > 1 && (
290
+ <span style={{ fontSize: "var(--text-caption2)", color: "var(--system-orange)", flexShrink: 0 }}>
291
+ {cron.consecutiveErrors}x
292
+ </span>
293
+ )}
294
+ {cron.lastError && (
295
+ <button
296
+ onClick={() => onCopy(cron.id, cron.lastError!)}
297
+ className="btn-ghost focus-ring flex-shrink-0"
298
+ aria-label={`Copy error for ${cron.name}`}
299
+ style={{ padding: "2px 8px", borderRadius: "var(--radius-sm)", fontSize: "var(--text-caption2)", fontWeight: "var(--weight-medium)", display: "inline-flex", alignItems: "center", gap: 3 }}
300
+ >
301
+ {copiedId === cron.id ? <Check size={12} /> : <Copy size={12} />}
302
+ {copiedId === cron.id ? "Copied" : "Copy"}
303
+ </button>
304
+ )}
305
+ {agent && (
306
+ <Link href={`/chat/${agent.id}`} className="flex-shrink-0 focus-ring" style={{ fontSize: "var(--text-caption2)", color: "var(--system-blue)", textDecoration: "none" }}>
307
+ {agent.name}
308
+ </Link>
309
+ )}
310
+ </div>
311
+ );
312
+ })}
313
+ </div>
314
+ </div>
315
+ )}
316
+
317
+ {/* Configuration Issues */}
318
+ {configIssues.length > 0 && (
319
+ <div
320
+ style={{
321
+ background: "rgba(255,149,0,0.04)",
322
+ borderLeft: "3px solid var(--system-orange)",
323
+ borderRadius: "var(--radius-sm)",
324
+ padding: "var(--space-3) var(--space-4)",
325
+ }}
326
+ >
327
+ <div style={{ fontSize: "var(--text-footnote)", color: "var(--system-orange)", fontWeight: "var(--weight-semibold)", marginBottom: "var(--space-2)" }}>
328
+ {configIssues.length} delivery target{configIssues.length !== 1 ? "s" : ""} missing
329
+ </div>
330
+ <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-1)" }}>
331
+ {configIssues.map((cron) => (
332
+ <div key={cron.id} className="flex items-center" style={{ gap: "var(--space-2)", fontSize: "var(--text-caption1)" }}>
333
+ <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--system-orange)", flexShrink: 0 }} />
334
+ <span style={{ color: "var(--text-primary)", fontWeight: "var(--weight-medium)" }}>{cron.name}</span>
335
+ <span style={{ color: "var(--text-tertiary)" }}>
336
+ {cron.delivery?.channel} — no &apos;to&apos; field
337
+ </span>
338
+ </div>
339
+ ))}
340
+ </div>
341
+ </div>
342
+ )}
343
+ </div>
344
+ );
345
+ }
346
+
347
+ /* ─── Recent Runs (lazy-loaded) ──────────────────────────────── */
348
+
349
+ function RecentRuns({ jobId }: { jobId: string }) {
350
+ const [runs, setRuns] = useState<CronRun[] | null>(null);
351
+ const [loading, setLoading] = useState(true);
352
+
353
+ useEffect(() => {
354
+ fetch(`/api/cron-runs?jobId=${encodeURIComponent(jobId)}`)
355
+ .then(r => r.ok ? r.json() : [])
356
+ .then(data => { setRuns((data as CronRun[]).slice(0, 5)); setLoading(false); })
357
+ .catch(() => { setRuns([]); setLoading(false); });
358
+ }, [jobId]);
359
+
360
+ if (loading) {
361
+ return (
362
+ <div style={{ marginTop: "var(--space-3)" }}>
363
+ <div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-semibold)", marginBottom: "var(--space-2)" }}>
364
+ Recent Runs
365
+ </div>
366
+ {[1, 2, 3].map(i => (
367
+ <Skeleton key={i} style={{ height: 16, marginBottom: 4, width: "80%" }} />
368
+ ))}
369
+ </div>
370
+ );
371
+ }
372
+
373
+ if (!runs || runs.length === 0) {
374
+ return (
375
+ <div style={{ marginTop: "var(--space-3)" }}>
376
+ <div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-semibold)", marginBottom: "var(--space-2)" }}>
377
+ Recent Runs
378
+ </div>
379
+ <div style={{ fontSize: "var(--text-caption2)", color: "var(--text-tertiary)" }}>No run history</div>
380
+ </div>
381
+ );
382
+ }
383
+
384
+ return (
385
+ <div style={{ marginTop: "var(--space-3)" }}>
386
+ <div style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)", fontWeight: "var(--weight-semibold)", marginBottom: "var(--space-2)" }}>
387
+ Recent Runs
388
+ </div>
389
+ <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
390
+ {runs.map((run, i) => {
391
+ const statusDot = run.status === "ok" ? "var(--system-green)" : "var(--system-red)";
392
+ const ago = timeAgo(new Date(run.ts).toISOString());
393
+ const duration = formatDuration(run.durationMs);
394
+ const deliveryStat = run.deliveryStatus === "delivered" ? "Delivered" : run.deliveryStatus === "unknown" ? "Unknown" : run.deliveryStatus || "—";
395
+ const summaryText = run.status === "error" ? (run.error || "Error") : (run.summary || "—");
396
+ const truncatedSummary = summaryText.length > 60 ? summaryText.slice(0, 57) + "..." : summaryText;
397
+
398
+ return (
399
+ <div
400
+ key={`${run.ts}-${i}`}
401
+ className="flex items-center"
402
+ style={{
403
+ gap: "var(--space-2)",
404
+ fontSize: "var(--text-caption2)",
405
+ minHeight: 22,
406
+ padding: "2px 0",
407
+ }}
408
+ >
409
+ <span style={{ width: 6, height: 6, borderRadius: "50%", background: statusDot, flexShrink: 0 }} />
410
+ <span style={{ color: "var(--text-tertiary)", minWidth: 52, flexShrink: 0 }}>{ago}</span>
411
+ <span style={{ color: "var(--text-secondary)", minWidth: 52, flexShrink: 0 }}>{duration}</span>
412
+ <span style={{ color: run.deliveryStatus === "delivered" ? "var(--system-green)" : "var(--text-tertiary)", minWidth: 60, flexShrink: 0 }}>
413
+ {deliveryStat}
414
+ </span>
415
+ <span className="truncate" style={{ color: "var(--text-tertiary)", minWidth: 0, flex: 1 }}>
416
+ {truncatedSummary}
417
+ </span>
418
+ </div>
419
+ );
420
+ })}
421
+ </div>
422
+ </div>
423
+ );
424
+ }
425
+
426
+ /* ─── Component ─────────────────────────────────────────────────── */
427
+
428
+ export default function CronsPage() {
429
+ const [crons, setCrons] = useState<CronJob[]>([]);
430
+ const [agents, setAgents] = useState<Agent[]>([]);
431
+ const [filter, setFilter] = useState<Filter>("all");
432
+ const [tab, setTab] = useState<Tab>("overview");
433
+ const [expanded, setExpanded] = useState<string | null>(null);
434
+ const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
435
+ const [loading, setLoading] = useState(true);
436
+ const [refreshing, setRefreshing] = useState(false);
437
+ const [error, setError] = useState<string | null>(null);
438
+ const [updatedAgo, setUpdatedAgo] = useState("just now");
439
+ const [copiedId, setCopiedId] = useState<string | null>(null);
440
+
441
+ const pillsRef = useRef<HTMLDivElement>(null);
442
+
443
+ const refresh = useCallback(() => {
444
+ setRefreshing(true);
445
+ setError(null);
446
+ Promise.all([
447
+ fetch("/api/crons").then((r) => {
448
+ if (!r.ok) throw new Error("Failed to load crons");
449
+ return r.json();
450
+ }),
451
+ fetch("/api/agents").then((r) => {
452
+ if (!r.ok) throw new Error("Failed to load agents");
453
+ return r.json();
454
+ }),
455
+ ])
456
+ .then(([c, a]) => {
457
+ setCrons(c);
458
+ setAgents(a);
459
+ setLastRefresh(new Date());
460
+ setLoading(false);
461
+ setRefreshing(false);
462
+ })
463
+ .catch((err) => {
464
+ setError(err instanceof Error ? err.message : "Unknown error");
465
+ setLoading(false);
466
+ setRefreshing(false);
467
+ });
468
+ }, []);
469
+
470
+ useEffect(() => {
471
+ refresh();
472
+ const interval = setInterval(refresh, 60000);
473
+ return () => clearInterval(interval);
474
+ }, [refresh]);
475
+
476
+ useEffect(() => {
477
+ const tick = () => setUpdatedAgo(timeAgo(lastRefresh.toISOString()));
478
+ tick();
479
+ const interval = setInterval(tick, 30000);
480
+ return () => clearInterval(interval);
481
+ }, [lastRefresh]);
482
+
483
+ /* Derived data */
484
+ const agentMap = new Map(agents.map((a) => [a.id, a]));
485
+ const statusOrder: Record<string, number> = { error: 0, idle: 1, ok: 2 };
486
+ const filtered = crons
487
+ .filter((c) => filter === "all" || c.status === filter)
488
+ .sort((a, b) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9));
489
+ const counts = {
490
+ all: crons.length,
491
+ ok: crons.filter((c) => c.status === "ok").length,
492
+ error: crons.filter((c) => c.status === "error").length,
493
+ idle: crons.filter((c) => c.status === "idle").length,
494
+ };
495
+ const errorCrons = crons.filter((c) => c.status === "error");
496
+
497
+ function handlePillKeyDown(e: React.KeyboardEvent) {
498
+ const pills = pillsRef.current;
499
+ if (!pills) return;
500
+ const buttons = Array.from(pills.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
501
+ const current = buttons.findIndex((b) => b.getAttribute("aria-selected") === "true");
502
+ let next = current;
503
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); next = (current + 1) % buttons.length; }
504
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); next = (current - 1 + buttons.length) % buttons.length; }
505
+ if (next !== current) { buttons[next].focus(); buttons[next].click(); }
506
+ }
507
+
508
+ function copyError(cronId: string, text: string) {
509
+ navigator.clipboard.writeText(text).then(() => {
510
+ setCopiedId(cronId);
511
+ setTimeout(() => setCopiedId(null), 2000);
512
+ });
513
+ }
514
+
515
+ if (error && crons.length === 0) {
516
+ return <ErrorState message={error} onRetry={refresh} />;
517
+ }
518
+
519
+ return (
520
+ <div className="h-full flex flex-col overflow-hidden animate-fade-in" style={{ background: "var(--bg)" }}>
521
+ {/* ── Sticky header ──────────────────────────────────────── */}
522
+ <header
523
+ className="sticky top-0 z-10 flex-shrink-0"
524
+ style={{
525
+ background: "var(--material-regular)",
526
+ backdropFilter: "blur(40px) saturate(180%)",
527
+ WebkitBackdropFilter: "blur(40px) saturate(180%)",
528
+ borderBottom: "1px solid var(--separator)",
529
+ }}
530
+ >
531
+ <div className="flex items-center justify-between" style={{ padding: "var(--space-4) var(--space-6)" }}>
532
+ <div>
533
+ <h1 style={{ fontSize: "var(--text-title1)", fontWeight: "var(--weight-bold)", color: "var(--text-primary)", letterSpacing: "-0.5px", lineHeight: "var(--leading-tight)" }}>
534
+ Cron Monitor
535
+ </h1>
536
+ {!loading && (
537
+ <p style={{ fontSize: "var(--text-footnote)", color: "var(--text-secondary)", marginTop: "var(--space-1)" }}>
538
+ {counts.all} job{counts.all !== 1 ? "s" : ""}
539
+ {counts.error > 0 && (
540
+ <span style={{ color: "var(--system-red)" }}>{" \u00b7 "}{counts.error} error{counts.error !== 1 ? "s" : ""}</span>
541
+ )}
542
+ {" \u00b7 "}{counts.ok} ok
543
+ </p>
544
+ )}
545
+ </div>
546
+ <div className="flex items-center" style={{ gap: "var(--space-3)" }}>
547
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Updated {updatedAgo}</span>
548
+ <button
549
+ onClick={refresh}
550
+ className="focus-ring"
551
+ aria-label="Refresh cron data"
552
+ style={{ width: 32, height: 32, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-sm)", border: "none", background: "transparent", color: "var(--text-tertiary)", cursor: "pointer", transition: "color 150ms var(--ease-smooth)" }}
553
+ >
554
+ <RefreshCw size={16} className={refreshing ? "animate-spin" : ""} />
555
+ </button>
556
+ </div>
557
+ </div>
558
+
559
+ {/* ── Tab navigation ─────────────────────────────────── */}
560
+ <div className="flex items-center" style={{ padding: "0 var(--space-6) var(--space-3)", gap: "var(--space-1)" }}>
561
+ {TABS.map((t) => {
562
+ const isActive = tab === t.key;
563
+ const TabIcon = TAB_ICONS[t.key];
564
+ return (
565
+ <button
566
+ key={t.key}
567
+ onClick={() => setTab(t.key)}
568
+ className="focus-ring"
569
+ style={{
570
+ padding: "6px 16px",
571
+ fontSize: "var(--text-footnote)",
572
+ fontWeight: isActive ? "var(--weight-semibold)" : "var(--weight-medium)",
573
+ border: "none",
574
+ borderRadius: "var(--radius-sm)",
575
+ cursor: "pointer",
576
+ transition: "all 200ms var(--ease-smooth)",
577
+ background: isActive ? "var(--accent-fill)" : "transparent",
578
+ color: isActive ? "var(--accent)" : "var(--text-secondary)",
579
+ display: "inline-flex",
580
+ alignItems: "center",
581
+ gap: 6,
582
+ }}
583
+ >
584
+ <TabIcon size={14} />
585
+ {t.label}
586
+ </button>
587
+ );
588
+ })}
589
+ </div>
590
+ </header>
591
+
592
+ {/* ── Scrollable content ─────────────────────────────────── */}
593
+ <div className="flex-1 overflow-y-auto" style={{ padding: "var(--space-4) var(--space-6) var(--space-6)" }}>
594
+ {loading ? (
595
+ <>
596
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "var(--space-3)", marginBottom: "var(--space-4)" }}>
597
+ {[1, 2, 3].map((i) => (
598
+ <div key={i} style={{ background: "var(--material-regular)", border: "1px solid var(--separator)", borderRadius: "var(--radius-md)", padding: "var(--space-4)" }}>
599
+ <Skeleton style={{ width: 60, height: 10, marginBottom: 8 }} />
600
+ <Skeleton style={{ width: 80, height: 14 }} />
601
+ </div>
602
+ ))}
603
+ </div>
604
+ <div style={{ borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--material-regular)" }}>
605
+ {[1, 2, 3, 4, 5].map((i) => (
606
+ <div key={i} className="flex items-center" style={{ padding: "var(--space-3) var(--space-4)", borderBottom: i < 5 ? "1px solid var(--separator)" : undefined, gap: "var(--space-3)" }}>
607
+ <Skeleton className="flex-shrink-0" style={{ width: 8, height: 8, borderRadius: "50%" }} />
608
+ <Skeleton style={{ width: 180, height: 14 }} />
609
+ <div className="ml-auto flex items-center" style={{ gap: "var(--space-3)" }}>
610
+ <Skeleton style={{ width: 48, height: 12 }} />
611
+ <Skeleton style={{ width: 64, height: 12 }} />
612
+ </div>
613
+ </div>
614
+ ))}
615
+ </div>
616
+ </>
617
+ ) : (
618
+ <>
619
+ {/* ─── OVERVIEW TAB ─────────────────────────────── */}
620
+ {tab === "overview" && (
621
+ <>
622
+ {/* Summary cards */}
623
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "var(--space-3)", marginBottom: "var(--space-4)" }} className="summary-cards-grid">
624
+ <HealthCard ok={counts.ok} total={counts.all} />
625
+ <AttentionCard errors={errorCrons} />
626
+ <DeliveryCard crons={crons} />
627
+ </div>
628
+
629
+ {/* Categorized error banners */}
630
+ <ErrorsBanners crons={crons} agentMap={agentMap} onCopy={copyError} copiedId={copiedId} />
631
+
632
+ {/* Filter pills */}
633
+ <div
634
+ ref={pillsRef}
635
+ role="tablist"
636
+ aria-label="Filter cron jobs by status"
637
+ onKeyDown={handlePillKeyDown}
638
+ className="flex items-center overflow-x-auto flex-shrink-0"
639
+ style={{ marginBottom: "var(--space-3)", gap: "var(--space-2)" }}
640
+ >
641
+ {PILLS.map((pill) => {
642
+ const isActive = filter === pill.key;
643
+ return (
644
+ <button
645
+ key={pill.key}
646
+ role="tab"
647
+ aria-selected={isActive}
648
+ tabIndex={isActive ? 0 : -1}
649
+ onClick={() => setFilter(pill.key)}
650
+ className="focus-ring flex items-center flex-shrink-0"
651
+ style={{
652
+ borderRadius: 20,
653
+ padding: "6px 14px",
654
+ fontSize: "var(--text-footnote)",
655
+ fontWeight: "var(--weight-medium)",
656
+ border: "none",
657
+ cursor: "pointer",
658
+ gap: "var(--space-2)",
659
+ transition: "all 200ms var(--ease-smooth)",
660
+ ...(isActive
661
+ ? { background: "var(--accent-fill)", color: "var(--accent)", boxShadow: "0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent)" }
662
+ : { background: "var(--fill-secondary)", color: "var(--text-primary)" }),
663
+ }}
664
+ >
665
+ <span className={`flex-shrink-0 rounded-full ${pill.key === "error" && counts.error > 0 ? "animate-error-pulse" : ""}`} style={{ width: 6, height: 6, background: pill.dotColor }} />
666
+ <span>{pill.label}</span>
667
+ <span style={{ fontWeight: "var(--weight-semibold)", color: isActive ? "var(--accent)" : "var(--text-secondary)" }}>{counts[pill.key]}</span>
668
+ </button>
669
+ );
670
+ })}
671
+ </div>
672
+
673
+ {/* Cron list */}
674
+ {filtered.length === 0 ? (
675
+ <div className="flex flex-col items-center justify-center" style={{ height: 200, color: "var(--text-secondary)", gap: "var(--space-2)" }}>
676
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--text-tertiary)", marginBottom: "var(--space-2)" }}>
677
+ <circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
678
+ </svg>
679
+ <span style={{ fontSize: "var(--text-subheadline)", fontWeight: "var(--weight-medium)" }}>
680
+ {crons.length === 0 ? "No scheduled tasks yet" : "No crons match this filter"}
681
+ </span>
682
+ <span style={{ fontSize: "var(--text-footnote)", color: "var(--text-tertiary)", textAlign: "center", maxWidth: 360, lineHeight: "var(--leading-relaxed)" }}>
683
+ {crons.length === 0 ? "Cron jobs are automated tasks that run on a schedule. They will appear here once your agents have scheduled tasks configured." : "Try selecting a different status filter"}
684
+ </span>
685
+ </div>
686
+ ) : (
687
+ <div style={{ borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--material-regular)", backdropFilter: "blur(20px)", WebkitBackdropFilter: "blur(20px)" }}>
688
+ {filtered.map((cron, idx) => {
689
+ const agent = cron.agentId ? agentMap.get(cron.agentId) : null;
690
+ const isExpanded = expanded === cron.id;
691
+ const isError = cron.status === "error";
692
+ const isOverdue = cron.nextRun && nextRunLabel(cron.nextRun) === "overdue";
693
+
694
+ return (
695
+ <div key={cron.id}>
696
+ {idx > 0 && (
697
+ <div style={{ height: 1, background: "var(--separator)", marginLeft: "var(--space-4)", marginRight: "var(--space-4)" }} />
698
+ )}
699
+
700
+ {/* Collapsed row */}
701
+ <div
702
+ role="button"
703
+ tabIndex={0}
704
+ aria-expanded={isExpanded}
705
+ aria-label={`${cron.name}, status ${cron.status}${agent ? `, agent ${agent.name}` : ""}`}
706
+ onClick={() => setExpanded(isExpanded ? null : cron.id)}
707
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setExpanded(isExpanded ? null : cron.id); } }}
708
+ className="flex items-center cursor-pointer hover-bg focus-ring"
709
+ style={{
710
+ minHeight: 48,
711
+ padding: "0 var(--space-4)",
712
+ background: isError ? "rgba(255,69,58,0.06)" : undefined,
713
+ borderLeft: `3px solid ${isError ? "var(--system-red)" : cron.status === "ok" ? "var(--system-green)" : "transparent"}`,
714
+ }}
715
+ >
716
+ <span className={`flex-shrink-0 rounded-full ${isError ? "animate-error-pulse" : ""}`} style={{ width: 8, height: 8, background: STATUS_DOT[cron.status] ?? "var(--text-tertiary)" }} />
717
+ <div className="ml-3 min-w-0 flex-1" style={{ display: "flex", flexDirection: "column" }}>
718
+ <span className="truncate" style={{ fontSize: "var(--text-footnote)", fontWeight: "var(--weight-semibold)", color: "var(--text-primary)" }}>{cron.name}</span>
719
+ {agent && (
720
+ <Link href={`/chat/${agent.id}`} onClick={(e) => e.stopPropagation()} className="md:hidden focus-ring" aria-label={`Chat with ${agent.name}`} style={{ fontSize: "var(--text-caption1)", color: "var(--system-blue)", textDecoration: "none", lineHeight: "var(--leading-snug)" }}>
721
+ {agent.name}
722
+ </Link>
723
+ )}
724
+ </div>
725
+ <div className="ml-auto flex items-center flex-shrink-0" style={{ gap: "var(--space-3)" }}>
726
+ {agent ? (
727
+ <Link href={`/chat/${agent.id}`} onClick={(e) => e.stopPropagation()} className="hidden md:inline focus-ring" aria-label={`Chat with ${agent.name}`} style={{ fontSize: "var(--text-caption1)", color: "var(--system-blue)", textDecoration: "none" }}>
728
+ {agent.name}
729
+ </Link>
730
+ ) : (
731
+ <span className="hidden md:inline" style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>{"\u2014"}</span>
732
+ )}
733
+ <span className="hidden md:inline" style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>
734
+ {cron.scheduleDescription || cron.schedule}
735
+ </span>
736
+ <span aria-hidden="true" style={{ fontSize: "var(--text-footnote)", color: "var(--text-tertiary)", transition: "transform 200ms var(--ease-smooth)", transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)", display: "inline-block" }}>
737
+ &#8250;
738
+ </span>
739
+ </div>
740
+ </div>
741
+
742
+ {/* Expanded detail */}
743
+ {isExpanded && (
744
+ <div className="animate-slide-down" style={{ padding: "0 var(--space-4) var(--space-4) var(--space-4)", marginLeft: 3 }}>
745
+ <div style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "var(--space-1) var(--space-4)", marginTop: "var(--space-2)", marginBottom: "var(--space-3)" }}>
746
+ {/* Description */}
747
+ {cron.description && (
748
+ <>
749
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Description</span>
750
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-secondary)" }}>{cron.description}</span>
751
+ </>
752
+ )}
753
+
754
+ {/* Last run */}
755
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Last run</span>
756
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-secondary)" }}>{timeAgo(cron.lastRun)}</span>
757
+
758
+ {/* Next run */}
759
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Next run</span>
760
+ <span style={{ fontSize: "var(--text-caption1)", color: isOverdue ? "var(--system-orange)" : "var(--text-secondary)", fontWeight: isOverdue ? "var(--weight-semibold)" : undefined }}>
761
+ {nextRunLabel(cron.nextRun)}
762
+ </span>
763
+
764
+ {/* Duration */}
765
+ {cron.lastDurationMs != null && (
766
+ <>
767
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Duration</span>
768
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-secondary)" }}>{formatDuration(cron.lastDurationMs)}</span>
769
+ </>
770
+ )}
771
+
772
+ {/* Status */}
773
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Status</span>
774
+ <span style={{ fontSize: "var(--text-caption1)", color: cron.status === "error" ? "var(--system-red)" : cron.status === "ok" ? "var(--system-green)" : "var(--text-secondary)", fontWeight: "var(--weight-medium)", textTransform: "capitalize" }}>
775
+ {cron.status}
776
+ </span>
777
+
778
+ {/* Schedule */}
779
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Schedule</span>
780
+ <div>
781
+ {cron.scheduleDescription && (
782
+ <div style={{ fontSize: "var(--text-caption1)", color: "var(--text-secondary)" }}>{cron.scheduleDescription}</div>
783
+ )}
784
+ <div className="font-mono" style={{ fontSize: "var(--text-caption2)", color: "var(--text-tertiary)", marginTop: cron.scheduleDescription ? 2 : 0 }}>
785
+ {cron.schedule}
786
+ {cron.timezone && <span style={{ marginLeft: 8 }}>({cron.timezone})</span>}
787
+ </div>
788
+ </div>
789
+
790
+ {/* Delivery */}
791
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Delivery</span>
792
+ <DeliveryBadge cron={cron} />
793
+
794
+ {/* Consecutive errors */}
795
+ {cron.consecutiveErrors > 0 && (
796
+ <>
797
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--text-tertiary)" }}>Errors</span>
798
+ <span style={{ fontSize: "var(--text-caption1)", color: "var(--system-orange)", fontWeight: "var(--weight-medium)" }}>
799
+ {cron.consecutiveErrors} consecutive
800
+ </span>
801
+ </>
802
+ )}
803
+ </div>
804
+
805
+ {/* Error box */}
806
+ {cron.lastError && (
807
+ <div style={{ borderRadius: "var(--radius-sm)", background: "var(--code-bg)", border: "1px solid var(--code-border)", padding: "var(--space-3)", marginBottom: "var(--space-3)" }}>
808
+ <div className="flex items-start justify-between" style={{ gap: "var(--space-2)" }}>
809
+ <pre className="font-mono" style={{ fontSize: "var(--text-caption1)", color: "var(--system-red)", whiteSpace: "pre-wrap", wordBreak: "break-word", margin: 0, flex: 1, lineHeight: "var(--leading-relaxed)" }}>
810
+ {cron.lastError}
811
+ </pre>
812
+ <button
813
+ onClick={(e) => { e.stopPropagation(); copyError(cron.id, cron.lastError!); }}
814
+ className="btn-ghost focus-ring flex-shrink-0"
815
+ aria-label="Copy error text"
816
+ style={{ padding: "4px 10px", borderRadius: "var(--radius-sm)", fontSize: "var(--text-caption2)", fontWeight: "var(--weight-medium)", display: "inline-flex", alignItems: "center", gap: 3 }}
817
+ >
818
+ {copiedId === cron.id ? <Check size={12} /> : <Copy size={12} />}
819
+ {copiedId === cron.id ? "Copied" : "Copy"}
820
+ </button>
821
+ </div>
822
+ </div>
823
+ )}
824
+
825
+ {/* Recent runs */}
826
+ <RecentRuns jobId={cron.id} />
827
+
828
+ {/* Actions */}
829
+ <div className="flex items-center" style={{ gap: "var(--space-2)", marginTop: "var(--space-3)" }}>
830
+ {agent && (
831
+ <Link
832
+ href={`/chat/${agent.id}`}
833
+ className="btn-ghost focus-ring"
834
+ aria-label={`Chat with ${agent.name}`}
835
+ style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-1)", padding: "6px 12px", borderRadius: "var(--radius-sm)", fontSize: "var(--text-caption1)", fontWeight: "var(--weight-medium)", textDecoration: "none", color: "var(--system-blue)" }}
836
+ >
837
+ Chat with {agent.name}
838
+ <span aria-hidden="true" style={{ fontSize: "var(--text-caption1)" }}>{"\u2192"}</span>
839
+ </Link>
840
+ )}
841
+ </div>
842
+ </div>
843
+ )}
844
+ </div>
845
+ );
846
+ })}
847
+ </div>
848
+ )}
849
+ </>
850
+ )}
851
+
852
+ {/* ─── SCHEDULE TAB ──────────────────────────────── */}
853
+ {tab === "schedule" && <WeeklySchedule crons={crons} />}
854
+
855
+ {/* ─── PIPELINES TAB ─────────────────────────────── */}
856
+ {tab === "pipelines" && <PipelineGraph crons={crons} />}
857
+ </>
858
+ )}
859
+ </div>
860
+
861
+ <style>{`
862
+ @media (max-width: 640px) {
863
+ .summary-cards-grid {
864
+ grid-template-columns: 1fr !important;
865
+ }
866
+ }
867
+ `}</style>
868
+ </div>
869
+ );
870
+ }