clawport-ui 0.4.7 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- minWidth: 200,
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
- style={{
47
- fontSize: "var(--text-body)",
48
- fontWeight: "var(--weight-semibold)",
49
- color: "var(--text-primary)",
50
- whiteSpace: "nowrap",
51
- overflow: "hidden",
52
- textOverflow: "ellipsis",
53
- }}
54
- >
55
- {agent.name}
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
- {/* Title */}
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
- whiteSpace: "nowrap",
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
- {/* Cron health row */}
90
- {hasCrons && (
91
- <div
92
- style={{
93
- display: "flex",
94
- alignItems: "center",
95
- gap: 4,
96
- marginTop: "var(--space-2)",
97
- fontSize: "var(--text-caption2)",
98
- color: hasErrors ? "var(--system-red)" : "var(--system-green)",
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
- width: 6,
105
- height: 6,
106
- borderRadius: "50%",
107
- background: hasErrors ? "var(--system-red)" : "var(--system-green)",
108
- flexShrink: 0,
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
- {cronCount} cron{cronCount !== 1 ? "s" : ""} · {hasErrors ? "errors" : "healthy"}
112
- </div>
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
- export const nodeTypes = { agentNode: AgentNode }
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 }
@@ -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 { jarvis, teams, soloOps } = buildTeams(agents)
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
- {jarvis && (
251
+ {root && (
290
252
  <button
291
253
  className="hover-lift focus-ring"
292
- onClick={() => onSelect(jarvis)}
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%, ${jarvis.color}08 100%)`,
301
- border: selectedId === jarvis.id
302
- ? `1.5px solid ${jarvis.color}`
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 === jarvis.id
309
- ? `0 0 0 1px ${jarvis.color}40, 0 8px 32px ${jarvis.color}12`
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={jarvis}
278
+ agent={root}
317
279
  size={64}
318
280
  borderRadius={18}
319
281
  style={{
320
- border: `1.5px solid ${jarvis.color}50`,
321
- boxShadow: `0 4px 20px ${jarvis.color}20`,
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
- {jarvis.name}
296
+ {root.name}
335
297
  </div>
336
298
  <div
337
299
  style={{
338
300
  fontSize: "var(--text-subheadline)",
339
- color: jarvis.color,
301
+ color: root.color,
340
302
  opacity: 0.85,
341
303
  marginTop: 2,
342
304
  }}
343
305
  >
344
- {jarvis.title}
306
+ {root.title}
345
307
  </div>
346
- {jarvis.description && (
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
- {jarvis.description}
316
+ {root.description}
355
317
  </div>
356
318
  )}
357
319
  </div>
@@ -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
- 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]))
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
- 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
- }
37
+ return new Map(withCrons.map((a) => [a.id, a]))
38
+ }
80
39
 
81
- // Build edges -- selected agent's edges get accent color
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 selectedAgent = agentMap.get(selectedId)
86
- if (selectedAgent) {
87
- if (selectedAgent.reportsTo) selectedAgentIds.add(selectedAgent.reportsTo)
88
- selectedAgent.directReports.forEach((id) => selectedAgentIds.add(id))
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(--separator)",
105
- strokeWidth: isHighlighted ? 2 : 1.5,
106
- opacity: isHighlighted ? 1 : 0.6,
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
- return { nodes, edges }
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 { nodes: initialNodes, edges: initialEdges } = buildLayout(agents, crons, selectedId)
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 } = buildLayout(agents, crons, selectedId)
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
  }
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, existsSync, readdirSync } from 'fs'
2
+ import { execSync } from 'child_process'
2
3
  import { join, basename } from 'path'
3
4
  import bundledRegistry from '@/lib/agents.json'
4
5
  import type { Agent } from '@/lib/types'
@@ -295,16 +296,100 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
295
296
  return discovered.length > 0 ? discovered : null
296
297
  }
297
298
 
299
+ // ---------------------------------------------------------------------------
300
+ // CLI-based discovery (openclaw agents list)
301
+ // ---------------------------------------------------------------------------
302
+
303
+ /**
304
+ * Discover agents via `openclaw agents list --json`.
305
+ * Returns AgentEntry[] for any agents found, or null on failure.
306
+ * This catches agents defined in openclaw.json that don't have
307
+ * filesystem directories under agents/.
308
+ */
309
+ function discoverAgentsViaCli(openclawBin: string): AgentEntry[] | null {
310
+ try {
311
+ const raw = execSync(`${openclawBin} agents list --json`, {
312
+ encoding: 'utf-8',
313
+ timeout: 10000,
314
+ })
315
+ const parsed = JSON.parse(raw)
316
+ const agents: unknown[] = Array.isArray(parsed)
317
+ ? parsed
318
+ : parsed.agents ?? parsed.data ?? []
319
+
320
+ if (agents.length === 0) return null
321
+
322
+ let colorIndex = 0
323
+ return agents.map((entry) => {
324
+ const a = entry as Record<string, unknown>
325
+ const id = String(a.id ?? a.name ?? a.slug ?? '')
326
+ const name = String(a.name ?? a.displayName ?? slugToName(id))
327
+ const title = String(a.title ?? a.role ?? 'Agent')
328
+ const reportsTo = a.reportsTo != null ? String(a.reportsTo) : null
329
+ const directReports = Array.isArray(a.directReports)
330
+ ? a.directReports.map(String)
331
+ : []
332
+ const tools = Array.isArray(a.tools) ? a.tools.map(String) : ['read', 'write']
333
+
334
+ return {
335
+ id,
336
+ name,
337
+ title,
338
+ reportsTo,
339
+ directReports,
340
+ soulPath: typeof a.soulPath === 'string' ? a.soulPath : null,
341
+ voiceId: typeof a.voiceId === 'string' ? a.voiceId : null,
342
+ color: typeof a.color === 'string' ? a.color : DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
343
+ emoji: typeof a.emoji === 'string' ? a.emoji : name.charAt(0).toUpperCase(),
344
+ tools,
345
+ memoryPath: typeof a.memoryPath === 'string' ? a.memoryPath : null,
346
+ description: typeof a.description === 'string' ? a.description : `${name} agent.`,
347
+ }
348
+ })
349
+ } catch {
350
+ return null
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Merge CLI-discovered agents into an existing registry.
356
+ * Only adds agents whose IDs are not already present.
357
+ * Also patches directReports on existing agents to include new CLI agents.
358
+ */
359
+ function mergeCliAgents(existing: AgentEntry[], cliAgents: AgentEntry[]): AgentEntry[] {
360
+ const existingIds = new Set(existing.map(a => a.id))
361
+ const added: AgentEntry[] = []
362
+
363
+ for (const ca of cliAgents) {
364
+ if (existingIds.has(ca.id)) continue
365
+ added.push(ca)
366
+ existingIds.add(ca.id)
367
+
368
+ // If the CLI agent reports to an existing agent, add it to that agent's directReports
369
+ if (ca.reportsTo) {
370
+ const parent = existing.find(a => a.id === ca.reportsTo)
371
+ if (parent && !parent.directReports.includes(ca.id)) {
372
+ parent.directReports.push(ca.id)
373
+ }
374
+ }
375
+ }
376
+
377
+ return [...existing, ...added]
378
+ }
379
+
298
380
  /**
299
381
  * Load the agent registry.
300
382
  *
301
383
  * Resolution order:
302
384
  * 1. $WORKSPACE_PATH/clawport/agents.json (user's own config)
303
385
  * 2. Auto-discovered from $WORKSPACE_PATH (agents/ directory scan)
304
- * 3. Bundled lib/agents.json (default example registry)
386
+ * + merged with `openclaw agents list --json` (catches config-only agents)
387
+ * 3. CLI-only discovery via `openclaw agents list --json`
388
+ * 4. Bundled lib/agents.json (default example registry)
305
389
  */
306
390
  export function loadRegistry(): AgentEntry[] {
307
391
  const workspacePath = process.env.WORKSPACE_PATH
392
+ const openclawBin = process.env.OPENCLAW_BIN
308
393
 
309
394
  if (workspacePath) {
310
395
  // 1. User-provided override
@@ -318,11 +403,24 @@ export function loadRegistry(): AgentEntry[] {
318
403
  }
319
404
  }
320
405
 
321
- // 2. Auto-discover from workspace
406
+ // 2. Auto-discover from workspace filesystem
322
407
  const discovered = discoverAgents(workspacePath)
408
+
409
+ // 2b. Merge in any agents known to OpenClaw CLI but missing from filesystem
410
+ if (discovered && openclawBin) {
411
+ const cliAgents = discoverAgentsViaCli(openclawBin)
412
+ if (cliAgents) return mergeCliAgents(discovered, cliAgents)
413
+ return discovered
414
+ }
323
415
  if (discovered) return discovered
416
+
417
+ // 3. CLI-only discovery (no workspace agents/ dir, but CLI knows about agents)
418
+ if (openclawBin) {
419
+ const cliAgents = discoverAgentsViaCli(openclawBin)
420
+ if (cliAgents) return cliAgents
421
+ }
324
422
  }
325
423
 
326
- // 3. Bundled fallback
424
+ // 4. Bundled fallback
327
425
  return bundledRegistry as AgentEntry[]
328
426
  }
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.4.7",
3
+ "version": "0.5.1",
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",