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,532 @@
1
+ "use client"
2
+
3
+ import type { Agent, CronJob } from "@/lib/types"
4
+ import { AgentAvatar } from "@/components/AgentAvatar"
5
+
6
+ interface GridViewProps {
7
+ agents: Agent[]
8
+ crons: CronJob[]
9
+ selectedId: string | null
10
+ onSelect: (agent: Agent) => void
11
+ }
12
+
13
+ function worstStatus(statuses: CronJob["status"][]): CronJob["status"] {
14
+ if (statuses.includes("error")) return "error"
15
+ if (statuses.includes("ok")) return "ok"
16
+ return "idle"
17
+ }
18
+
19
+ interface Team {
20
+ manager: Agent
21
+ members: Agent[]
22
+ }
23
+
24
+ function buildTeams(agents: Agent[]): { jarvis: Agent | null; teams: Team[]; soloOps: Agent[] } {
25
+ const jarvis = agents.find((a) => a.reportsTo === null) ?? null
26
+ if (!jarvis) return { jarvis: null, teams: [], soloOps: [] }
27
+
28
+ const byId = new Map(agents.map((a) => [a.id, a]))
29
+ const teamManagers: Agent[] = []
30
+ const soloOps: Agent[] = []
31
+
32
+ for (const rid of jarvis.directReports) {
33
+ const r = byId.get(rid)
34
+ if (!r) continue
35
+ if (r.directReports.length > 0) {
36
+ teamManagers.push(r)
37
+ } else {
38
+ soloOps.push(r)
39
+ }
40
+ }
41
+
42
+ const teams: Team[] = teamManagers.map((mgr) => {
43
+ const members: Agent[] = []
44
+ const queue = [...mgr.directReports]
45
+ while (queue.length > 0) {
46
+ const id = queue.shift()!
47
+ const a = byId.get(id)
48
+ if (a) {
49
+ members.push(a)
50
+ queue.push(...a.directReports)
51
+ }
52
+ }
53
+ return { manager: mgr, members }
54
+ })
55
+
56
+ return { jarvis, teams, soloOps }
57
+ }
58
+
59
+ function AgentCard({
60
+ agent,
61
+ crons,
62
+ selected,
63
+ onSelect,
64
+ }: {
65
+ agent: Agent
66
+ crons: CronJob[]
67
+ selected: boolean
68
+ onSelect: () => void
69
+ }) {
70
+ const agentCrons = crons.filter((c) => c.agentId === agent.id)
71
+ const cronStatus = worstStatus(agentCrons.map((c) => c.status))
72
+ const cronColor =
73
+ cronStatus === "ok"
74
+ ? "var(--system-green)"
75
+ : cronStatus === "error"
76
+ ? "var(--system-red)"
77
+ : "var(--text-tertiary)"
78
+
79
+ return (
80
+ <button
81
+ className="hover-lift focus-ring"
82
+ onClick={onSelect}
83
+ style={{
84
+ display: "flex",
85
+ alignItems: "center",
86
+ gap: "var(--space-3)",
87
+ padding: "var(--space-3) var(--space-4)",
88
+ borderRadius: "var(--radius-md)",
89
+ background: "var(--material-regular)",
90
+ border: selected
91
+ ? `1.5px solid ${agent.color}`
92
+ : "1px solid var(--separator)",
93
+ borderTop: `2px solid ${agent.color}`,
94
+ cursor: "pointer",
95
+ width: "100%",
96
+ textAlign: "left",
97
+ transition: "all 150ms var(--ease-spring)",
98
+ boxShadow: selected
99
+ ? `0 0 0 1px ${agent.color}40, 0 4px 16px ${agent.color}18`
100
+ : "var(--shadow-subtle)",
101
+ }}
102
+ >
103
+ <AgentAvatar
104
+ agent={agent}
105
+ size={40}
106
+ borderRadius={11}
107
+ style={{
108
+ border: `1px solid ${agent.color}30`,
109
+ }}
110
+ />
111
+ <div style={{ flex: 1, minWidth: 0 }}>
112
+ <div
113
+ style={{
114
+ fontSize: "var(--text-body)",
115
+ fontWeight: "var(--weight-semibold)",
116
+ color: "var(--text-primary)",
117
+ whiteSpace: "nowrap",
118
+ overflow: "hidden",
119
+ textOverflow: "ellipsis",
120
+ lineHeight: "var(--leading-tight)",
121
+ }}
122
+ >
123
+ {agent.name}
124
+ </div>
125
+ <div
126
+ style={{
127
+ fontSize: "var(--text-caption1)",
128
+ color: agent.color,
129
+ opacity: 0.8,
130
+ whiteSpace: "nowrap",
131
+ overflow: "hidden",
132
+ textOverflow: "ellipsis",
133
+ marginTop: 1,
134
+ }}
135
+ >
136
+ {agent.title}
137
+ </div>
138
+ {agent.description && (
139
+ <div
140
+ style={{
141
+ fontSize: "var(--text-caption2)",
142
+ color: "var(--text-tertiary)",
143
+ whiteSpace: "nowrap",
144
+ overflow: "hidden",
145
+ textOverflow: "ellipsis",
146
+ marginTop: 2,
147
+ }}
148
+ >
149
+ {agent.description}
150
+ </div>
151
+ )}
152
+ </div>
153
+ <div
154
+ style={{
155
+ display: "flex",
156
+ flexDirection: "column",
157
+ alignItems: "flex-end",
158
+ gap: 4,
159
+ flexShrink: 0,
160
+ }}
161
+ >
162
+ {agentCrons.length > 0 && (
163
+ <span
164
+ style={{
165
+ display: "flex",
166
+ alignItems: "center",
167
+ gap: 4,
168
+ fontSize: "var(--text-caption2)",
169
+ color: cronColor,
170
+ fontWeight: "var(--weight-medium)",
171
+ }}
172
+ >
173
+ <span
174
+ className={cronStatus === "error" ? "animate-error-pulse" : ""}
175
+ style={{
176
+ width: 6,
177
+ height: 6,
178
+ borderRadius: "50%",
179
+ background: cronColor,
180
+ display: "inline-block",
181
+ }}
182
+ />
183
+ {agentCrons.length} cron{agentCrons.length !== 1 ? "s" : ""}
184
+ </span>
185
+ )}
186
+ {agent.tools.length > 0 && (
187
+ <span
188
+ style={{
189
+ fontSize: "var(--text-caption2)",
190
+ fontWeight: "var(--weight-medium)",
191
+ color: "var(--text-quaternary)",
192
+ background: "var(--fill-quaternary)",
193
+ padding: "1px 7px",
194
+ borderRadius: 10,
195
+ }}
196
+ >
197
+ {agent.tools.length} tools
198
+ </span>
199
+ )}
200
+ </div>
201
+ </button>
202
+ )
203
+ }
204
+
205
+ function TeamSection({
206
+ label,
207
+ icon,
208
+ count,
209
+ errorCount,
210
+ children,
211
+ }: {
212
+ label: string
213
+ icon: React.ReactNode
214
+ count: number
215
+ errorCount: number
216
+ children: React.ReactNode
217
+ }) {
218
+ return (
219
+ <div
220
+ style={{
221
+ background: "var(--bg-secondary)",
222
+ borderRadius: "var(--radius-lg)",
223
+ border: "1px solid var(--separator)",
224
+ padding: "var(--space-4)",
225
+ display: "flex",
226
+ flexDirection: "column",
227
+ gap: "var(--space-2)",
228
+ }}
229
+ >
230
+ {/* Header */}
231
+ <div
232
+ style={{
233
+ display: "flex",
234
+ alignItems: "center",
235
+ gap: "var(--space-2)",
236
+ marginBottom: "var(--space-1)",
237
+ }}
238
+ >
239
+ {icon}
240
+ <span
241
+ style={{
242
+ fontSize: "var(--text-caption1)",
243
+ fontWeight: "var(--weight-semibold)",
244
+ letterSpacing: "var(--tracking-wide)",
245
+ textTransform: "uppercase",
246
+ color: "var(--text-tertiary)",
247
+ }}
248
+ >
249
+ {label}
250
+ </span>
251
+ <span
252
+ style={{
253
+ fontSize: "var(--text-caption2)",
254
+ color: "var(--text-quaternary)",
255
+ marginLeft: "auto",
256
+ }}
257
+ >
258
+ {count} agent{count !== 1 ? "s" : ""}
259
+ {errorCount > 0 && (
260
+ <span style={{ color: "var(--system-red)", marginLeft: 6 }}>
261
+ {errorCount} err
262
+ </span>
263
+ )}
264
+ </span>
265
+ </div>
266
+ {children}
267
+ </div>
268
+ )
269
+ }
270
+
271
+ export function GridView({ agents, crons, selectedId, onSelect }: GridViewProps) {
272
+ const { jarvis, teams, soloOps } = buildTeams(agents)
273
+
274
+ const totalCrons = crons.length
275
+ const healthyCrons = crons.filter((c) => c.status === "ok").length
276
+ const errorCrons = crons.filter((c) => c.status === "error").length
277
+ const healthPct = totalCrons === 0 ? 100 : Math.round((healthyCrons / totalCrons) * 100)
278
+
279
+ return (
280
+ <div
281
+ className="h-full"
282
+ style={{
283
+ overflowY: "auto",
284
+ padding: "var(--space-6)",
285
+ paddingTop: 52,
286
+ }}
287
+ >
288
+ {/* Jarvis hero banner */}
289
+ {jarvis && (
290
+ <button
291
+ className="hover-lift focus-ring"
292
+ onClick={() => onSelect(jarvis)}
293
+ style={{
294
+ display: "flex",
295
+ alignItems: "center",
296
+ gap: "var(--space-5)",
297
+ width: "100%",
298
+ padding: "var(--space-5) var(--space-6)",
299
+ borderRadius: "var(--radius-xl)",
300
+ background: `linear-gradient(135deg, var(--material-regular) 0%, ${jarvis.color}08 100%)`,
301
+ border: selectedId === jarvis.id
302
+ ? `1.5px solid ${jarvis.color}`
303
+ : "1px solid var(--separator)",
304
+ cursor: "pointer",
305
+ textAlign: "left",
306
+ marginBottom: "var(--space-6)",
307
+ transition: "all 150ms var(--ease-spring)",
308
+ boxShadow: selectedId === jarvis.id
309
+ ? `0 0 0 1px ${jarvis.color}40, 0 8px 32px ${jarvis.color}12`
310
+ : "var(--shadow-card)",
311
+ position: "relative",
312
+ overflow: "hidden",
313
+ }}
314
+ >
315
+ <AgentAvatar
316
+ agent={jarvis}
317
+ size={64}
318
+ borderRadius={18}
319
+ style={{
320
+ border: `1.5px solid ${jarvis.color}50`,
321
+ boxShadow: `0 4px 20px ${jarvis.color}20`,
322
+ }}
323
+ />
324
+ <div style={{ flex: 1, minWidth: 0 }}>
325
+ <div
326
+ style={{
327
+ fontSize: "var(--text-title2)",
328
+ fontWeight: "var(--weight-bold)",
329
+ color: "var(--text-primary)",
330
+ letterSpacing: "var(--tracking-tight)",
331
+ lineHeight: "var(--leading-tight)",
332
+ }}
333
+ >
334
+ {jarvis.name}
335
+ </div>
336
+ <div
337
+ style={{
338
+ fontSize: "var(--text-subheadline)",
339
+ color: jarvis.color,
340
+ opacity: 0.85,
341
+ marginTop: 2,
342
+ }}
343
+ >
344
+ {jarvis.title}
345
+ </div>
346
+ {jarvis.description && (
347
+ <div
348
+ style={{
349
+ fontSize: "var(--text-caption1)",
350
+ color: "var(--text-tertiary)",
351
+ marginTop: "var(--space-1)",
352
+ }}
353
+ >
354
+ {jarvis.description}
355
+ </div>
356
+ )}
357
+ </div>
358
+
359
+ {/* Stats cluster */}
360
+ <div
361
+ style={{
362
+ display: "flex",
363
+ gap: "var(--space-4)",
364
+ flexShrink: 0,
365
+ }}
366
+ >
367
+ <div style={{ textAlign: "center" }}>
368
+ <div
369
+ style={{
370
+ fontSize: "var(--text-title3)",
371
+ fontWeight: "var(--weight-bold)",
372
+ color: "var(--text-primary)",
373
+ lineHeight: 1,
374
+ }}
375
+ >
376
+ {agents.length}
377
+ </div>
378
+ <div
379
+ style={{
380
+ fontSize: "var(--text-caption2)",
381
+ color: "var(--text-tertiary)",
382
+ marginTop: 2,
383
+ }}
384
+ >
385
+ agents
386
+ </div>
387
+ </div>
388
+ <div
389
+ style={{
390
+ width: 1,
391
+ alignSelf: "stretch",
392
+ background: "var(--separator)",
393
+ }}
394
+ />
395
+ <div style={{ textAlign: "center" }}>
396
+ <div
397
+ style={{
398
+ fontSize: "var(--text-title3)",
399
+ fontWeight: "var(--weight-bold)",
400
+ color: "var(--text-primary)",
401
+ lineHeight: 1,
402
+ }}
403
+ >
404
+ {totalCrons}
405
+ </div>
406
+ <div
407
+ style={{
408
+ fontSize: "var(--text-caption2)",
409
+ color: "var(--text-tertiary)",
410
+ marginTop: 2,
411
+ }}
412
+ >
413
+ crons
414
+ </div>
415
+ </div>
416
+ <div
417
+ style={{
418
+ width: 1,
419
+ alignSelf: "stretch",
420
+ background: "var(--separator)",
421
+ }}
422
+ />
423
+ <div style={{ textAlign: "center" }}>
424
+ <div
425
+ style={{
426
+ fontSize: "var(--text-title3)",
427
+ fontWeight: "var(--weight-bold)",
428
+ color: errorCrons > 0 ? "var(--system-red)" : "var(--system-green)",
429
+ lineHeight: 1,
430
+ }}
431
+ >
432
+ {healthPct}%
433
+ </div>
434
+ <div
435
+ style={{
436
+ fontSize: "var(--text-caption2)",
437
+ color: "var(--text-tertiary)",
438
+ marginTop: 2,
439
+ }}
440
+ >
441
+ health
442
+ </div>
443
+ </div>
444
+ </div>
445
+ </button>
446
+ )}
447
+
448
+ {/* Team columns */}
449
+ <div
450
+ style={{
451
+ display: "grid",
452
+ gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
453
+ gap: "var(--space-5)",
454
+ alignItems: "start",
455
+ }}
456
+ >
457
+ {teams.map((team) => {
458
+ const teamCrons = crons.filter(
459
+ (c) => c.agentId === team.manager.id || team.members.some((m) => m.id === c.agentId),
460
+ )
461
+ const teamErrors = teamCrons.filter((c) => c.status === "error").length
462
+
463
+ return (
464
+ <TeamSection
465
+ key={team.manager.id}
466
+ label={`Team ${team.manager.name}`}
467
+ icon={<AgentAvatar agent={team.manager} size={22} borderRadius={6} />}
468
+ count={1 + team.members.length}
469
+ errorCount={teamErrors}
470
+ >
471
+ <AgentCard
472
+ agent={team.manager}
473
+ crons={crons}
474
+ selected={selectedId === team.manager.id}
475
+ onSelect={() => onSelect(team.manager)}
476
+ />
477
+ {team.members.map((m) => (
478
+ <AgentCard
479
+ key={m.id}
480
+ agent={m}
481
+ crons={crons}
482
+ selected={selectedId === m.id}
483
+ onSelect={() => onSelect(m)}
484
+ />
485
+ ))}
486
+ </TeamSection>
487
+ )
488
+ })}
489
+
490
+ {/* Solo Ops column */}
491
+ {soloOps.length > 0 && (
492
+ <TeamSection
493
+ label="Solo Ops"
494
+ icon={
495
+ <span
496
+ style={{
497
+ width: 22,
498
+ height: 22,
499
+ borderRadius: 6,
500
+ background: "var(--fill-tertiary)",
501
+ display: "flex",
502
+ alignItems: "center",
503
+ justifyContent: "center",
504
+ fontSize: 12,
505
+ flexShrink: 0,
506
+ }}
507
+ >
508
+ &#x26A1;
509
+ </span>
510
+ }
511
+ count={soloOps.length}
512
+ errorCount={
513
+ crons.filter(
514
+ (c) => soloOps.some((a) => a.id === c.agentId) && c.status === "error",
515
+ ).length
516
+ }
517
+ >
518
+ {soloOps.map((a) => (
519
+ <AgentCard
520
+ key={a.id}
521
+ agent={a}
522
+ crons={crons}
523
+ selected={selectedId === a.id}
524
+ onSelect={() => onSelect(a)}
525
+ />
526
+ ))}
527
+ </TeamSection>
528
+ )}
529
+ </div>
530
+ </div>
531
+ )
532
+ }
@@ -0,0 +1,157 @@
1
+ "use client"
2
+ import {
3
+ ReactFlow,
4
+ Controls,
5
+ useNodesState,
6
+ useEdgesState,
7
+ type Node,
8
+ type Edge,
9
+ ConnectionLineType,
10
+ } from "@xyflow/react"
11
+ import { useCallback, useEffect } from "react"
12
+ import type { Agent, CronJob } from "@/lib/types"
13
+ import { nodeTypes } from "@/components/AgentNode"
14
+
15
+ interface OrgMapProps {
16
+ agents: Agent[]
17
+ crons: CronJob[]
18
+ selectedId: string | null
19
+ onNodeClick: (agent: Agent) => void
20
+ }
21
+
22
+ function buildLayout(
23
+ agents: Agent[],
24
+ crons: CronJob[],
25
+ selectedId: string | null,
26
+ ): { nodes: Node[]; edges: Edge[] } {
27
+ const agentMap = new Map(agents.map((a) => [a.id, a]))
28
+ const withCrons = agents.map((a) => ({
29
+ ...a,
30
+ crons: crons.filter((c) => c.agentId === a.id),
31
+ }))
32
+ const agentMapWithCrons = new Map(withCrons.map((a) => [a.id, a]))
33
+
34
+ // BFS to determine levels
35
+ const levels: string[][] = []
36
+ const visited = new Set<string>()
37
+ const root = agents.find((a) => a.reportsTo === null)
38
+ if (!root) return { nodes: [], edges: [] }
39
+
40
+ let queue = [root.id]
41
+ while (queue.length > 0) {
42
+ levels.push([...queue])
43
+ queue.forEach((id) => visited.add(id))
44
+ const nextQueue: string[] = []
45
+ for (const id of queue) {
46
+ const agent = agentMap.get(id)
47
+ if (!agent) continue
48
+ for (const childId of agent.directReports) {
49
+ if (!visited.has(childId)) nextQueue.push(childId)
50
+ }
51
+ }
52
+ queue = nextQueue
53
+ }
54
+
55
+ // Pick up disconnected agents
56
+ const disconnected = agents.filter((a) => !visited.has(a.id))
57
+ if (disconnected.length > 0) levels.push(disconnected.map((a) => a.id))
58
+
59
+ const LEVEL_HEIGHT = 200
60
+ const nodes: Node[] = []
61
+
62
+ for (let level = 0; level < levels.length; level++) {
63
+ const ids = levels[level]
64
+ const spacing = Math.max(200, Math.min(260, 1600 / Math.max(ids.length, 1)))
65
+ const totalWidth = ids.length * spacing
66
+ const startX = 600 - totalWidth / 2 + spacing / 2
67
+
68
+ ids.forEach((id, i) => {
69
+ const agent = agentMapWithCrons.get(id)
70
+ if (!agent) return
71
+ nodes.push({
72
+ id,
73
+ type: "agentNode",
74
+ data: agent as unknown as Record<string, unknown>,
75
+ position: { x: startX + i * spacing - spacing / 2, y: level * LEVEL_HEIGHT + 20 },
76
+ selected: id === selectedId,
77
+ })
78
+ })
79
+ }
80
+
81
+ // Build edges -- selected agent's edges get accent color
82
+ const selectedAgentIds = new Set<string>()
83
+ if (selectedId) {
84
+ selectedAgentIds.add(selectedId)
85
+ const selectedAgent = agentMap.get(selectedId)
86
+ if (selectedAgent) {
87
+ if (selectedAgent.reportsTo) selectedAgentIds.add(selectedAgent.reportsTo)
88
+ selectedAgent.directReports.forEach((id) => selectedAgentIds.add(id))
89
+ }
90
+ }
91
+
92
+ const edges: Edge[] = []
93
+ for (const agent of agents) {
94
+ for (const childId of agent.directReports) {
95
+ const isHighlighted =
96
+ selectedId && selectedAgentIds.has(agent.id) && selectedAgentIds.has(childId)
97
+
98
+ edges.push({
99
+ id: `${agent.id}-${childId}`,
100
+ source: agent.id,
101
+ target: childId,
102
+ type: "smoothstep",
103
+ style: {
104
+ stroke: isHighlighted ? "var(--accent)" : "var(--separator)",
105
+ strokeWidth: isHighlighted ? 2 : 1.5,
106
+ opacity: isHighlighted ? 1 : 0.6,
107
+ strokeDasharray: isHighlighted ? undefined : "6 4",
108
+ },
109
+ animated: !!isHighlighted,
110
+ })
111
+ }
112
+ }
113
+
114
+ return { nodes, edges }
115
+ }
116
+
117
+ export function OrgMap({ agents, crons, selectedId, onNodeClick }: OrgMapProps) {
118
+ const { nodes: initialNodes, edges: initialEdges } = buildLayout(agents, crons, selectedId)
119
+ const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
120
+ const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
121
+
122
+ useEffect(() => {
123
+ const { nodes: n, edges: e } = buildLayout(agents, crons, selectedId)
124
+ setNodes(n)
125
+ setEdges(e)
126
+ }, [agents, crons, selectedId, setNodes, setEdges])
127
+
128
+ const handleNodeClick = useCallback(
129
+ (_: React.MouseEvent, node: Node) => {
130
+ const agent = agents.find((a) => a.id === node.id)
131
+ if (agent) onNodeClick(agent)
132
+ },
133
+ [agents, onNodeClick],
134
+ )
135
+
136
+ return (
137
+ <ReactFlow
138
+ nodes={nodes}
139
+ edges={edges}
140
+ onNodesChange={onNodesChange}
141
+ onEdgesChange={onEdgesChange}
142
+ onNodeClick={handleNodeClick}
143
+ nodeTypes={nodeTypes}
144
+ connectionLineType={ConnectionLineType.SmoothStep}
145
+ fitView
146
+ fitViewOptions={{ padding: 0.2 }}
147
+ minZoom={0.2}
148
+ maxZoom={2}
149
+ proOptions={{ hideAttribution: true }}
150
+ >
151
+ <Controls
152
+ position="bottom-left"
153
+ style={{ left: 16, bottom: 16 }}
154
+ />
155
+ </ReactFlow>
156
+ )
157
+ }