feat(FN-1794): merge fusion/fn-1794
This commit is contained in:
@@ -12,6 +12,7 @@ import type { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { AgentReflectionsTab } from "./AgentReflectionsTab";
|
||||
import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||
import { SkillMultiselect } from "./SkillMultiselect";
|
||||
|
||||
/**
|
||||
* Simple className utility - joins class names conditionally
|
||||
@@ -653,6 +654,20 @@ function DashboardTab({
|
||||
<span className="info-label">Role</span>
|
||||
<span className="info-value">{agent.role}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Skills</span>
|
||||
<span className="info-value">
|
||||
{Array.isArray(agent.metadata?.skills) && (agent.metadata.skills as string[]).length > 0 ? (
|
||||
<div className="skill-badge-row">
|
||||
{(agent.metadata.skills as string[]).map((skillId: string) => (
|
||||
<span key={skillId} className="skill-badge">{skillId}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">State</span>
|
||||
<span className="info-value">
|
||||
@@ -2285,6 +2300,11 @@ function ConfigTab({
|
||||
const [bundleExternalPath, setBundleExternalPath] = useState(agent.bundleConfig?.externalPath ?? "");
|
||||
const [bundleFiles, setBundleFiles] = useState<string[]>(agent.bundleConfig?.files ?? []);
|
||||
|
||||
// Skills state initialized from agent.metadata.skills
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||
Array.isArray(agent.metadata?.skills) ? agent.metadata.skills as string[] : []
|
||||
);
|
||||
|
||||
// Budget status for progress bar display
|
||||
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
|
||||
const [isResettingBudget, setIsResettingBudget] = useState(false);
|
||||
@@ -2360,6 +2380,10 @@ function ConfigTab({
|
||||
: "";
|
||||
if (currentThreshold !== persistedThreshold) return true;
|
||||
|
||||
// Check skills
|
||||
const persistedSkills = Array.isArray(agent.metadata?.skills) ? agent.metadata.skills as string[] : [];
|
||||
if (JSON.stringify(selectedSkills) !== JSON.stringify(persistedSkills)) return true;
|
||||
|
||||
return false;
|
||||
})();
|
||||
|
||||
@@ -2488,6 +2512,13 @@ function ConfigTab({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle skills in metadata
|
||||
if (selectedSkills.length > 0) {
|
||||
newMetadata.skills = selectedSkills;
|
||||
} else {
|
||||
delete newMetadata.skills;
|
||||
}
|
||||
|
||||
// Build the runtimeConfig payload — only include non-empty values
|
||||
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns"] as const) {
|
||||
@@ -2646,6 +2677,25 @@ function ConfigTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<h3>Skills</h3>
|
||||
<p className="config-description">
|
||||
Assign skills to this agent for specialized behavior.
|
||||
</p>
|
||||
|
||||
<div className="config-fields">
|
||||
<div className="config-field">
|
||||
<SkillMultiselect
|
||||
id="agent-skills"
|
||||
label="Skills"
|
||||
value={selectedSkills}
|
||||
onChange={setSelectedSkills}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<h3>Heartbeat Settings</h3>
|
||||
<p className="config-description">
|
||||
|
||||
@@ -47,6 +47,7 @@ function AgentTreeNode({
|
||||
getChildCount,
|
||||
getHealthStatus,
|
||||
getRoleIcon,
|
||||
getSkillBadges,
|
||||
}: {
|
||||
node: AgentNode;
|
||||
onSelect: (id: string) => void;
|
||||
@@ -55,6 +56,7 @@ function AgentTreeNode({
|
||||
getChildCount: (id: string) => number;
|
||||
getHealthStatus: (agent: Agent) => { label: string; icon: JSX.Element; color: string };
|
||||
getRoleIcon: (role: AgentCapability) => string;
|
||||
getSkillBadges: (agent: Agent) => string[];
|
||||
}) {
|
||||
const { agent, children, depth } = node;
|
||||
const childCount = getChildCount(agent.id);
|
||||
@@ -104,6 +106,16 @@ function AgentTreeNode({
|
||||
{childCount > 0 && (
|
||||
<span className="agent-tree__count text-secondary">({childCount})</span>
|
||||
)}
|
||||
{/* Tree view: up to 1 skill badge */}
|
||||
{(() => {
|
||||
const skills = getSkillBadges(agent);
|
||||
if (skills.length === 0) return null;
|
||||
return (
|
||||
<span className="agent-tree__skill" title={skills.join(", ")}>
|
||||
{skills[0]}{skills.length > 1 && ` +${skills.length - 1}`}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{expanded && children.length > 0 && (
|
||||
@@ -118,6 +130,7 @@ function AgentTreeNode({
|
||||
getChildCount={getChildCount}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
getSkillBadges={getSkillBadges}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -131,11 +144,13 @@ function OrgChartNode({
|
||||
onSelect,
|
||||
getHealthStatus,
|
||||
getRoleIcon,
|
||||
getSkillBadges,
|
||||
}: {
|
||||
node: OrgTreeNode;
|
||||
onSelect: (id: string) => void;
|
||||
getHealthStatus: (agent: Agent) => { label: string; icon: JSX.Element; color: string };
|
||||
getRoleIcon: (role: AgentCapability) => string;
|
||||
getSkillBadges: (agent: Agent) => string[];
|
||||
}) {
|
||||
const { agent, children } = node;
|
||||
const health = getHealthStatus(agent);
|
||||
@@ -169,6 +184,21 @@ function OrgChartNode({
|
||||
{health.icon}
|
||||
<span className="text-secondary">{health.label}</span>
|
||||
</span>
|
||||
{/* Org chart: up to 2 skill badges */}
|
||||
{(() => {
|
||||
const skills = getSkillBadges(agent);
|
||||
if (skills.length === 0) return null;
|
||||
const displaySkills = skills.slice(0, 2);
|
||||
const extraCount = skills.length - 2;
|
||||
return (
|
||||
<>
|
||||
{displaySkills.map((skillId) => (
|
||||
<span key={skillId} className="org-chart-node__skill">{skillId}</span>
|
||||
))}
|
||||
{extraCount > 0 && <span className="org-chart-node__skill">+{extraCount}</span>}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
@@ -180,6 +210,7 @@ function OrgChartNode({
|
||||
onSelect={onSelect}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
getSkillBadges={getSkillBadges}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -406,6 +437,14 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
|
||||
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "🤖";
|
||||
|
||||
/** Get skill badges from agent metadata */
|
||||
const getSkillBadges = (agent: Agent): string[] => {
|
||||
if (Array.isArray(agent.metadata?.skills)) {
|
||||
return agent.metadata.skills as string[];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Use centralized health status utility for consistent labels across all views
|
||||
const getHealthStatus = (agent: Agent): { label: string; icon: JSX.Element; color: string } => {
|
||||
return getAgentHealthStatus(agent);
|
||||
@@ -547,6 +586,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
getChildCount={(id) => hierarchy.getChildren(id).length}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
getSkillBadges={getSkillBadges}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -572,6 +612,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
onSelect={setSelectedAgentId}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
getSkillBadges={getSkillBadges}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -618,6 +659,21 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
{agent.name}
|
||||
</div>
|
||||
<div className="agent-board-id">{agent.id}</div>
|
||||
{/* Board view: up to 1 skill badge */}
|
||||
{(() => {
|
||||
const skills = getSkillBadges(agent);
|
||||
if (skills.length === 0) return null;
|
||||
const displaySkills = skills.slice(0, 1);
|
||||
const extraCount = skills.length - 1;
|
||||
return (
|
||||
<div className="agent-board-skills">
|
||||
{displaySkills.map((skillId) => (
|
||||
<span key={skillId} className="skill-badge-sm">{skillId}</span>
|
||||
))}
|
||||
{extraCount > 0 && <span className="skill-badge-sm skill-badge-extra">+{extraCount}</span>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="agent-board-actions">
|
||||
{agent.state === "idle" && (
|
||||
@@ -817,6 +873,21 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
<span className="badge text-secondary">
|
||||
{getRoleLabel(agent.role)}
|
||||
</span>
|
||||
{/* List view: up to 2 skill badges */}
|
||||
{(() => {
|
||||
const skills = getSkillBadges(agent);
|
||||
if (skills.length === 0) return null;
|
||||
const displaySkills = skills.slice(0, 2);
|
||||
const extraCount = skills.length - 2;
|
||||
return (
|
||||
<>
|
||||
{displaySkills.map((skillId) => (
|
||||
<span key={skillId} className="badge badge-skill">{skillId}</span>
|
||||
))}
|
||||
{extraCount > 0 && <span className="badge badge-skill">+{extraCount}</span>}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { AgentGenerationModal } from "./AgentGenerationModal";
|
||||
import { AGENT_PRESETS, type AgentPreset } from "./agent-presets";
|
||||
import { SkillMultiselect } from "./SkillMultiselect";
|
||||
|
||||
export interface NewAgentDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -51,6 +52,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
maxTurns: 1000,
|
||||
});
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isGenerationModalOpen, setIsGenerationModalOpen] = useState(false);
|
||||
@@ -169,6 +171,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
setMemory("");
|
||||
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 1000 });
|
||||
setSelectedPresetId(null);
|
||||
setSelectedSkills([]);
|
||||
setError(null);
|
||||
setIsGenerationModalOpen(false);
|
||||
onClose();
|
||||
@@ -194,6 +197,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
...(soul.trim() ? { soul: soul.trim() } : {}),
|
||||
...(memory.trim() ? { memory: memory.trim() } : {}),
|
||||
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
|
||||
...(selectedSkills.length > 0 ? { metadata: { skills: selectedSkills } } : {}),
|
||||
}, projectId);
|
||||
handleClose();
|
||||
onCreated();
|
||||
@@ -419,6 +423,18 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
onChange={e => setRuntimeConfig(c => ({ ...c, maxTurns: Math.max(1, parseInt(e.target.value, 10) || 1) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<SkillMultiselect
|
||||
id="agent-skills"
|
||||
label="Skills"
|
||||
value={selectedSkills}
|
||||
onChange={setSelectedSkills}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<p className="agent-dialog-optional agent-dialog-skills-hint">
|
||||
Optional skills to assign to this agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -497,6 +513,12 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
<span className="agent-dialog-summary-row-label">Max Turns</span>
|
||||
<span>{runtimeConfig.maxTurns}</span>
|
||||
</div>
|
||||
{selectedSkills.length > 0 && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Skills</span>
|
||||
<span>{selectedSkills.length} skill{selectedSkills.length !== 1 ? "s" : ""} selected</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="agent-dialog-error">{error}</p>
|
||||
|
||||
148
packages/dashboard/app/components/SkillMultiselect.tsx
Normal file
148
packages/dashboard/app/components/SkillMultiselect.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchDiscoveredSkills } from "../api";
|
||||
import type { DiscoveredSkill } from "../api";
|
||||
|
||||
export interface SkillMultiselectProps {
|
||||
/** Currently selected skill IDs */
|
||||
value: string[];
|
||||
/** Called when the selection changes */
|
||||
onChange: (skills: string[]) => void;
|
||||
/** Project context for API calls */
|
||||
projectId?: string;
|
||||
/** Disable the control */
|
||||
disabled?: boolean;
|
||||
/** HTML id for accessibility */
|
||||
id?: string;
|
||||
/** Label text shown above the component */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable skill multiselect component.
|
||||
* Shows selected skills as removable chips and a dropdown to add more skills.
|
||||
*/
|
||||
export function SkillMultiselect({
|
||||
value,
|
||||
onChange,
|
||||
projectId,
|
||||
disabled = false,
|
||||
id,
|
||||
label = "Skills",
|
||||
}: SkillMultiselectProps) {
|
||||
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
fetchDiscoveredSkills(projectId)
|
||||
.then((discovered) => {
|
||||
if (!cancelled) {
|
||||
setSkills(discovered);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSkills([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const handleAddSkill = (skillId: string) => {
|
||||
if (!value.includes(skillId)) {
|
||||
onChange([...value, skillId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSkill = (skillId: string) => {
|
||||
onChange(value.filter((id) => id !== skillId));
|
||||
};
|
||||
|
||||
// Get skill names for display
|
||||
const getSkillName = (skillId: string): string => {
|
||||
const skill = skills.find((s) => s.id === skillId);
|
||||
return skill?.name ?? skillId;
|
||||
};
|
||||
|
||||
// Skills available to add (not yet selected)
|
||||
const availableSkills = skills.filter((s) => !value.includes(s.id));
|
||||
|
||||
return (
|
||||
<div className="skill-multiselect" data-testid="skill-multiselect">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={id ? `${id}-select` : undefined}
|
||||
className="skill-multiselect-label"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Selected skill chips */}
|
||||
{value.length > 0 && (
|
||||
<div className="skill-multiselect-chips" data-testid="skill-chips">
|
||||
{value.map((skillId) => (
|
||||
<span key={skillId} className="skill-chip" data-testid={`skill-chip-${skillId}`}>
|
||||
<span className="skill-chip-name">{getSkillName(skillId)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="skill-chip-remove"
|
||||
onClick={() => handleRemoveSkill(skillId)}
|
||||
disabled={disabled}
|
||||
aria-label={`Remove ${getSkillName(skillId)}`}
|
||||
data-testid={`remove-skill-${skillId}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown to add skills */}
|
||||
<div className="skill-multiselect-add">
|
||||
{isLoading ? (
|
||||
<span className="skill-multiselect-loading" data-testid="skills-loading">
|
||||
Loading skills…
|
||||
</span>
|
||||
) : availableSkills.length === 0 ? (
|
||||
<span className="skill-multiselect-empty" data-testid="skills-empty">
|
||||
{value.length === 0 ? "No skills discovered" : "All skills selected"}
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
id={id ? `${id}-select` : undefined}
|
||||
className="select skill-multiselect-dropdown"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
handleAddSkill(e.target.value);
|
||||
// Reset to placeholder
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
data-testid="skill-dropdown"
|
||||
>
|
||||
<option value="">Add a skill…</option>
|
||||
{availableSkills.map((skill) => (
|
||||
<option key={skill.id} value={skill.id}>
|
||||
{skill.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ vi.mock("../../api", () => ({
|
||||
resetAgentBudget: vi.fn(),
|
||||
fetchWorkspaceFileContent: vi.fn(),
|
||||
saveWorkspaceFileContent: vi.fn(),
|
||||
fetchDiscoveredSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentLogViewer", () => ({
|
||||
@@ -36,7 +37,18 @@ vi.mock("../AgentLogViewer", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../../api";
|
||||
// Mock SkillMultiselect
|
||||
vi.mock("../SkillMultiselect", () => ({
|
||||
SkillMultiselect: ({ value, onChange, id }: { value: string[]; onChange: (v: string[]) => void; id?: string }) => (
|
||||
<div data-testid="skill-multiselect">
|
||||
<span data-testid="skill-multiselect-value">{JSON.stringify(value)}</span>
|
||||
<button data-testid="add-skill-test" onClick={() => onChange([...value, "test-skill"])}>Add Test Skill</button>
|
||||
<button data-testid="remove-skill-test" onClick={() => onChange(value.filter(s => s !== "test-skill"))}>Remove Test Skill</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills } from "../../api";
|
||||
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
const mockUpdateAgent = vi.mocked(updateAgent);
|
||||
@@ -52,6 +64,12 @@ const mockResetAgentBudget = vi.mocked(resetAgentBudget);
|
||||
const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
|
||||
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
||||
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
|
||||
const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills);
|
||||
|
||||
const MOCK_SKILLS = [
|
||||
{ id: "skill-1", name: "Skill One", path: "/path/skill-1", relativePath: "skills/skill-1", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
|
||||
{ id: "skill-2", name: "Skill Two", path: "/path/skill-2", relativePath: "skills/skill-2", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
|
||||
];
|
||||
|
||||
describe("AgentDetailView", () => {
|
||||
const createMockAgent = (overrides: Partial<{
|
||||
@@ -124,6 +142,8 @@ describe("AgentDetailView", () => {
|
||||
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
||||
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
||||
mockUpdateAgentInstructions.mockResolvedValue({} as any);
|
||||
// Default: return skills
|
||||
mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS);
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
@@ -2418,4 +2438,189 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Skills ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Skills", () => {
|
||||
it("renders skill badges in Dashboard tab when agent has skills", async () => {
|
||||
const agentWithSkills = createMockAgent({
|
||||
metadata: { skills: ["skill-1", "skill-2"] },
|
||||
});
|
||||
mockFetchAgent.mockResolvedValue(agentWithSkills);
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("skill-1")).toBeTruthy();
|
||||
expect(screen.getAllByText("skill-2")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows dash when agent has no skills in Dashboard tab", async () => {
|
||||
const agentWithNoSkills = createMockAgent({
|
||||
metadata: {},
|
||||
});
|
||||
mockFetchAgent.mockResolvedValue(agentWithNoSkills);
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||
// Should show dash when no skills
|
||||
const skillsLabel = screen.getByText("Skills");
|
||||
const parent = skillsLabel.closest(".info-item");
|
||||
expect(parent?.textContent).toContain("—");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows SkillMultiselect in Config tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const agentWithSkills = createMockAgent({
|
||||
metadata: { skills: ["skill-1"] },
|
||||
});
|
||||
mockFetchAgent.mockResolvedValue(agentWithSkills);
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Navigate to Settings tab
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText("Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should show pre-selected skill
|
||||
expect(screen.getByTestId("skill-multiselect-value").textContent).toContain("skill-1");
|
||||
});
|
||||
|
||||
it("pre-fills skills from agent metadata in Config tab", async () => {
|
||||
const agentWithSkills = createMockAgent({
|
||||
metadata: { skills: ["skill-1", "skill-2"] },
|
||||
});
|
||||
mockFetchAgent.mockResolvedValue(agentWithSkills);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText("Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should have both skills pre-selected
|
||||
expect(screen.getByTestId("skill-multiselect-value").textContent).toContain("skill-1");
|
||||
expect(screen.getByTestId("skill-multiselect-value").textContent).toContain("skill-2");
|
||||
});
|
||||
|
||||
it("includes skills in metadata when saving Config tab", async () => {
|
||||
const agentWithSkills = createMockAgent({
|
||||
metadata: { skills: ["skill-1"] },
|
||||
});
|
||||
mockFetchAgent.mockResolvedValue(agentWithSkills);
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent({ metadata: { skills: ["skill-1", "new-skill"] } }) as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText("Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Add a skill
|
||||
await user.click(screen.getByTestId("add-skill-test"));
|
||||
|
||||
// Save settings
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||
"agent-001",
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
skills: ["skill-1", "test-skill"],
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables Save Settings when skills change", async () => {
|
||||
const agentWithSkills = createMockAgent({
|
||||
metadata: { skills: [] },
|
||||
});
|
||||
mockFetchAgent.mockResolvedValue(agentWithSkills);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText("Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Initially no changes
|
||||
expect(screen.getByText("Save Settings")).toBeDisabled();
|
||||
|
||||
// Add a skill
|
||||
await user.click(screen.getByTestId("add-skill-test"));
|
||||
|
||||
// Save button should now be enabled
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save Settings")).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("../../api", () => ({
|
||||
startAgentRun: vi.fn(),
|
||||
fetchOrgTree: vi.fn(),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentDetailView", () => ({
|
||||
|
||||
@@ -9,6 +9,19 @@ vi.mock("../../api", () => ({
|
||||
createAgent: vi.fn(),
|
||||
fetchModels: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
fetchDiscoveredSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock SkillMultiselect
|
||||
vi.mock("../SkillMultiselect", () => ({
|
||||
SkillMultiselect: ({ value, onChange, id }: { value: string[]; onChange: (v: string[]) => void; id?: string }) => (
|
||||
<div data-testid="skill-multiselect">
|
||||
<span data-testid="skill-multiselect-value">{JSON.stringify(value)}</span>
|
||||
<button data-testid="add-skill-1" onClick={() => onChange([...value, "skill-1"])}>Add Skill 1</button>
|
||||
<button data-testid="add-skill-2" onClick={() => onChange([...value, "skill-2"])}>Add Skill 2</button>
|
||||
<button data-testid="remove-skill-1" onClick={() => onChange(value.filter(s => s !== "skill-1"))}>Remove Skill 1</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock CustomModelDropdown to simplify interaction testing
|
||||
@@ -93,6 +106,7 @@ vi.mock("../AgentGenerationModal", () => ({
|
||||
const mockCreateAgent = vi.mocked(apiModule.createAgent);
|
||||
const mockFetchModels = vi.mocked(apiModule.fetchModels);
|
||||
const mockUpdateGlobalSettings = vi.mocked(apiModule.updateGlobalSettings);
|
||||
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
|
||||
|
||||
const MOCK_MODELS_RESPONSE = {
|
||||
models: [
|
||||
@@ -103,6 +117,11 @@ const MOCK_MODELS_RESPONSE = {
|
||||
favoriteModels: ["anthropic/claude-sonnet-4-5"],
|
||||
};
|
||||
|
||||
const MOCK_SKILLS_RESPONSE = [
|
||||
{ id: "skill-1", name: "Skill One", path: "/path/skill-1", relativePath: "skills/skill-1", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
|
||||
{ id: "skill-2", name: "Skill Two", path: "/path/skill-2", relativePath: "skills/skill-2", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
|
||||
];
|
||||
|
||||
describe("NewAgentDialog", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnCreated = vi.fn();
|
||||
@@ -112,6 +131,7 @@ describe("NewAgentDialog", () => {
|
||||
mockFetchModels.mockResolvedValue(MOCK_MODELS_RESPONSE);
|
||||
mockCreateAgent.mockResolvedValue({} as any);
|
||||
mockUpdateGlobalSettings.mockResolvedValue({});
|
||||
mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS_RESPONSE);
|
||||
});
|
||||
|
||||
describe("modal visibility", () => {
|
||||
@@ -1134,4 +1154,98 @@ describe("NewAgentDialog", () => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill selection", () => {
|
||||
it("renders SkillMultiselect in Step 1", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Navigate to step 1
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
await user.type(nameInput, "Test Agent");
|
||||
await user.click(screen.getByText("Next"));
|
||||
|
||||
// SkillMultiselect should be visible in step 1
|
||||
expect(screen.getByTestId("skill-multiselect")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows selected skills in summary on step 2", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Navigate to step 1 and add a skill
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
await user.type(nameInput, "Test Agent");
|
||||
await user.click(screen.getByText("Next"));
|
||||
|
||||
// Add a skill using the mocked button
|
||||
await user.click(screen.getByTestId("add-skill-1"));
|
||||
|
||||
// Navigate to step 2
|
||||
await user.click(screen.getByText("Next"));
|
||||
|
||||
// Summary should show skill count
|
||||
expect(screen.getByText(/1 skill/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("includes metadata.skills in createAgent call when skills are selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Navigate to step 1
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
await user.type(nameInput, "Test Agent");
|
||||
await user.click(screen.getByText("Next"));
|
||||
|
||||
// Add skills
|
||||
await user.click(screen.getByTestId("add-skill-1"));
|
||||
await user.click(screen.getByTestId("add-skill-2"));
|
||||
|
||||
// Navigate to step 2 and create
|
||||
await user.click(screen.getByText("Next"));
|
||||
await user.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const createCall = mockCreateAgent.mock.calls[0][0];
|
||||
expect(createCall.metadata).toEqual({ skills: ["skill-1", "skill-2"] });
|
||||
});
|
||||
|
||||
it("does not include metadata when no skills are selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Navigate through steps without adding skills
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
await user.type(nameInput, "Test Agent");
|
||||
await user.click(screen.getByText("Next"));
|
||||
await user.click(screen.getByText("Next"));
|
||||
await user.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const createCall = mockCreateAgent.mock.calls[0][0];
|
||||
expect(createCall.metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25546,7 +25546,160 @@ html .column.drag-over * {
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
/* === Skill Multiselect === */
|
||||
.skill-multiselect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.skill-multiselect-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.skill-multiselect-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.skill-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.skill-chip-name {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.skill-chip-remove:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.skill-chip-remove:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.skill-multiselect-add {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.skill-multiselect-loading,
|
||||
.skill-multiselect-empty {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.skill-multiselect-dropdown {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.agent-dialog-skills-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Agent detail divider */
|
||||
|
||||
/* Skill badge row for Dashboard tab */
|
||||
.skill-badge-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.skill-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Skill badges in AgentsView */
|
||||
.badge-skill {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.agent-board-skills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.skill-badge-sm {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.skill-badge-extra {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Tree view skill badge */
|
||||
.agent-tree__skill {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* Org chart skill badge */
|
||||
.org-chart-node__skill {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.divider {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user