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,630 @@
1
+ "use client"
2
+
3
+ import { useMemo, useState, useRef, useEffect, useCallback } from "react"
4
+ import type { CronJob } from "@/lib/types"
5
+ import { parseScheduleSlots } from "@/lib/cron-utils"
6
+
7
+ interface WeeklyScheduleProps {
8
+ crons: CronJob[]
9
+ }
10
+
11
+ const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
12
+ const DAY_LABELS_FULL = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
13
+ // Map cron dow (0=Sun) to grid column (0=Mon)
14
+ const DOW_TO_COL: Record<number, number> = { 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, 0: 6 }
15
+
16
+ // Agent colors for pill coloring
17
+ const AGENT_COLORS: Record<string, string> = {
18
+ pulse: "#6366f1",
19
+ herald: "#f59e0b",
20
+ robin: "#10b981",
21
+ lumen: "#3b82f6",
22
+ echo: "#8b5cf6",
23
+ spark: "#f97316",
24
+ scribe: "#14b8a6",
25
+ kaze: "#ec4899",
26
+ jarvis: "#ef4444",
27
+ maven: "#84cc16",
28
+ oracle: "#a855f7",
29
+ mochi: "#06b6d4",
30
+ recon: "#d946ef",
31
+ cartographer: "#78716c",
32
+ }
33
+
34
+ function formatHour(h: number): string {
35
+ if (h === 0 || h === 24) return "12 AM"
36
+ if (h === 12) return "12 PM"
37
+ return h < 12 ? `${h} AM` : `${h - 12} PM`
38
+ }
39
+
40
+ function formatHourShort(h: number): string {
41
+ if (h === 0 || h === 24) return "12a"
42
+ if (h === 12) return "12p"
43
+ return h < 12 ? `${h}a` : `${h - 12}p`
44
+ }
45
+
46
+ interface SlotInfo {
47
+ cron: CronJob
48
+ hour: number
49
+ minute: number
50
+ col: number
51
+ }
52
+
53
+ interface TooltipData {
54
+ slot: SlotInfo
55
+ rect: DOMRect
56
+ }
57
+
58
+ function PillTooltip({ slot, rect, containerRect }: { slot: SlotInfo; rect: DOMRect; containerRect: DOMRect }) {
59
+ const color = AGENT_COLORS[slot.cron.agentId || ""] || "var(--text-secondary)"
60
+
61
+ // Position tooltip above the pill, centered horizontally
62
+ const top = rect.top - containerRect.top - 8
63
+ const left = rect.left - containerRect.left + rect.width / 2
64
+
65
+ return (
66
+ <div
67
+ style={{
68
+ position: "absolute",
69
+ top,
70
+ left,
71
+ transform: "translate(-50%, -100%)",
72
+ background: "var(--material-regular)",
73
+ border: "1px solid var(--separator)",
74
+ borderRadius: "var(--radius-md)",
75
+ padding: "var(--space-3) var(--space-4)",
76
+ fontSize: "var(--text-caption1)",
77
+ color: "var(--text-primary)",
78
+ pointerEvents: "none",
79
+ zIndex: 100,
80
+ minWidth: 220,
81
+ maxWidth: 320,
82
+ boxShadow: "0 8px 24px rgba(0,0,0,0.4), 0 2px 8px rgba(0,0,0,0.2)",
83
+ }}
84
+ >
85
+ {/* Arrow */}
86
+ <div
87
+ style={{
88
+ position: "absolute",
89
+ bottom: -5,
90
+ left: "50%",
91
+ transform: "translateX(-50%) rotate(45deg)",
92
+ width: 10,
93
+ height: 10,
94
+ background: "var(--material-regular)",
95
+ borderRight: "1px solid var(--separator)",
96
+ borderBottom: "1px solid var(--separator)",
97
+ }}
98
+ />
99
+ {/* Name */}
100
+ <div style={{
101
+ fontWeight: "var(--weight-bold)",
102
+ fontSize: "var(--text-footnote)",
103
+ marginBottom: "var(--space-1)",
104
+ borderLeft: `3px solid ${color}`,
105
+ paddingLeft: "var(--space-2)",
106
+ }}>
107
+ {slot.cron.name}
108
+ </div>
109
+ {/* Schedule */}
110
+ <div style={{ color: "var(--text-secondary)", fontSize: "var(--text-caption1)", marginBottom: "var(--space-2)" }}>
111
+ {slot.cron.scheduleDescription || slot.cron.schedule}
112
+ {slot.cron.timezone && (
113
+ <span style={{ color: "var(--text-tertiary)", marginLeft: "var(--space-1)" }}>
114
+ ({slot.cron.timezone})
115
+ </span>
116
+ )}
117
+ </div>
118
+ {/* Time */}
119
+ <div style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-caption2)", color: "var(--text-tertiary)", marginBottom: "var(--space-2)" }}>
120
+ {slot.cron.schedule}
121
+ </div>
122
+ {/* Status + Next run */}
123
+ <div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", fontSize: "var(--text-caption1)" }}>
124
+ <span style={{ display: "flex", alignItems: "center", gap: "var(--space-1)" }}>
125
+ <span
126
+ style={{
127
+ width: 7,
128
+ height: 7,
129
+ borderRadius: "50%",
130
+ background:
131
+ slot.cron.status === "ok" ? "var(--system-green)"
132
+ : slot.cron.status === "error" ? "var(--system-red)"
133
+ : "var(--text-tertiary)",
134
+ flexShrink: 0,
135
+ }}
136
+ />
137
+ <span style={{
138
+ color: slot.cron.status === "ok" ? "var(--system-green)"
139
+ : slot.cron.status === "error" ? "var(--system-red)"
140
+ : "var(--text-tertiary)",
141
+ fontWeight: "var(--weight-medium)",
142
+ textTransform: "capitalize",
143
+ }}>
144
+ {slot.cron.status}
145
+ </span>
146
+ </span>
147
+ {slot.cron.nextRun && (
148
+ <span style={{ color: "var(--text-tertiary)" }}>
149
+ Next: {new Date(slot.cron.nextRun).toLocaleString([], { weekday: "short", hour: "numeric", minute: "2-digit" })}
150
+ </span>
151
+ )}
152
+ </div>
153
+ {/* Error */}
154
+ {slot.cron.lastError && (
155
+ <div style={{
156
+ marginTop: "var(--space-2)",
157
+ padding: "var(--space-1) var(--space-2)",
158
+ background: "rgba(255,69,58,0.08)",
159
+ borderRadius: "var(--radius-sm)",
160
+ fontSize: "var(--text-caption2)",
161
+ color: "var(--system-red)",
162
+ overflow: "hidden",
163
+ textOverflow: "ellipsis",
164
+ whiteSpace: "nowrap",
165
+ }}>
166
+ {slot.cron.lastError}
167
+ </div>
168
+ )}
169
+ </div>
170
+ )
171
+ }
172
+
173
+ export function WeeklySchedule({ crons }: WeeklyScheduleProps) {
174
+ const [tooltip, setTooltip] = useState<TooltipData | null>(null)
175
+ const containerRef = useRef<HTMLDivElement>(null)
176
+ const [containerRect, setContainerRect] = useState<DOMRect | null>(null)
177
+
178
+ // Update container rect on scroll/resize
179
+ const updateContainerRect = useCallback(() => {
180
+ if (containerRef.current) {
181
+ setContainerRect(containerRef.current.getBoundingClientRect())
182
+ }
183
+ }, [])
184
+
185
+ useEffect(() => {
186
+ updateContainerRect()
187
+ const el = containerRef.current
188
+ if (!el) return
189
+
190
+ const scrollParent = el.closest("[class*='overflow-y']") || window
191
+ scrollParent.addEventListener("scroll", updateContainerRect, { passive: true })
192
+ window.addEventListener("resize", updateContainerRect, { passive: true })
193
+ return () => {
194
+ scrollParent.removeEventListener("scroll", updateContainerRect)
195
+ window.removeEventListener("resize", updateContainerRect)
196
+ }
197
+ }, [updateContainerRect])
198
+
199
+ // Parse all crons into schedule slots, grouped by (col, hour)
200
+ const { slotsByDayHour, activeHours } = useMemo(() => {
201
+ const map = new Map<string, SlotInfo[]>()
202
+ const hourSet = new Set<number>()
203
+
204
+ for (const cron of crons) {
205
+ if (!cron.enabled) continue
206
+ const parsed = parseScheduleSlots(cron.schedule)
207
+ if (!parsed) continue
208
+
209
+ for (const dow of parsed.days) {
210
+ const col = DOW_TO_COL[dow]
211
+ if (col === undefined) continue
212
+ const key = `${col}-${parsed.hour}`
213
+ const existing = map.get(key) || []
214
+ existing.push({ cron, hour: parsed.hour, minute: parsed.minute, col })
215
+ map.set(key, existing)
216
+ hourSet.add(parsed.hour)
217
+ }
218
+ }
219
+
220
+ // Sort slots within each cell by minute, then name
221
+ for (const [key, slots] of map) {
222
+ map.set(key, slots.sort((a, b) => a.minute - b.minute || a.cron.name.localeCompare(b.cron.name)))
223
+ }
224
+
225
+ // Active hours sorted
226
+ const activeHours = Array.from(hourSet).sort((a, b) => a - b)
227
+
228
+ return { slotsByDayHour: map, activeHours }
229
+ }, [crons])
230
+
231
+ // Current day/time
232
+ const now = new Date()
233
+ const nowDow = now.getDay() // 0=Sun
234
+ const nowCol = DOW_TO_COL[nowDow]
235
+ const nowHour = now.getHours()
236
+ const nowMinuteFrac = now.getMinutes() / 60
237
+
238
+ // Find max pills in any cell for a given hour (used for row sizing)
239
+ const maxPillsPerHour = useMemo(() => {
240
+ const result = new Map<number, number>()
241
+ for (const hour of activeHours) {
242
+ let max = 0
243
+ for (let col = 0; col < 7; col++) {
244
+ const key = `${col}-${hour}`
245
+ const count = slotsByDayHour.get(key)?.length || 0
246
+ if (count > max) max = count
247
+ }
248
+ result.set(hour, max)
249
+ }
250
+ return result
251
+ }, [activeHours, slotsByDayHour])
252
+
253
+ function handlePillClick(slot: SlotInfo, e: React.MouseEvent<HTMLButtonElement>) {
254
+ e.stopPropagation()
255
+ const pillRect = (e.currentTarget as HTMLElement).getBoundingClientRect()
256
+ updateContainerRect()
257
+ if (tooltip?.slot.cron.id === slot.cron.id && tooltip?.slot.col === slot.col && tooltip?.slot.hour === slot.hour) {
258
+ setTooltip(null)
259
+ } else {
260
+ setTooltip({ slot, rect: pillRect })
261
+ }
262
+ }
263
+
264
+ function handlePillEnter(slot: SlotInfo, e: React.MouseEvent<HTMLButtonElement>) {
265
+ const pillRect = (e.currentTarget as HTMLElement).getBoundingClientRect()
266
+ updateContainerRect()
267
+ setTooltip({ slot, rect: pillRect })
268
+ }
269
+
270
+ // Close tooltip when clicking outside
271
+ useEffect(() => {
272
+ if (!tooltip) return
273
+ const handler = () => setTooltip(null)
274
+ document.addEventListener("click", handler)
275
+ return () => document.removeEventListener("click", handler)
276
+ }, [tooltip])
277
+
278
+ if (activeHours.length === 0) {
279
+ return (
280
+ <div
281
+ className="flex flex-col items-center justify-center"
282
+ style={{
283
+ height: 200,
284
+ color: "var(--text-secondary)",
285
+ gap: "var(--space-2)",
286
+ }}
287
+ >
288
+ <svg
289
+ width="32" height="32" viewBox="0 0 24 24"
290
+ fill="none" stroke="currentColor" strokeWidth="1.5"
291
+ strokeLinecap="round" strokeLinejoin="round"
292
+ style={{ color: "var(--text-tertiary)", marginBottom: "var(--space-2)" }}
293
+ >
294
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
295
+ <line x1="16" y1="2" x2="16" y2="6" />
296
+ <line x1="8" y1="2" x2="8" y2="6" />
297
+ <line x1="3" y1="10" x2="21" y2="10" />
298
+ </svg>
299
+ <span style={{ fontSize: "var(--text-subheadline)", fontWeight: "var(--weight-medium)" }}>
300
+ No scheduled jobs to display
301
+ </span>
302
+ <span style={{ fontSize: "var(--text-footnote)", color: "var(--text-tertiary)" }}>
303
+ Enable some cron jobs to see the weekly schedule
304
+ </span>
305
+ </div>
306
+ )
307
+ }
308
+
309
+ return (
310
+ <div
311
+ ref={containerRef}
312
+ className="animate-fade-in"
313
+ style={{ position: "relative" }}
314
+ onClick={() => setTooltip(null)}
315
+ >
316
+ {/* Grid container */}
317
+ <div
318
+ style={{
319
+ display: "grid",
320
+ gridTemplateColumns: "56px repeat(7, 1fr)",
321
+ background: "var(--material-regular)",
322
+ borderRadius: "var(--radius-md)",
323
+ border: "1px solid var(--separator)",
324
+ overflow: "hidden",
325
+ }}
326
+ >
327
+ {/* ── Header row ────────────────────────────────── */}
328
+ {/* Empty corner cell */}
329
+ <div
330
+ style={{
331
+ padding: "var(--space-3) var(--space-2)",
332
+ borderBottom: "1px solid var(--separator)",
333
+ background: "var(--material-thick)",
334
+ }}
335
+ />
336
+ {/* Day headers */}
337
+ {DAY_LABELS.map((label, i) => {
338
+ const isToday = i === nowCol
339
+ return (
340
+ <div
341
+ key={label}
342
+ style={{
343
+ padding: "var(--space-3) var(--space-2)",
344
+ textAlign: "center",
345
+ borderBottom: "1px solid var(--separator)",
346
+ borderLeft: "1px solid var(--separator)",
347
+ background: isToday
348
+ ? "var(--accent-fill)"
349
+ : "var(--material-thick)",
350
+ position: "relative",
351
+ }}
352
+ >
353
+ <div
354
+ title={DAY_LABELS_FULL[i]}
355
+ style={{
356
+ fontSize: "var(--text-footnote)",
357
+ fontWeight: isToday ? "var(--weight-bold)" : "var(--weight-semibold)",
358
+ color: isToday ? "var(--accent)" : "var(--text-primary)",
359
+ letterSpacing: "0.02em",
360
+ }}
361
+ >
362
+ {label}
363
+ </div>
364
+ {/* Today indicator dot */}
365
+ {isToday && (
366
+ <div style={{
367
+ position: "absolute",
368
+ bottom: -3,
369
+ left: "50%",
370
+ transform: "translateX(-50%)",
371
+ width: 6,
372
+ height: 6,
373
+ borderRadius: "50%",
374
+ background: "var(--accent)",
375
+ zIndex: 2,
376
+ }} />
377
+ )}
378
+ </div>
379
+ )
380
+ })}
381
+
382
+ {/* ── Hour rows ─────────────────────────────────── */}
383
+ {activeHours.map((hour, hourIdx) => {
384
+ const maxPills = maxPillsPerHour.get(hour) || 1
385
+ // Each pill is 28px tall + 4px gap, plus some padding
386
+ const cellPadding = 8 // top + bottom
387
+ const pillHeight = 28
388
+ const pillGap = 4
389
+ const minCellHeight = cellPadding + maxPills * pillHeight + (maxPills - 1) * pillGap
390
+ const isNowHour = hour === nowHour
391
+ const isLastRow = hourIdx === activeHours.length - 1
392
+
393
+ return (
394
+ <div key={hour} style={{ display: "contents" }}>
395
+ {/* Hour label cell */}
396
+ <div
397
+ style={{
398
+ padding: "var(--space-2) var(--space-2)",
399
+ display: "flex",
400
+ alignItems: "flex-start",
401
+ justifyContent: "flex-end",
402
+ borderBottom: isLastRow ? "none" : "1px solid var(--separator)",
403
+ minHeight: minCellHeight,
404
+ background: isNowHour ? "var(--accent-fill)" : undefined,
405
+ position: "relative",
406
+ }}
407
+ >
408
+ <span
409
+ style={{
410
+ fontSize: "var(--text-caption1)",
411
+ fontFamily: "var(--font-mono)",
412
+ color: isNowHour ? "var(--accent)" : "var(--text-tertiary)",
413
+ fontWeight: isNowHour ? "var(--weight-semibold)" : "var(--weight-regular)",
414
+ lineHeight: "var(--leading-tight)",
415
+ whiteSpace: "nowrap",
416
+ paddingTop: 2,
417
+ }}
418
+ title={formatHour(hour)}
419
+ >
420
+ {formatHourShort(hour)}
421
+ </span>
422
+ </div>
423
+
424
+ {/* Day cells for this hour */}
425
+ {Array.from({ length: 7 }, (_, col) => {
426
+ const key = `${col}-${hour}`
427
+ const slots = slotsByDayHour.get(key) || []
428
+ const isToday = col === nowCol
429
+ const isNowCell = isToday && isNowHour
430
+
431
+ return (
432
+ <div
433
+ key={key}
434
+ style={{
435
+ padding: `${cellPadding / 2}px 4px`,
436
+ borderLeft: "1px solid var(--separator)",
437
+ borderBottom: isLastRow ? "none" : "1px solid var(--separator)",
438
+ minHeight: minCellHeight,
439
+ display: "flex",
440
+ flexDirection: "column",
441
+ gap: pillGap,
442
+ background: isNowCell
443
+ ? "color-mix(in srgb, var(--accent) 6%, transparent)"
444
+ : isToday
445
+ ? "color-mix(in srgb, var(--accent) 3%, transparent)"
446
+ : undefined,
447
+ position: "relative",
448
+ }}
449
+ >
450
+ {/* Now indicator line */}
451
+ {isNowCell && (
452
+ <div style={{
453
+ position: "absolute",
454
+ top: `${(nowMinuteFrac * 100).toFixed(1)}%`,
455
+ left: 0,
456
+ right: 0,
457
+ height: 2,
458
+ background: "var(--accent)",
459
+ opacity: 0.7,
460
+ zIndex: 3,
461
+ borderRadius: 1,
462
+ }} />
463
+ )}
464
+
465
+ {/* Pills */}
466
+ {slots.map((slot, slotIdx) => {
467
+ const agentId = slot.cron.agentId || ""
468
+ const color = AGENT_COLORS[agentId] || "var(--text-secondary)"
469
+ const isError = slot.cron.status === "error"
470
+ const isActive = tooltip?.slot.cron.id === slot.cron.id
471
+ && tooltip?.slot.col === slot.col
472
+ && tooltip?.slot.hour === slot.hour
473
+
474
+ return (
475
+ <button
476
+ key={`${key}-${slotIdx}`}
477
+ type="button"
478
+ title={`${slot.cron.name} - ${slot.cron.scheduleDescription || slot.cron.schedule}`}
479
+ onClick={(e) => handlePillClick(slot, e)}
480
+ onMouseEnter={(e) => handlePillEnter(slot, e)}
481
+ onMouseLeave={() => setTooltip(null)}
482
+ style={{
483
+ display: "flex",
484
+ alignItems: "center",
485
+ gap: 5,
486
+ height: pillHeight,
487
+ padding: "0 6px",
488
+ borderRadius: "var(--radius-sm)",
489
+ border: "none",
490
+ cursor: "pointer",
491
+ width: "100%",
492
+ minWidth: 0,
493
+ background: isActive
494
+ ? `color-mix(in srgb, ${color} 25%, transparent)`
495
+ : `color-mix(in srgb, ${color} 12%, transparent)`,
496
+ borderLeft: `3px solid ${color}`,
497
+ transition: "background 150ms var(--ease-smooth), box-shadow 150ms var(--ease-smooth)",
498
+ boxShadow: isActive
499
+ ? `0 0 0 1px color-mix(in srgb, ${color} 40%, transparent)`
500
+ : "none",
501
+ textAlign: "left",
502
+ position: "relative",
503
+ overflow: "hidden",
504
+ }}
505
+ onFocus={(e) => {
506
+ const pillRect = e.currentTarget.getBoundingClientRect()
507
+ updateContainerRect()
508
+ setTooltip({ slot, rect: pillRect })
509
+ }}
510
+ onBlur={() => setTooltip(null)}
511
+ >
512
+ {/* Status dot */}
513
+ <span
514
+ style={{
515
+ width: 6,
516
+ height: 6,
517
+ borderRadius: "50%",
518
+ flexShrink: 0,
519
+ background:
520
+ slot.cron.status === "ok" ? "var(--system-green)"
521
+ : isError ? "var(--system-red)"
522
+ : "var(--text-tertiary)",
523
+ }}
524
+ />
525
+ {/* Time */}
526
+ <span
527
+ style={{
528
+ fontSize: "var(--text-caption2)",
529
+ fontFamily: "var(--font-mono)",
530
+ color: "var(--text-tertiary)",
531
+ flexShrink: 0,
532
+ lineHeight: 1,
533
+ }}
534
+ >
535
+ {`:${String(slot.minute).padStart(2, "0")}`}
536
+ </span>
537
+ {/* Name */}
538
+ <span
539
+ style={{
540
+ fontSize: "var(--text-caption2)",
541
+ fontWeight: "var(--weight-semibold)",
542
+ color: color,
543
+ overflow: "hidden",
544
+ textOverflow: "ellipsis",
545
+ whiteSpace: "nowrap",
546
+ minWidth: 0,
547
+ flex: 1,
548
+ lineHeight: 1,
549
+ }}
550
+ >
551
+ {slot.cron.name}
552
+ </span>
553
+ {/* Error indicator */}
554
+ {isError && (
555
+ <span style={{
556
+ fontSize: 9,
557
+ color: "var(--system-red)",
558
+ flexShrink: 0,
559
+ lineHeight: 1,
560
+ }}>
561
+ !
562
+ </span>
563
+ )}
564
+ </button>
565
+ )
566
+ })}
567
+
568
+ {/* Empty state for cells with no jobs */}
569
+ {slots.length === 0 && (
570
+ <div style={{ flex: 1 }} />
571
+ )}
572
+ </div>
573
+ )
574
+ })}
575
+ </div>
576
+ )
577
+ })}
578
+ </div>
579
+
580
+ {/* Legend */}
581
+ <div
582
+ style={{
583
+ display: "flex",
584
+ flexWrap: "wrap",
585
+ gap: "var(--space-2) var(--space-4)",
586
+ marginTop: "var(--space-3)",
587
+ padding: "0 var(--space-1)",
588
+ }}
589
+ >
590
+ {Object.entries(AGENT_COLORS)
591
+ .filter(([agentId]) => crons.some(c => c.agentId === agentId && c.enabled))
592
+ .map(([agentId, color]) => (
593
+ <div
594
+ key={agentId}
595
+ style={{
596
+ display: "flex",
597
+ alignItems: "center",
598
+ gap: "var(--space-1)",
599
+ fontSize: "var(--text-caption2)",
600
+ color: "var(--text-tertiary)",
601
+ }}
602
+ >
603
+ <span
604
+ style={{
605
+ width: 8,
606
+ height: 8,
607
+ borderRadius: 2,
608
+ background: color,
609
+ flexShrink: 0,
610
+ opacity: 0.8,
611
+ }}
612
+ />
613
+ <span>{agentId}</span>
614
+ </div>
615
+ ))
616
+ }
617
+ </div>
618
+
619
+ {/* Tooltip overlay */}
620
+ {tooltip && containerRect && (
621
+ <PillTooltip
622
+ slot={tooltip.slot}
623
+ rect={tooltip.rect}
624
+ containerRect={containerRect}
625
+ />
626
+ )}
627
+
628
+ </div>
629
+ )
630
+ }