clawport-ui 0.4.6 → 0.5.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.
- package/components/AgentNode.tsx +133 -56
- package/components/GridView.tsx +17 -55
- package/components/OrgMap.tsx +291 -68
- package/lib/agents-registry.ts +3 -0
- package/lib/agents.test.ts +41 -0
- package/lib/teams.ts +44 -0
- package/package.json +2 -1
package/components/AgentNode.tsx
CHANGED
|
@@ -10,6 +10,8 @@ export function AgentNode({ data, selected }: NodeProps) {
|
|
|
10
10
|
const hasCrons = agent.crons && agent.crons.length > 0
|
|
11
11
|
const hasErrors = hasCrons && agent.crons.some((c: CronJob) => c.status === "error")
|
|
12
12
|
const cronCount = hasCrons ? agent.crons.length : 0
|
|
13
|
+
const toolCount = agent.tools?.length ?? 0
|
|
14
|
+
const reportCount = agent.directReports?.length ?? 0
|
|
13
15
|
|
|
14
16
|
return (
|
|
15
17
|
<div
|
|
@@ -25,14 +27,13 @@ export function AgentNode({ data, selected }: NodeProps) {
|
|
|
25
27
|
borderBottom: `1px solid ${selected ? "var(--accent)" : "var(--separator)"}`,
|
|
26
28
|
borderLeft: `1px solid ${selected ? "var(--accent)" : "var(--separator)"}`,
|
|
27
29
|
padding: "var(--space-3) var(--space-4)",
|
|
28
|
-
|
|
29
|
-
maxWidth: 220,
|
|
30
|
+
width: 260,
|
|
30
31
|
cursor: "pointer",
|
|
31
32
|
position: "relative",
|
|
32
33
|
boxShadow: selected ? "0 0 0 1px var(--accent), var(--shadow-card)" : "var(--shadow-card)",
|
|
33
34
|
}}
|
|
34
35
|
>
|
|
35
|
-
{/* Emoji + Name row */}
|
|
36
|
+
{/* Emoji + Name + Title row */}
|
|
36
37
|
<div
|
|
37
38
|
style={{
|
|
38
39
|
display: "flex",
|
|
@@ -42,75 +43,119 @@ export function AgentNode({ data, selected }: NodeProps) {
|
|
|
42
43
|
}}
|
|
43
44
|
>
|
|
44
45
|
<AgentAvatar agent={agent} size={30} borderRadius={8} />
|
|
45
|
-
<div
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
47
|
+
<div
|
|
48
|
+
style={{
|
|
49
|
+
fontSize: "var(--text-body)",
|
|
50
|
+
fontWeight: "var(--weight-semibold)",
|
|
51
|
+
color: "var(--text-primary)",
|
|
52
|
+
whiteSpace: "nowrap",
|
|
53
|
+
overflow: "hidden",
|
|
54
|
+
textOverflow: "ellipsis",
|
|
55
|
+
lineHeight: "var(--leading-tight)",
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{agent.name}
|
|
59
|
+
</div>
|
|
60
|
+
<div
|
|
61
|
+
style={{
|
|
62
|
+
fontSize: "var(--text-caption2)",
|
|
63
|
+
color: agent.color,
|
|
64
|
+
opacity: 0.85,
|
|
65
|
+
whiteSpace: "nowrap",
|
|
66
|
+
overflow: "hidden",
|
|
67
|
+
textOverflow: "ellipsis",
|
|
68
|
+
marginTop: 1,
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{agent.title}
|
|
72
|
+
</div>
|
|
56
73
|
</div>
|
|
57
74
|
</div>
|
|
58
75
|
|
|
59
|
-
{/*
|
|
60
|
-
<div
|
|
61
|
-
style={{
|
|
62
|
-
fontSize: "var(--text-caption1)",
|
|
63
|
-
color: "var(--text-secondary)",
|
|
64
|
-
whiteSpace: "nowrap",
|
|
65
|
-
overflow: "hidden",
|
|
66
|
-
textOverflow: "ellipsis",
|
|
67
|
-
marginTop: 1,
|
|
68
|
-
}}
|
|
69
|
-
>
|
|
70
|
-
{agent.title}
|
|
71
|
-
</div>
|
|
72
|
-
|
|
73
|
-
{/* Description snippet */}
|
|
76
|
+
{/* Description — allow 2 lines */}
|
|
74
77
|
{agent.description && (
|
|
75
78
|
<div
|
|
76
79
|
style={{
|
|
77
80
|
fontSize: "var(--text-caption2)",
|
|
81
|
+
lineHeight: 1.4,
|
|
78
82
|
color: "var(--text-tertiary)",
|
|
79
|
-
|
|
83
|
+
marginTop: "var(--space-1)",
|
|
84
|
+
display: "-webkit-box",
|
|
85
|
+
WebkitLineClamp: 2,
|
|
86
|
+
WebkitBoxOrient: "vertical",
|
|
80
87
|
overflow: "hidden",
|
|
81
|
-
textOverflow: "ellipsis",
|
|
82
|
-
marginTop: 2,
|
|
83
88
|
}}
|
|
84
89
|
>
|
|
85
90
|
{agent.description}
|
|
86
91
|
</div>
|
|
87
92
|
)}
|
|
88
93
|
|
|
89
|
-
{/*
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<div
|
|
102
|
-
className={hasErrors ? "animate-error-pulse" : ""}
|
|
94
|
+
{/* Stats row */}
|
|
95
|
+
<div
|
|
96
|
+
style={{
|
|
97
|
+
display: "flex",
|
|
98
|
+
alignItems: "center",
|
|
99
|
+
gap: "var(--space-2)",
|
|
100
|
+
marginTop: "var(--space-2)",
|
|
101
|
+
flexWrap: "wrap",
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{toolCount > 0 && (
|
|
105
|
+
<span
|
|
103
106
|
style={{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
background:
|
|
108
|
-
|
|
107
|
+
fontSize: "var(--text-caption2)",
|
|
108
|
+
fontWeight: "var(--weight-medium)",
|
|
109
|
+
color: "var(--text-quaternary)",
|
|
110
|
+
background: "var(--fill-quaternary)",
|
|
111
|
+
padding: "1px 7px",
|
|
112
|
+
borderRadius: 10,
|
|
109
113
|
}}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
>
|
|
115
|
+
{toolCount} tools
|
|
116
|
+
</span>
|
|
117
|
+
)}
|
|
118
|
+
{reportCount > 0 && (
|
|
119
|
+
<span
|
|
120
|
+
style={{
|
|
121
|
+
fontSize: "var(--text-caption2)",
|
|
122
|
+
fontWeight: "var(--weight-medium)",
|
|
123
|
+
color: "var(--text-quaternary)",
|
|
124
|
+
background: "var(--fill-quaternary)",
|
|
125
|
+
padding: "1px 7px",
|
|
126
|
+
borderRadius: 10,
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
{reportCount} reports
|
|
130
|
+
</span>
|
|
131
|
+
)}
|
|
132
|
+
{hasCrons && (
|
|
133
|
+
<span
|
|
134
|
+
style={{
|
|
135
|
+
display: "inline-flex",
|
|
136
|
+
alignItems: "center",
|
|
137
|
+
gap: 4,
|
|
138
|
+
fontSize: "var(--text-caption2)",
|
|
139
|
+
fontWeight: "var(--weight-medium)",
|
|
140
|
+
color: hasErrors ? "var(--system-red)" : "var(--system-green)",
|
|
141
|
+
background: hasErrors ? "var(--system-red)10" : "var(--system-green)10",
|
|
142
|
+
padding: "1px 7px",
|
|
143
|
+
borderRadius: 10,
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<span
|
|
147
|
+
className={hasErrors ? "animate-error-pulse" : ""}
|
|
148
|
+
style={{
|
|
149
|
+
width: 5,
|
|
150
|
+
height: 5,
|
|
151
|
+
borderRadius: "50%",
|
|
152
|
+
background: hasErrors ? "var(--system-red)" : "var(--system-green)",
|
|
153
|
+
}}
|
|
154
|
+
/>
|
|
155
|
+
{cronCount} cron{cronCount !== 1 ? "s" : ""}
|
|
156
|
+
</span>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
114
159
|
|
|
115
160
|
{/* Handles - invisible */}
|
|
116
161
|
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
|
|
@@ -119,4 +164,36 @@ export function AgentNode({ data, selected }: NodeProps) {
|
|
|
119
164
|
)
|
|
120
165
|
}
|
|
121
166
|
|
|
122
|
-
|
|
167
|
+
function TeamGroupNode({ data }: NodeProps) {
|
|
168
|
+
const { label, color } = data as { label: string; color?: string } & Record<string, unknown>
|
|
169
|
+
return (
|
|
170
|
+
<div
|
|
171
|
+
style={{
|
|
172
|
+
width: "100%",
|
|
173
|
+
height: "100%",
|
|
174
|
+
position: "relative",
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
<div
|
|
178
|
+
style={{
|
|
179
|
+
position: "absolute",
|
|
180
|
+
top: 10,
|
|
181
|
+
left: 0,
|
|
182
|
+
right: 0,
|
|
183
|
+
textAlign: "center",
|
|
184
|
+
fontSize: "var(--text-caption2)",
|
|
185
|
+
fontWeight: "var(--weight-semibold)",
|
|
186
|
+
letterSpacing: "var(--tracking-wide)",
|
|
187
|
+
textTransform: "uppercase",
|
|
188
|
+
color: color ?? "var(--text-tertiary)",
|
|
189
|
+
userSelect: "none",
|
|
190
|
+
pointerEvents: "none",
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
{label}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const nodeTypes = { agentNode: AgentNode, teamGroup: TeamGroupNode }
|
package/components/GridView.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import type { Agent, CronJob } from "@/lib/types"
|
|
4
|
+
import { buildTeams } from "@/lib/teams"
|
|
4
5
|
import { AgentAvatar } from "@/components/AgentAvatar"
|
|
5
6
|
|
|
6
7
|
interface GridViewProps {
|
|
@@ -16,45 +17,6 @@ function worstStatus(statuses: CronJob["status"][]): CronJob["status"] {
|
|
|
16
17
|
return "idle"
|
|
17
18
|
}
|
|
18
19
|
|
|
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
20
|
|
|
59
21
|
function AgentCard({
|
|
60
22
|
agent,
|
|
@@ -269,7 +231,7 @@ function TeamSection({
|
|
|
269
231
|
}
|
|
270
232
|
|
|
271
233
|
export function GridView({ agents, crons, selectedId, onSelect }: GridViewProps) {
|
|
272
|
-
const {
|
|
234
|
+
const { root, teams, soloOps } = buildTeams(agents)
|
|
273
235
|
|
|
274
236
|
const totalCrons = crons.length
|
|
275
237
|
const healthyCrons = crons.filter((c) => c.status === "ok").length
|
|
@@ -286,10 +248,10 @@ export function GridView({ agents, crons, selectedId, onSelect }: GridViewProps)
|
|
|
286
248
|
}}
|
|
287
249
|
>
|
|
288
250
|
{/* Jarvis hero banner */}
|
|
289
|
-
{
|
|
251
|
+
{root && (
|
|
290
252
|
<button
|
|
291
253
|
className="hover-lift focus-ring"
|
|
292
|
-
onClick={() => onSelect(
|
|
254
|
+
onClick={() => onSelect(root)}
|
|
293
255
|
style={{
|
|
294
256
|
display: "flex",
|
|
295
257
|
alignItems: "center",
|
|
@@ -297,28 +259,28 @@ export function GridView({ agents, crons, selectedId, onSelect }: GridViewProps)
|
|
|
297
259
|
width: "100%",
|
|
298
260
|
padding: "var(--space-5) var(--space-6)",
|
|
299
261
|
borderRadius: "var(--radius-xl)",
|
|
300
|
-
background: `linear-gradient(135deg, var(--material-regular) 0%, ${
|
|
301
|
-
border: selectedId ===
|
|
302
|
-
? `1.5px solid ${
|
|
262
|
+
background: `linear-gradient(135deg, var(--material-regular) 0%, ${root.color}08 100%)`,
|
|
263
|
+
border: selectedId === root.id
|
|
264
|
+
? `1.5px solid ${root.color}`
|
|
303
265
|
: "1px solid var(--separator)",
|
|
304
266
|
cursor: "pointer",
|
|
305
267
|
textAlign: "left",
|
|
306
268
|
marginBottom: "var(--space-6)",
|
|
307
269
|
transition: "all 150ms var(--ease-spring)",
|
|
308
|
-
boxShadow: selectedId ===
|
|
309
|
-
? `0 0 0 1px ${
|
|
270
|
+
boxShadow: selectedId === root.id
|
|
271
|
+
? `0 0 0 1px ${root.color}40, 0 8px 32px ${root.color}12`
|
|
310
272
|
: "var(--shadow-card)",
|
|
311
273
|
position: "relative",
|
|
312
274
|
overflow: "hidden",
|
|
313
275
|
}}
|
|
314
276
|
>
|
|
315
277
|
<AgentAvatar
|
|
316
|
-
agent={
|
|
278
|
+
agent={root}
|
|
317
279
|
size={64}
|
|
318
280
|
borderRadius={18}
|
|
319
281
|
style={{
|
|
320
|
-
border: `1.5px solid ${
|
|
321
|
-
boxShadow: `0 4px 20px ${
|
|
282
|
+
border: `1.5px solid ${root.color}50`,
|
|
283
|
+
boxShadow: `0 4px 20px ${root.color}20`,
|
|
322
284
|
}}
|
|
323
285
|
/>
|
|
324
286
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
@@ -331,19 +293,19 @@ export function GridView({ agents, crons, selectedId, onSelect }: GridViewProps)
|
|
|
331
293
|
lineHeight: "var(--leading-tight)",
|
|
332
294
|
}}
|
|
333
295
|
>
|
|
334
|
-
{
|
|
296
|
+
{root.name}
|
|
335
297
|
</div>
|
|
336
298
|
<div
|
|
337
299
|
style={{
|
|
338
300
|
fontSize: "var(--text-subheadline)",
|
|
339
|
-
color:
|
|
301
|
+
color: root.color,
|
|
340
302
|
opacity: 0.85,
|
|
341
303
|
marginTop: 2,
|
|
342
304
|
}}
|
|
343
305
|
>
|
|
344
|
-
{
|
|
306
|
+
{root.title}
|
|
345
307
|
</div>
|
|
346
|
-
{
|
|
308
|
+
{root.description && (
|
|
347
309
|
<div
|
|
348
310
|
style={{
|
|
349
311
|
fontSize: "var(--text-caption1)",
|
|
@@ -351,7 +313,7 @@ export function GridView({ agents, crons, selectedId, onSelect }: GridViewProps)
|
|
|
351
313
|
marginTop: "var(--space-1)",
|
|
352
314
|
}}
|
|
353
315
|
>
|
|
354
|
-
{
|
|
316
|
+
{root.description}
|
|
355
317
|
</div>
|
|
356
318
|
)}
|
|
357
319
|
</div>
|
package/components/OrgMap.tsx
CHANGED
|
@@ -2,14 +2,17 @@
|
|
|
2
2
|
import {
|
|
3
3
|
ReactFlow,
|
|
4
4
|
Controls,
|
|
5
|
+
Panel,
|
|
5
6
|
useNodesState,
|
|
6
7
|
useEdgesState,
|
|
7
8
|
type Node,
|
|
8
9
|
type Edge,
|
|
9
10
|
ConnectionLineType,
|
|
10
11
|
} from "@xyflow/react"
|
|
11
|
-
import { useCallback, useEffect } from "react"
|
|
12
|
+
import { useCallback, useEffect, useState } from "react"
|
|
13
|
+
import dagre from "@dagrejs/dagre"
|
|
12
14
|
import type { Agent, CronJob } from "@/lib/types"
|
|
15
|
+
import { buildTeams } from "@/lib/teams"
|
|
13
16
|
import { nodeTypes } from "@/components/AgentNode"
|
|
14
17
|
|
|
15
18
|
interface OrgMapProps {
|
|
@@ -19,79 +22,40 @@ interface OrgMapProps {
|
|
|
19
22
|
onNodeClick: (agent: Agent) => void
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
type MapLayout = "teams" | "hierarchy"
|
|
26
|
+
|
|
27
|
+
const NODE_W = 260
|
|
28
|
+
const NODE_H = 110
|
|
29
|
+
|
|
30
|
+
// ── Shared helpers ─────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function mergeAgentsWithCrons(agents: Agent[], crons: CronJob[]) {
|
|
28
33
|
const withCrons = agents.map((a) => ({
|
|
29
34
|
...a,
|
|
30
35
|
crons: crons.filter((c) => c.agentId === a.id),
|
|
31
36
|
}))
|
|
32
|
-
|
|
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
|
-
}
|
|
37
|
+
return new Map(withCrons.map((a) => [a.id, a]))
|
|
38
|
+
}
|
|
80
39
|
|
|
81
|
-
|
|
40
|
+
function buildEdges(
|
|
41
|
+
agents: Agent[],
|
|
42
|
+
selectedId: string | null,
|
|
43
|
+
): Edge[] {
|
|
44
|
+
const agentMap = new Map(agents.map((a) => [a.id, a]))
|
|
82
45
|
const selectedAgentIds = new Set<string>()
|
|
83
46
|
if (selectedId) {
|
|
84
47
|
selectedAgentIds.add(selectedId)
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
if (
|
|
88
|
-
|
|
48
|
+
const sel = agentMap.get(selectedId)
|
|
49
|
+
if (sel) {
|
|
50
|
+
if (sel.reportsTo) selectedAgentIds.add(sel.reportsTo)
|
|
51
|
+
sel.directReports.forEach((id) => selectedAgentIds.add(id))
|
|
89
52
|
}
|
|
90
53
|
}
|
|
91
54
|
|
|
92
55
|
const edges: Edge[] = []
|
|
93
56
|
for (const agent of agents) {
|
|
94
57
|
for (const childId of agent.directReports) {
|
|
58
|
+
if (!agentMap.has(childId)) continue
|
|
95
59
|
const isHighlighted =
|
|
96
60
|
selectedId && selectedAgentIds.has(agent.id) && selectedAgentIds.has(childId)
|
|
97
61
|
|
|
@@ -101,29 +65,240 @@ function buildLayout(
|
|
|
101
65
|
target: childId,
|
|
102
66
|
type: "smoothstep",
|
|
103
67
|
style: {
|
|
104
|
-
stroke: isHighlighted ? "var(--accent)" : "var(--
|
|
105
|
-
strokeWidth: isHighlighted ? 2 : 1.5,
|
|
106
|
-
opacity: isHighlighted ? 1 : 0.
|
|
107
|
-
strokeDasharray: isHighlighted ? undefined : "6 4",
|
|
68
|
+
stroke: isHighlighted ? "var(--accent)" : "var(--text-quaternary)",
|
|
69
|
+
strokeWidth: isHighlighted ? 2.5 : 1.5,
|
|
70
|
+
opacity: isHighlighted ? 1 : 0.7,
|
|
108
71
|
},
|
|
109
72
|
animated: !!isHighlighted,
|
|
110
73
|
})
|
|
111
74
|
}
|
|
112
75
|
}
|
|
76
|
+
return edges
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Dagre helper ───────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function dagreLayout(
|
|
82
|
+
nodeIds: string[],
|
|
83
|
+
parentChildEdges: [string, string][],
|
|
84
|
+
opts: { rankdir?: string; nodesep?: number; ranksep?: number } = {},
|
|
85
|
+
): Map<string, { x: number; y: number }> {
|
|
86
|
+
const g = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}))
|
|
87
|
+
g.setGraph({
|
|
88
|
+
rankdir: opts.rankdir ?? "TB",
|
|
89
|
+
nodesep: opts.nodesep ?? 60,
|
|
90
|
+
ranksep: opts.ranksep ?? 120,
|
|
91
|
+
marginx: 20,
|
|
92
|
+
marginy: 20,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
for (const id of nodeIds) {
|
|
96
|
+
g.setNode(id, { width: NODE_W, height: NODE_H })
|
|
97
|
+
}
|
|
98
|
+
for (const [src, tgt] of parentChildEdges) {
|
|
99
|
+
g.setEdge(src, tgt)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
dagre.layout(g)
|
|
103
|
+
|
|
104
|
+
const positions = new Map<string, { x: number; y: number }>()
|
|
105
|
+
for (const id of nodeIds) {
|
|
106
|
+
const n = g.node(id)
|
|
107
|
+
// dagre returns center coords — convert to top-left for React Flow
|
|
108
|
+
positions.set(id, { x: n.x - NODE_W / 2, y: n.y - NODE_H / 2 })
|
|
109
|
+
}
|
|
110
|
+
return positions
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Team-column layout (dagre per column) ──────────────────────
|
|
114
|
+
|
|
115
|
+
const COL_GAP = 80
|
|
116
|
+
const GROUP_PAD_X = 30
|
|
117
|
+
const GROUP_PAD_TOP = 36
|
|
118
|
+
const GROUP_PAD_BOTTOM = 24
|
|
119
|
+
|
|
120
|
+
function buildTeamLayout(
|
|
121
|
+
agents: Agent[],
|
|
122
|
+
crons: CronJob[],
|
|
123
|
+
selectedId: string | null,
|
|
124
|
+
): { nodes: Node[]; edges: Edge[] } {
|
|
125
|
+
const agentMapWithCrons = mergeAgentsWithCrons(agents, crons)
|
|
126
|
+
const { root, teams, soloOps } = buildTeams(agents)
|
|
127
|
+
if (!root) return { nodes: [], edges: [] }
|
|
128
|
+
|
|
129
|
+
// Collect all placed IDs
|
|
130
|
+
const placedIds = new Set<string>([root.id])
|
|
131
|
+
for (const t of teams) {
|
|
132
|
+
placedIds.add(t.manager.id)
|
|
133
|
+
t.members.forEach((m) => placedIds.add(m.id))
|
|
134
|
+
}
|
|
135
|
+
soloOps.forEach((a) => placedIds.add(a.id))
|
|
136
|
+
const disconnected = agents.filter((a) => !placedIds.has(a.id))
|
|
137
|
+
|
|
138
|
+
// Build columns
|
|
139
|
+
type Column = { label: string; color?: string; agentIds: string[]; edges: [string, string][] }
|
|
140
|
+
const columns: Column[] = []
|
|
141
|
+
|
|
142
|
+
const agentMap = new Map(agents.map((a) => [a.id, a]))
|
|
143
|
+
for (const t of teams) {
|
|
144
|
+
const ids = [t.manager.id, ...t.members.map((m) => m.id)]
|
|
145
|
+
const colEdges: [string, string][] = []
|
|
146
|
+
for (const id of ids) {
|
|
147
|
+
const a = agentMap.get(id)
|
|
148
|
+
if (!a) continue
|
|
149
|
+
for (const cid of a.directReports) {
|
|
150
|
+
if (ids.includes(cid)) colEdges.push([id, cid])
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
columns.push({ label: `Team ${t.manager.name}`, color: t.manager.color, agentIds: ids, edges: colEdges })
|
|
154
|
+
}
|
|
155
|
+
if (soloOps.length > 0) {
|
|
156
|
+
columns.push({ label: "Solo Ops", agentIds: soloOps.map((a) => a.id), edges: [] })
|
|
157
|
+
}
|
|
158
|
+
if (disconnected.length > 0) {
|
|
159
|
+
columns.push({ label: "Unlinked", agentIds: disconnected.map((a) => a.id), edges: [] })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Layout each column with dagre independently, then offset horizontally
|
|
163
|
+
const nodes: Node[] = []
|
|
164
|
+
let cursorX = 0
|
|
165
|
+
const ROOT_Y = 0
|
|
166
|
+
const COLUMNS_TOP = 200
|
|
167
|
+
|
|
168
|
+
// Place root centered (we'll adjust x after computing total width)
|
|
169
|
+
const rootAgent = agentMapWithCrons.get(root.id)
|
|
170
|
+
|
|
171
|
+
type ColumnResult = { groupNode: Node; childNodes: Node[]; width: number }
|
|
172
|
+
const columnResults: ColumnResult[] = []
|
|
173
|
+
|
|
174
|
+
for (let ci = 0; ci < columns.length; ci++) {
|
|
175
|
+
const col = columns[ci]
|
|
176
|
+
const positions = dagreLayout(col.agentIds, col.edges, { nodesep: 40, ranksep: 90 })
|
|
177
|
+
|
|
178
|
+
// Compute bounding box of dagre output
|
|
179
|
+
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
|
|
180
|
+
for (const pos of positions.values()) {
|
|
181
|
+
minX = Math.min(minX, pos.x)
|
|
182
|
+
maxX = Math.max(maxX, pos.x + NODE_W)
|
|
183
|
+
minY = Math.min(minY, pos.y)
|
|
184
|
+
maxY = Math.max(maxY, pos.y + NODE_H)
|
|
185
|
+
}
|
|
186
|
+
const contentW = maxX - minX
|
|
187
|
+
const contentH = maxY - minY
|
|
188
|
+
const groupW = contentW + GROUP_PAD_X * 2
|
|
189
|
+
const groupH = GROUP_PAD_TOP + contentH + GROUP_PAD_BOTTOM
|
|
190
|
+
|
|
191
|
+
const groupId = `group-${ci}`
|
|
192
|
+
const groupNode: Node = {
|
|
193
|
+
id: groupId,
|
|
194
|
+
type: "teamGroup",
|
|
195
|
+
data: { label: col.label, color: col.color },
|
|
196
|
+
position: { x: cursorX, y: COLUMNS_TOP },
|
|
197
|
+
style: {
|
|
198
|
+
width: groupW,
|
|
199
|
+
height: groupH,
|
|
200
|
+
background: "var(--fill-quaternary)",
|
|
201
|
+
borderRadius: 12,
|
|
202
|
+
border: "1px solid var(--separator)",
|
|
203
|
+
padding: 0,
|
|
204
|
+
},
|
|
205
|
+
selectable: false,
|
|
206
|
+
draggable: false,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const childNodes: Node[] = []
|
|
210
|
+
for (const id of col.agentIds) {
|
|
211
|
+
const agent = agentMapWithCrons.get(id)
|
|
212
|
+
const pos = positions.get(id)
|
|
213
|
+
if (!agent || !pos) continue
|
|
214
|
+
childNodes.push({
|
|
215
|
+
id,
|
|
216
|
+
type: "agentNode",
|
|
217
|
+
data: agent as unknown as Record<string, unknown>,
|
|
218
|
+
position: { x: pos.x - minX + GROUP_PAD_X, y: pos.y - minY + GROUP_PAD_TOP },
|
|
219
|
+
parentId: groupId,
|
|
220
|
+
extent: "parent" as const,
|
|
221
|
+
selected: id === selectedId,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
columnResults.push({ groupNode, childNodes, width: groupW })
|
|
226
|
+
cursorX += groupW + COL_GAP
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const totalWidth = cursorX - COL_GAP
|
|
230
|
+
|
|
231
|
+
// Root node
|
|
232
|
+
if (rootAgent) {
|
|
233
|
+
nodes.push({
|
|
234
|
+
id: root.id,
|
|
235
|
+
type: "agentNode",
|
|
236
|
+
data: rootAgent as unknown as Record<string, unknown>,
|
|
237
|
+
position: { x: totalWidth / 2 - NODE_W / 2, y: ROOT_Y },
|
|
238
|
+
selected: root.id === selectedId,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const cr of columnResults) {
|
|
243
|
+
nodes.push(cr.groupNode)
|
|
244
|
+
nodes.push(...cr.childNodes)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { nodes, edges: buildEdges(agents, selectedId) }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Hierarchy layout (full dagre) ──────────────────────────────
|
|
251
|
+
|
|
252
|
+
function buildHierarchyLayout(
|
|
253
|
+
agents: Agent[],
|
|
254
|
+
crons: CronJob[],
|
|
255
|
+
selectedId: string | null,
|
|
256
|
+
): { nodes: Node[]; edges: Edge[] } {
|
|
257
|
+
const agentMapWithCrons = mergeAgentsWithCrons(agents, crons)
|
|
258
|
+
const agentMap = new Map(agents.map((a) => [a.id, a]))
|
|
259
|
+
|
|
260
|
+
const allIds = agents.map((a) => a.id)
|
|
261
|
+
const allEdges: [string, string][] = []
|
|
262
|
+
for (const a of agents) {
|
|
263
|
+
for (const cid of a.directReports) {
|
|
264
|
+
if (agentMap.has(cid)) allEdges.push([a.id, cid])
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const positions = dagreLayout(allIds, allEdges, { nodesep: 60, ranksep: 140 })
|
|
113
269
|
|
|
114
|
-
|
|
270
|
+
const nodes: Node[] = []
|
|
271
|
+
for (const a of agents) {
|
|
272
|
+
const agent = agentMapWithCrons.get(a.id)
|
|
273
|
+
const pos = positions.get(a.id)
|
|
274
|
+
if (!agent || !pos) continue
|
|
275
|
+
nodes.push({
|
|
276
|
+
id: a.id,
|
|
277
|
+
type: "agentNode",
|
|
278
|
+
data: agent as unknown as Record<string, unknown>,
|
|
279
|
+
position: pos,
|
|
280
|
+
selected: a.id === selectedId,
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { nodes, edges: buildEdges(agents, selectedId) }
|
|
115
285
|
}
|
|
116
286
|
|
|
287
|
+
// ── Component ──────────────────────────────────────────────────
|
|
288
|
+
|
|
117
289
|
export function OrgMap({ agents, crons, selectedId, onNodeClick }: OrgMapProps) {
|
|
118
|
-
const
|
|
290
|
+
const [layout, setLayout] = useState<MapLayout>("hierarchy")
|
|
291
|
+
|
|
292
|
+
const build = layout === "teams" ? buildTeamLayout : buildHierarchyLayout
|
|
293
|
+
const { nodes: initialNodes, edges: initialEdges } = build(agents, crons, selectedId)
|
|
119
294
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
|
120
295
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
|
121
296
|
|
|
122
297
|
useEffect(() => {
|
|
123
|
-
const { nodes: n, edges: e } =
|
|
298
|
+
const { nodes: n, edges: e } = build(agents, crons, selectedId)
|
|
124
299
|
setNodes(n)
|
|
125
300
|
setEdges(e)
|
|
126
|
-
}, [agents, crons, selectedId, setNodes, setEdges])
|
|
301
|
+
}, [agents, crons, selectedId, layout, setNodes, setEdges, build])
|
|
127
302
|
|
|
128
303
|
const handleNodeClick = useCallback(
|
|
129
304
|
(_: React.MouseEvent, node: Node) => {
|
|
@@ -152,6 +327,54 @@ export function OrgMap({ agents, crons, selectedId, onNodeClick }: OrgMapProps)
|
|
|
152
327
|
position="bottom-left"
|
|
153
328
|
style={{ left: 16, bottom: 16 }}
|
|
154
329
|
/>
|
|
330
|
+
|
|
331
|
+
{/* Layout toggle */}
|
|
332
|
+
<Panel
|
|
333
|
+
position="bottom-center"
|
|
334
|
+
style={{
|
|
335
|
+
display: "flex",
|
|
336
|
+
alignItems: "center",
|
|
337
|
+
gap: 2,
|
|
338
|
+
padding: 3,
|
|
339
|
+
borderRadius: "var(--radius-sm)",
|
|
340
|
+
background: "var(--material-regular)",
|
|
341
|
+
backdropFilter: "blur(20px)",
|
|
342
|
+
WebkitBackdropFilter: "blur(20px)",
|
|
343
|
+
border: "1px solid var(--separator)",
|
|
344
|
+
}}
|
|
345
|
+
>
|
|
346
|
+
{(["teams", "hierarchy"] as const).map((opt) => {
|
|
347
|
+
const isActive = layout === opt
|
|
348
|
+
return (
|
|
349
|
+
<button
|
|
350
|
+
key={opt}
|
|
351
|
+
onClick={() => setLayout(opt)}
|
|
352
|
+
className="focus-ring"
|
|
353
|
+
style={{
|
|
354
|
+
padding: "4px 12px",
|
|
355
|
+
borderRadius: "var(--radius-sm)",
|
|
356
|
+
fontSize: "var(--text-caption1)",
|
|
357
|
+
fontWeight: "var(--weight-medium)",
|
|
358
|
+
border: "none",
|
|
359
|
+
cursor: "pointer",
|
|
360
|
+
transition: "all 200ms var(--ease-smooth)",
|
|
361
|
+
...(isActive
|
|
362
|
+
? {
|
|
363
|
+
background: "var(--accent-fill)",
|
|
364
|
+
color: "var(--accent)",
|
|
365
|
+
boxShadow: "0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent)",
|
|
366
|
+
}
|
|
367
|
+
: {
|
|
368
|
+
background: "transparent",
|
|
369
|
+
color: "var(--text-secondary)",
|
|
370
|
+
}),
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
{opt === "teams" ? "Teams" : "Hierarchy"}
|
|
374
|
+
</button>
|
|
375
|
+
)
|
|
376
|
+
})}
|
|
377
|
+
</Panel>
|
|
155
378
|
</ReactFlow>
|
|
156
379
|
)
|
|
157
380
|
}
|
package/lib/agents-registry.ts
CHANGED
|
@@ -207,6 +207,9 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
|
|
|
207
207
|
|
|
208
208
|
// --- Top-level agents ---
|
|
209
209
|
for (const dirName of allAgentDirs) {
|
|
210
|
+
// Skip if directory name matches the root agent ID (already added above)
|
|
211
|
+
if (hasRoot && dirName === rootId) continue
|
|
212
|
+
|
|
210
213
|
const soulFile = join(agentsDir, dirName, 'SOUL.md')
|
|
211
214
|
const hasSoul = existsSync(soulFile)
|
|
212
215
|
const subAgentsDir = join(agentsDir, dirName, 'sub-agents')
|
package/lib/agents.test.ts
CHANGED
|
@@ -593,6 +593,47 @@ describe('auto-discovery from workspace', () => {
|
|
|
593
593
|
expect(agents[0].reportsTo).toBeNull()
|
|
594
594
|
})
|
|
595
595
|
|
|
596
|
+
it('does not duplicate root agent when agents/ dir name matches root ID', async () => {
|
|
597
|
+
vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
|
|
598
|
+
|
|
599
|
+
// IDENTITY.md says "Jarvis" → rootId = 'jarvis'
|
|
600
|
+
// agents/jarvis/ directory also exists → should NOT create a second 'jarvis' entry
|
|
601
|
+
mockExistsSync.mockImplementation((p: string) => {
|
|
602
|
+
if (p === '/tmp/ws/clawport/agents.json') return false
|
|
603
|
+
if (p === '/tmp/ws/SOUL.md') return true
|
|
604
|
+
if (p === '/tmp/ws/IDENTITY.md') return true
|
|
605
|
+
if (p === '/tmp/ws/agents') return true
|
|
606
|
+
if (p === '/tmp/ws/agents/jarvis/SOUL.md') return true
|
|
607
|
+
if (p === '/tmp/ws/agents/jarvis/sub-agents') return false
|
|
608
|
+
if (p === '/tmp/ws/agents/jarvis/members') return false
|
|
609
|
+
if (p === '/tmp/ws/agents/vera/SOUL.md') return true
|
|
610
|
+
if (p === '/tmp/ws/agents/vera/sub-agents') return false
|
|
611
|
+
if (p === '/tmp/ws/agents/vera/members') return false
|
|
612
|
+
return false
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
mockReaddirSync.mockReturnValue([
|
|
616
|
+
{ name: 'jarvis', isDirectory: () => true },
|
|
617
|
+
{ name: 'vera', isDirectory: () => true },
|
|
618
|
+
])
|
|
619
|
+
|
|
620
|
+
mockReadFileSync.mockImplementation((p: string) => {
|
|
621
|
+
if (p === '/tmp/ws/IDENTITY.md') return '- **Name:** Jarvis\n- **Emoji:** 🤖'
|
|
622
|
+
if (p === '/tmp/ws/SOUL.md') return '# SOUL.md - Who You Are\nYou are Jarvis.'
|
|
623
|
+
if (p === '/tmp/ws/agents/jarvis/SOUL.md') return '# SOUL.md — Jarvis\nOrchestrator details.'
|
|
624
|
+
if (p === '/tmp/ws/agents/vera/SOUL.md') return '# SOUL.md — VERA\nStrategy.'
|
|
625
|
+
throw new Error('ENOENT')
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
const agents = await getAgents()
|
|
629
|
+
// Should have exactly 2 agents: jarvis (root) + vera, NOT 3 (no duplicate jarvis)
|
|
630
|
+
const jarvisEntries = agents.filter(a => a.id === 'jarvis')
|
|
631
|
+
expect(jarvisEntries).toHaveLength(1)
|
|
632
|
+
expect(jarvisEntries[0].reportsTo).toBeNull() // it's the root
|
|
633
|
+
expect(jarvisEntries[0].title).toBe('Orchestrator') // root title, not agent scan
|
|
634
|
+
expect(agents.find(a => a.id === 'vera')).toBeDefined()
|
|
635
|
+
})
|
|
636
|
+
|
|
596
637
|
it('falls back to bundled when no agents/ dir and no root SOUL.md', async () => {
|
|
597
638
|
vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
|
|
598
639
|
mockExistsSync.mockReturnValue(false)
|
package/lib/teams.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Agent } from "@/lib/types"
|
|
2
|
+
|
|
3
|
+
export interface Team {
|
|
4
|
+
manager: Agent
|
|
5
|
+
members: Agent[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function buildTeams(agents: Agent[]): { root: Agent | null; teams: Team[]; soloOps: Agent[] } {
|
|
9
|
+
const root = agents.find((a) => a.reportsTo === null) ?? null
|
|
10
|
+
if (!root) return { root: null, teams: [], soloOps: [] }
|
|
11
|
+
|
|
12
|
+
const byId = new Map(agents.map((a) => [a.id, a]))
|
|
13
|
+
const teamManagers: Agent[] = []
|
|
14
|
+
const soloOps: Agent[] = []
|
|
15
|
+
|
|
16
|
+
for (const rid of root.directReports) {
|
|
17
|
+
const r = byId.get(rid)
|
|
18
|
+
if (!r) continue
|
|
19
|
+
if (r.directReports.length > 0) {
|
|
20
|
+
teamManagers.push(r)
|
|
21
|
+
} else {
|
|
22
|
+
soloOps.push(r)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const teams: Team[] = teamManagers.map((mgr) => {
|
|
27
|
+
const members: Agent[] = []
|
|
28
|
+
const visited = new Set<string>([mgr.id])
|
|
29
|
+
const queue = [...mgr.directReports]
|
|
30
|
+
while (queue.length > 0) {
|
|
31
|
+
const id = queue.shift()!
|
|
32
|
+
if (visited.has(id)) continue
|
|
33
|
+
visited.add(id)
|
|
34
|
+
const a = byId.get(id)
|
|
35
|
+
if (a) {
|
|
36
|
+
members.push(a)
|
|
37
|
+
queue.push(...a.directReports)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { manager: mgr, members }
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return { root, teams, soloOps }
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawport-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Open-source dashboard for managing, monitoring, and chatting with your OpenClaw AI agents.",
|
|
5
5
|
"homepage": "https://clawport.dev",
|
|
6
6
|
"repository": {
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"prepublishOnly": "npx tsc --noEmit && vitest run"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
+
"@dagrejs/dagre": "^2.0.4",
|
|
50
51
|
"@tailwindcss/postcss": "^4",
|
|
51
52
|
"@types/node": "^20",
|
|
52
53
|
"@types/react": "^19",
|