feat(FN-984): add agent hierarchy tree view with parent-child relationships
- Add GET /agents/:id/children API endpoint to fetch child agents of a parent - Create fetchAgentChildren API function with tests in dashboard client - Implement useAgentHierarchy hook with recursive tree loading and expand/collapse - Add tree view mode to AgentsView with hierarchical agent display - Enhance AgentDetailView with parent info and expandable children section - Add tree view CSS styles for connection lines and expand/collapse indicators
This commit is contained in:
@@ -970,6 +970,62 @@ describe("startAgentRun", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fetchAgentChildren", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches children for an agent", async () => {
|
||||||
|
const mockChildren = [
|
||||||
|
{ id: "child-1", name: "Child Agent 1", state: "active", reportsTo: "agent-001" },
|
||||||
|
{ id: "child-2", name: "Child Agent 2", state: "idle", reportsTo: "agent-001" },
|
||||||
|
];
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockChildren));
|
||||||
|
|
||||||
|
const { fetchAgentChildren } = await import("./api");
|
||||||
|
const result = await fetchAgentChildren("agent-001");
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toBe("child-1");
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/children", {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes projectId as query param", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||||
|
|
||||||
|
const { fetchAgentChildren } = await import("./api");
|
||||||
|
await fetchAgentChildren("agent-001", "proj_123");
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/children?projectId=proj_123", {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for 404 (agent not found)", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(
|
||||||
|
mockFetchResponse(false, { error: "Agent not found" }, 404),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { fetchAgentChildren } = await import("./api");
|
||||||
|
const result = await fetchAgentChildren("agent-999");
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-404 errors", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(
|
||||||
|
mockFetchResponse(false, { error: "Internal server error" }, 500),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { fetchAgentChildren } = await import("./api");
|
||||||
|
await expect(fetchAgentChildren("agent-001")).rejects.toThrow("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Git Management API", () => {
|
describe("Git Management API", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
|||||||
@@ -1834,6 +1834,15 @@ export function fetchAgentStats(projectId?: string): Promise<AgentStats> {
|
|||||||
return api<AgentStats>(withProjectId("/agents/stats", projectId));
|
return api<AgentStats>(withProjectId("/agents/stats", projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch child agents that report to a given parent agent */
|
||||||
|
export function fetchAgentChildren(agentId: string, projectId?: string): Promise<Agent[]> {
|
||||||
|
return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/children`, projectId)).catch((err: Error) => {
|
||||||
|
// Return empty array for 404 (agent may have been deleted)
|
||||||
|
if (err.message.includes("not found")) return [];
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Agent Generation API ────────────────────────────────────────────────────
|
// ── Agent Generation API ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Generated agent specification returned by the AI */
|
/** Generated agent specification returned by the AI */
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|||||||
import {
|
import {
|
||||||
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
|
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
|
||||||
Settings, FileText, ActivitySquare, X, Copy,
|
Settings, FileText, ActivitySquare, X, Copy,
|
||||||
ExternalLink, CheckCircle, XCircle, Loader2
|
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
|
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
|
||||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs } from "../api";
|
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentChildren } from "../api";
|
||||||
|
import type { Agent } from "../api";
|
||||||
import type { AgentLogEntry } from "@fusion/core";
|
import type { AgentLogEntry } from "@fusion/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,14 +45,16 @@ interface AgentDetailViewProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
addToast: (message: string, type?: "success" | "error") => void;
|
addToast: (message: string, type?: "success" | "error") => void;
|
||||||
|
onChildClick?: (childId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = "dashboard" | "logs" | "config" | "runs";
|
type TabId = "dashboard" | "logs" | "config" | "runs" | "children";
|
||||||
|
|
||||||
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
|
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
|
||||||
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare },
|
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare },
|
||||||
{ id: "logs", label: "Logs", icon: FileText },
|
{ id: "logs", label: "Logs", icon: FileText },
|
||||||
{ id: "runs", label: "Runs", icon: Activity },
|
{ id: "runs", label: "Runs", icon: Activity },
|
||||||
|
{ id: "children", label: "Children", icon: GitBranch },
|
||||||
{ id: "config", label: "Settings", icon: Settings },
|
{ id: "config", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@ const RUN_STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string
|
|||||||
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" },
|
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AgentDetailView({ agentId, projectId, onClose, addToast }: AgentDetailViewProps) {
|
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick }: AgentDetailViewProps) {
|
||||||
const [agent, setAgent] = useState<AgentDetail | null>(null);
|
const [agent, setAgent] = useState<AgentDetail | null>(null);
|
||||||
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
|
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -385,6 +388,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
|
|||||||
onSaved={loadAgent}
|
onSaved={loadAgent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "children" && (
|
||||||
|
<ChildrenTab
|
||||||
|
agentId={agent.id}
|
||||||
|
projectId={projectId}
|
||||||
|
onChildClick={onChildClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer with agent ID */}
|
{/* Footer with agent ID */}
|
||||||
@@ -1038,3 +1049,87 @@ function ConfigTab({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Children Tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ChildrenTab({
|
||||||
|
agentId,
|
||||||
|
projectId,
|
||||||
|
onChildClick,
|
||||||
|
}: {
|
||||||
|
agentId: string;
|
||||||
|
projectId?: string;
|
||||||
|
onChildClick?: (childId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [children, setChildren] = useState<Agent[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
fetchAgentChildren(agentId, projectId)
|
||||||
|
.then(setChildren)
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [agentId, projectId]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="detail-section">
|
||||||
|
<div className="detail-section-header">
|
||||||
|
<h3>Child Agents</h3>
|
||||||
|
</div>
|
||||||
|
<div className="detail-section-body" style={{ display: "flex", alignItems: "center", gap: 8, padding: 16 }}>
|
||||||
|
<Loader2 size={16} className="spin" />
|
||||||
|
<span className="text-secondary">Loading children...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-section">
|
||||||
|
<div className="detail-section-header">
|
||||||
|
<h3>Child Agents</h3>
|
||||||
|
<span className="text-secondary">({children.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-section-body">
|
||||||
|
{children.length === 0 ? (
|
||||||
|
<div className="agent-empty" style={{ padding: 24 }}>
|
||||||
|
<GitBranch size={32} opacity={0.3} />
|
||||||
|
<p>No child agents</p>
|
||||||
|
<p className="text-secondary">This agent has no spawned children</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="agent-tree__children">
|
||||||
|
{children.map((child) => {
|
||||||
|
const stateStyle = STATE_COLORS[child.state as AgentState];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={child.id}
|
||||||
|
className={`agent-tree__node agent-is-child`}
|
||||||
|
onClick={() => onChildClick?.(child.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onChildClick?.(child.id)}
|
||||||
|
style={{ cursor: onChildClick ? "pointer" : "default" }}
|
||||||
|
>
|
||||||
|
<span className="agent-tree__icon">{child.icon ?? "🤖"}</span>
|
||||||
|
<span className="agent-tree__name">{child.name}</span>
|
||||||
|
<span
|
||||||
|
className="agent-tree__badge"
|
||||||
|
style={{
|
||||||
|
background: stateStyle?.bg ?? "var(--state-idle-bg)",
|
||||||
|
color: stateStyle?.text ?? "var(--state-idle-text)",
|
||||||
|
border: `1px solid ${stateStyle?.border ?? "var(--state-idle-border)"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{child.state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight, Filter } from "lucide-react";
|
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight, ChevronDown, GitBranch, Filter } from "lucide-react";
|
||||||
import type { Agent, AgentCapability, AgentState } from "../api";
|
import type { Agent, AgentCapability, AgentState } from "../api";
|
||||||
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun } from "../api";
|
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun } from "../api";
|
||||||
import { AgentDetailView } from "./AgentDetailView";
|
import { AgentDetailView } from "./AgentDetailView";
|
||||||
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
|
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
|
||||||
import { AgentMetricsBar } from "./AgentMetricsBar";
|
import { AgentMetricsBar } from "./AgentMetricsBar";
|
||||||
import { useAgents } from "../hooks/useAgents";
|
import { useAgents } from "../hooks/useAgents";
|
||||||
|
import { useAgentHierarchy } from "../hooks/useAgentHierarchy";
|
||||||
|
import type { AgentNode } from "../hooks/useAgentHierarchy";
|
||||||
import { NewAgentDialog } from "./NewAgentDialog";
|
import { NewAgentDialog } from "./NewAgentDialog";
|
||||||
|
|
||||||
export interface AgentsViewProps {
|
export interface AgentsViewProps {
|
||||||
@@ -33,6 +35,94 @@ const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: strin
|
|||||||
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Recursive tree node component for agent hierarchy */
|
||||||
|
function AgentTreeNode({
|
||||||
|
node,
|
||||||
|
onSelect,
|
||||||
|
onToggle,
|
||||||
|
isExpanded,
|
||||||
|
getChildCount,
|
||||||
|
getHealthStatus,
|
||||||
|
getRoleIcon,
|
||||||
|
}: {
|
||||||
|
node: AgentNode;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
isExpanded: (id: string) => boolean;
|
||||||
|
getChildCount: (id: string) => number;
|
||||||
|
getHealthStatus: (agent: Agent) => { label: string; icon: JSX.Element; color: string };
|
||||||
|
getRoleIcon: (role: AgentCapability) => string;
|
||||||
|
}) {
|
||||||
|
const { agent, children, depth } = node;
|
||||||
|
const childCount = getChildCount(agent.id);
|
||||||
|
const expanded = isExpanded(agent.id);
|
||||||
|
const health = getHealthStatus(agent);
|
||||||
|
const stateStyle = STATE_COLORS[agent.state];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`agent-tree__node${agent.reportsTo ? " agent-is-child" : ""} agent-tree__indent--${Math.min(depth, 4)}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`agent-tree__toggle${childCount === 0 ? " agent-tree__toggle--leaf" : ""}`}
|
||||||
|
onClick={() => childCount > 0 && onToggle(agent.id)}
|
||||||
|
title={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No children"}
|
||||||
|
aria-label={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No children"}
|
||||||
|
>
|
||||||
|
{childCount > 0 ? (
|
||||||
|
expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />
|
||||||
|
) : (
|
||||||
|
<Bot size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="agent-tree__content"
|
||||||
|
onClick={() => onSelect(agent.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onSelect(agent.id)}
|
||||||
|
>
|
||||||
|
<span className="agent-tree__icon">{getRoleIcon(agent.role)}</span>
|
||||||
|
<span className="agent-tree__name">{agent.name}</span>
|
||||||
|
<span
|
||||||
|
className="agent-tree__badge"
|
||||||
|
style={{
|
||||||
|
background: stateStyle.bg,
|
||||||
|
color: stateStyle.text,
|
||||||
|
border: `1px solid ${stateStyle.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.state}
|
||||||
|
</span>
|
||||||
|
<span className="agent-tree__health" style={{ color: health.color }} title={health.label}>
|
||||||
|
{health.icon}
|
||||||
|
</span>
|
||||||
|
{childCount > 0 && (
|
||||||
|
<span className="agent-tree__count text-secondary">({childCount})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expanded && children.length > 0 && (
|
||||||
|
<div className="agent-tree__children">
|
||||||
|
{children.map((child) => (
|
||||||
|
<AgentTreeNode
|
||||||
|
key={child.agent.id}
|
||||||
|
node={child}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onToggle={onToggle}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
getChildCount={getChildCount}
|
||||||
|
getHealthStatus={getHealthStatus}
|
||||||
|
getRoleIcon={getRoleIcon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||||
const { activeAgents, stats } = useAgents(projectId);
|
const { activeAgents, stats } = useAgents(projectId);
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
@@ -40,10 +130,10 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||||
const [agentView, setAgentView] = useState<"board" | "list">(() => {
|
const [agentView, setAgentView] = useState<"board" | "list" | "tree">(() => {
|
||||||
if (typeof window === "undefined") return "list";
|
if (typeof window === "undefined") return "list";
|
||||||
const saved = localStorage.getItem("kb-agent-view");
|
const saved = localStorage.getItem("kb-agent-view");
|
||||||
return (saved === "board" || saved === "list") ? saved : "list";
|
return (saved === "board" || saved === "list" || saved === "tree") ? saved : "list";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist view preference to localStorage
|
// Persist view preference to localStorage
|
||||||
@@ -54,6 +144,8 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null);
|
const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null);
|
||||||
const roleSelectRef = useRef<HTMLSelectElement>(null);
|
const roleSelectRef = useRef<HTMLSelectElement>(null);
|
||||||
|
|
||||||
|
const hierarchy = useAgentHierarchy(agents);
|
||||||
|
|
||||||
const loadAgents = useCallback(async () => {
|
const loadAgents = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -183,6 +275,15 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
>
|
>
|
||||||
<List size={16} />
|
<List size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-toggle-btn${agentView === "tree" ? " active" : ""}`}
|
||||||
|
onClick={() => setAgentView("tree")}
|
||||||
|
title="Tree view"
|
||||||
|
aria-label="Tree view"
|
||||||
|
aria-pressed={agentView === "tree"}
|
||||||
|
>
|
||||||
|
<GitBranch size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
@@ -239,6 +340,30 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
<ActiveAgentsPanel agents={activeAgents} />
|
<ActiveAgentsPanel agents={activeAgents} />
|
||||||
|
|
||||||
{/* Agent List */}
|
{/* Agent List */}
|
||||||
|
{agentView === "tree" ? (
|
||||||
|
<div className="agent-tree__view">
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<div className="agent-empty">
|
||||||
|
<Bot size={48} opacity={0.3} />
|
||||||
|
<p>No agents found</p>
|
||||||
|
<p className="text-secondary">Create an agent to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
hierarchy.rootNodes.map((node) => (
|
||||||
|
<AgentTreeNode
|
||||||
|
key={node.agent.id}
|
||||||
|
node={node}
|
||||||
|
onSelect={setSelectedAgentId}
|
||||||
|
onToggle={hierarchy.toggleExpand}
|
||||||
|
isExpanded={hierarchy.isExpanded}
|
||||||
|
getChildCount={(id) => hierarchy.getChildren(id).length}
|
||||||
|
getHealthStatus={getHealthStatus}
|
||||||
|
getRoleIcon={getRoleIcon}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className={agentView === "board" ? "agent-board" : "agent-list"}>
|
<div className={agentView === "board" ? "agent-board" : "agent-list"}>
|
||||||
{agents.length === 0 ? (
|
{agents.length === 0 ? (
|
||||||
<div className="agent-empty">
|
<div className="agent-empty">
|
||||||
@@ -583,6 +708,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent Detail Modal */}
|
{/* Agent Detail Modal */}
|
||||||
@@ -592,6 +718,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onClose={() => setSelectedAgentId(null)}
|
onClose={() => setSelectedAgentId(null)}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
|
onChildClick={(childId) => setSelectedAgentId(childId)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
197
packages/dashboard/app/hooks/__tests__/useAgentHierarchy.test.ts
Normal file
197
packages/dashboard/app/hooks/__tests__/useAgentHierarchy.test.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { useAgentHierarchy } from "../useAgentHierarchy";
|
||||||
|
import type { Agent, AgentCapability, AgentState } from "../../api";
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageStore: Record<string, string> = {};
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: vi.fn((key: string) => localStorageStore[key] ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
|
localStorageStore[key] = value;
|
||||||
|
}),
|
||||||
|
removeItem: vi.fn((key: string) => {
|
||||||
|
delete localStorageStore[key];
|
||||||
|
}),
|
||||||
|
clear: vi.fn(() => {
|
||||||
|
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vi.stubGlobal("localStorage", localStorageMock);
|
||||||
|
|
||||||
|
function createMockAgent(overrides: Partial<Agent> = {}): Agent {
|
||||||
|
return {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Test Agent",
|
||||||
|
role: "executor" as AgentCapability,
|
||||||
|
state: "idle" as AgentState,
|
||||||
|
metadata: {},
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.getItem.mockImplementation((key: string) => localStorageStore[key] ?? null);
|
||||||
|
localStorageMock.setItem.mockImplementation((key: string, value: string) => {
|
||||||
|
localStorageStore[key] = value;
|
||||||
|
});
|
||||||
|
localStorageMock.clear.mockImplementation(() => {
|
||||||
|
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorageMock.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAgentHierarchy", () => {
|
||||||
|
it("builds tree from flat agents array", () => {
|
||||||
|
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||||
|
const child = createMockAgent({ id: "child-1", name: "Child", reportsTo: "parent-1" });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentHierarchy([parent, child]));
|
||||||
|
|
||||||
|
expect(result.current.rootNodes).toHaveLength(1);
|
||||||
|
expect(result.current.rootNodes[0].agent.id).toBe("parent-1");
|
||||||
|
expect(result.current.rootNodes[0].depth).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty agents array", () => {
|
||||||
|
const { result } = renderHook(() => useAgentHierarchy([]));
|
||||||
|
|
||||||
|
expect(result.current.rootNodes).toHaveLength(0);
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles agents with no parent-child relationships (all root nodes)", () => {
|
||||||
|
const agent1 = createMockAgent({ id: "agent-1" });
|
||||||
|
const agent2 = createMockAgent({ id: "agent-2" });
|
||||||
|
const agent3 = createMockAgent({ id: "agent-3" });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentHierarchy([agent1, agent2, agent3]));
|
||||||
|
|
||||||
|
expect(result.current.rootNodes).toHaveLength(3);
|
||||||
|
expect(result.current.rootNodes.map((n) => n.agent.id)).toEqual(["agent-1", "agent-2", "agent-3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles deeply nested hierarchies (parent -> child -> grandchild)", () => {
|
||||||
|
const parent = createMockAgent({ id: "parent", name: "Parent" });
|
||||||
|
const child = createMockAgent({ id: "child", name: "Child", reportsTo: "parent" });
|
||||||
|
const grandchild = createMockAgent({ id: "grandchild", name: "Grandchild", reportsTo: "child" });
|
||||||
|
|
||||||
|
// Pre-expand parent and child so grandchild shows up
|
||||||
|
localStorageMock.getItem.mockImplementation((key: string) => {
|
||||||
|
if (key === "kb-agent-tree-expanded") return JSON.stringify(["parent", "child"]);
|
||||||
|
return localStorageStore[key] ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentHierarchy([parent, child, grandchild]));
|
||||||
|
|
||||||
|
expect(result.current.rootNodes).toHaveLength(1);
|
||||||
|
const rootNode = result.current.rootNodes[0];
|
||||||
|
expect(rootNode.agent.id).toBe("parent");
|
||||||
|
expect(rootNode.depth).toBe(0);
|
||||||
|
expect(rootNode.children).toHaveLength(1);
|
||||||
|
expect(rootNode.children[0].agent.id).toBe("child");
|
||||||
|
expect(rootNode.children[0].depth).toBe(1);
|
||||||
|
expect(rootNode.children[0].children).toHaveLength(1);
|
||||||
|
expect(rootNode.children[0].children[0].agent.id).toBe("grandchild");
|
||||||
|
expect(rootNode.children[0].children[0].depth).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles expand state for a node", () => {
|
||||||
|
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||||
|
const child = createMockAgent({ id: "child-1", name: "Child", reportsTo: "parent-1" });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentHierarchy([parent, child]));
|
||||||
|
|
||||||
|
// Initially not expanded
|
||||||
|
expect(result.current.isExpanded("parent-1")).toBe(false);
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleExpand("parent-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isExpanded("parent-1")).toBe(true);
|
||||||
|
|
||||||
|
// Collapse
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleExpand("parent-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isExpanded("parent-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists expand state to localStorage", () => {
|
||||||
|
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentHierarchy([parent]));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleExpand("parent-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
|
"kb-agent-tree-expanded",
|
||||||
|
JSON.stringify(["parent-1"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores expand state from localStorage on mount", () => {
|
||||||
|
localStorageMock.getItem.mockImplementation((key: string) => {
|
||||||
|
if (key === "kb-agent-tree-expanded") return JSON.stringify(["parent-1"]);
|
||||||
|
return localStorageStore[key] ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentHierarchy([parent]));
|
||||||
|
|
||||||
|
expect(result.current.isExpanded("parent-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isExpanded returns correct state", () => {
|
||||||
|
const agent1 = createMockAgent({ id: "agent-1" });
|
||||||
|
const agent2 = createMockAgent({ id: "agent-2" });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentHierarchy([agent1, agent2]));
|
||||||
|
|
||||||
|
expect(result.current.isExpanded("agent-1")).toBe(false);
|
||||||
|
expect(result.current.isExpanded("agent-2")).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleExpand("agent-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isExpanded("agent-1")).toBe(true);
|
||||||
|
expect(result.current.isExpanded("agent-2")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getChildren returns direct children for an agent", () => {
|
||||||
|
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||||
|
const child1 = createMockAgent({ id: "child-1", name: "Child 1", reportsTo: "parent-1" });
|
||||||
|
const child2 = createMockAgent({ id: "child-2", name: "Child 2", reportsTo: "parent-1" });
|
||||||
|
const unrelated = createMockAgent({ id: "unrelated", name: "Unrelated" });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentHierarchy([parent, child1, child2, unrelated]));
|
||||||
|
|
||||||
|
const children = result.current.getChildren("parent-1");
|
||||||
|
expect(children).toHaveLength(2);
|
||||||
|
expect(children.map((c) => c.id)).toEqual(["child-1", "child-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles agents with reportsTo pointing to non-existent parent", () => {
|
||||||
|
const orphan = createMockAgent({ id: "orphan-1", name: "Orphan", reportsTo: "missing-parent" });
|
||||||
|
const normal = createMockAgent({ id: "normal-1", name: "Normal" });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentHierarchy([orphan, normal]));
|
||||||
|
|
||||||
|
// Orphan should be treated as a root node since parent doesn't exist
|
||||||
|
expect(result.current.rootNodes).toHaveLength(2);
|
||||||
|
expect(result.current.rootNodes.map((n) => n.agent.id)).toContain("orphan-1");
|
||||||
|
expect(result.current.rootNodes.map((n) => n.agent.id)).toContain("normal-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
111
packages/dashboard/app/hooks/useAgentHierarchy.ts
Normal file
111
packages/dashboard/app/hooks/useAgentHierarchy.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState, useMemo, useCallback } from "react";
|
||||||
|
import type { Agent } from "../api";
|
||||||
|
|
||||||
|
const EXPANDED_KEY = "kb-agent-tree-expanded";
|
||||||
|
|
||||||
|
export interface AgentNode {
|
||||||
|
agent: Agent;
|
||||||
|
children: AgentNode[];
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAgentHierarchyReturn {
|
||||||
|
rootNodes: AgentNode[];
|
||||||
|
toggleExpand: (agentId: string) => void;
|
||||||
|
isExpanded: (agentId: string) => boolean;
|
||||||
|
getChildren: (agentId: string) => Agent[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExpandedFromStorage(): Set<string> {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(EXPANDED_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed: string[] = JSON.parse(stored);
|
||||||
|
return new Set(Array.isArray(parsed) ? parsed : []);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Gracefully degrade if localStorage is unavailable
|
||||||
|
}
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeExpandedToStorage(expanded: Set<string>): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded]));
|
||||||
|
} catch {
|
||||||
|
// Gracefully degrade if localStorage is unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(agents: Agent[], expanded: Set<string>): AgentNode[] {
|
||||||
|
const agentMap = new Map<string, Agent>();
|
||||||
|
const childrenMap = new Map<string, Agent[]>();
|
||||||
|
|
||||||
|
for (const agent of agents) {
|
||||||
|
agentMap.set(agent.id, agent);
|
||||||
|
if (agent.reportsTo) {
|
||||||
|
const siblings = childrenMap.get(agent.reportsTo) ?? [];
|
||||||
|
siblings.push(agent);
|
||||||
|
childrenMap.set(agent.reportsTo, siblings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNode(agent: Agent, depth: number): AgentNode {
|
||||||
|
const childAgents = childrenMap.get(agent.id) ?? [];
|
||||||
|
const children: AgentNode[] = expanded.has(agent.id)
|
||||||
|
? childAgents.map((child) => buildNode(child, depth + 1))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { agent, children, depth };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root nodes are agents with no reportsTo or whose reportsTo points to non-existent agent
|
||||||
|
return agents
|
||||||
|
.filter((agent) => !agent.reportsTo || !agentMap.has(agent.reportsTo))
|
||||||
|
.map((agent) => buildNode(agent, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing agent hierarchy (parent-child relationships).
|
||||||
|
* Derives the tree structure from the `reportsTo` field on agents.
|
||||||
|
* Expand/collapse state is persisted to localStorage.
|
||||||
|
*/
|
||||||
|
export function useAgentHierarchy(agents: Agent[]): UseAgentHierarchyReturn {
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(() => readExpandedFromStorage());
|
||||||
|
|
||||||
|
const rootNodes = useMemo(() => buildTree(agents, expanded), [agents, expanded]);
|
||||||
|
|
||||||
|
const toggleExpand = useCallback((agentId: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(agentId)) {
|
||||||
|
next.delete(agentId);
|
||||||
|
} else {
|
||||||
|
next.add(agentId);
|
||||||
|
}
|
||||||
|
writeExpandedToStorage(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isExpanded = useCallback(
|
||||||
|
(agentId: string) => expanded.has(agentId),
|
||||||
|
[expanded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getChildren = useCallback(
|
||||||
|
(agentId: string): Agent[] => {
|
||||||
|
return agents.filter((a) => a.reportsTo === agentId);
|
||||||
|
},
|
||||||
|
[agents],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootNodes,
|
||||||
|
toggleExpand,
|
||||||
|
isExpanded,
|
||||||
|
getChildren,
|
||||||
|
isLoading: false, // tree is derived from pre-fetched agents
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19701,6 +19701,120 @@ html .column.drag-over * {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Agent Tree View ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.agent-tree__view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__node {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__node:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__indent--0 { padding-left: 0; }
|
||||||
|
.agent-tree__indent--1 { padding-left: 24px; }
|
||||||
|
.agent-tree__indent--2 { padding-left: 48px; }
|
||||||
|
.agent-tree__indent--3 { padding-left: 72px; }
|
||||||
|
.agent-tree__indent--4 { padding-left: 96px; }
|
||||||
|
|
||||||
|
.agent-tree__toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__toggle:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__toggle--leaf {
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__toggle--leaf:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__icon {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__health {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__count {
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-is-child {
|
||||||
|
background: rgba(124, 92, 191, 0.05);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__children {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-board {
|
.agent-board {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
|||||||
@@ -7069,6 +7069,33 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/agents/:id/children
|
||||||
|
* Fetch agents that report to a given agent (parent-child hierarchy).
|
||||||
|
* Response 200: Agent[] — Array of agents where reportsTo equals :id
|
||||||
|
* Response 404: { error: "Agent not found" } — When parent agent doesn't exist
|
||||||
|
*/
|
||||||
|
router.get("/agents/:id/children", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const scopedStore = await getScopedStore(req);
|
||||||
|
const { AgentStore } = await import("@fusion/core");
|
||||||
|
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||||
|
await agentStore.init();
|
||||||
|
|
||||||
|
// Validate the parent agent exists
|
||||||
|
const parent = await agentStore.getAgent(req.params.id);
|
||||||
|
if (!parent) {
|
||||||
|
res.status(404).json({ error: "Agent not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = await agentStore.getAgentsByReportsTo(req.params.id);
|
||||||
|
res.json(children);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Agent Generation Routes ──────────────────────────────────────────────
|
// ── Agent Generation Routes ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user