feat(FN-974): add agent preset selection UI to NewAgentDialog
- Define AgentPreset interface and presets array with role-specific configurations - Add preset selection UI to NewAgentDialog component with visual cards - Add CSS styles for preset cards, hover states, and selection indicators - Add comprehensive tests covering preset rendering, selection, and keyboard interaction
This commit is contained in:
@@ -33,6 +33,45 @@ interface RuntimeConfig {
|
||||
maxTurns: number;
|
||||
}
|
||||
|
||||
/** Preset agent template for one-click creation */
|
||||
interface AgentPreset {
|
||||
/** Unique identifier for the preset */
|
||||
id: string;
|
||||
/** Display name (e.g., "CEO", "CTO") */
|
||||
name: string;
|
||||
/** Emoji icon */
|
||||
icon: string;
|
||||
/** Professional title (e.g., "Chief Executive Officer") */
|
||||
title: string;
|
||||
/** Agent capability role */
|
||||
role: AgentCapability;
|
||||
/** Optional description of the agent's responsibilities */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const AGENT_PRESETS: AgentPreset[] = [
|
||||
{ id: "ceo", name: "CEO", icon: "👔", title: "Chief Executive Officer", role: "custom" },
|
||||
{ id: "cto", name: "CTO", icon: "🧠", title: "Chief Technology Officer", role: "custom" },
|
||||
{ id: "cmo", name: "CMO", icon: "📢", title: "Chief Marketing Officer", role: "custom" },
|
||||
{ id: "cfo", name: "CFO", icon: "💰", title: "Chief Financial Officer", role: "custom" },
|
||||
{ id: "engineer", name: "Engineer", icon: "👨💻", title: "Software Engineer", role: "engineer" },
|
||||
{ id: "backend-engineer", name: "Backend Engineer", icon: "⚙️", title: "Backend Engineer", role: "engineer" },
|
||||
{ id: "frontend-engineer", name: "Frontend Engineer", icon: "🎨", title: "Frontend Engineer", role: "engineer" },
|
||||
{ id: "fullstack-engineer", name: "Fullstack Engineer", icon: "🚀", title: "Full Stack Engineer", role: "engineer" },
|
||||
{ id: "qa-engineer", name: "QA Engineer", icon: "🧪", title: "Quality Assurance Engineer", role: "engineer" },
|
||||
{ id: "devops-engineer", name: "DevOps Engineer", icon: "🔧", title: "DevOps Engineer", role: "engineer" },
|
||||
{ id: "ci-engineer", name: "CI Engineer", icon: "⚡", title: "CI/CD Engineer", role: "engineer" },
|
||||
{ id: "security-engineer", name: "Security Engineer", icon: "🛡️", title: "Security Engineer", role: "engineer" },
|
||||
{ id: "data-engineer", name: "Data Engineer", icon: "📊", title: "Data Engineer", role: "engineer" },
|
||||
{ id: "ml-engineer", name: "ML Engineer", icon: "🤖", title: "Machine Learning Engineer", role: "engineer" },
|
||||
{ id: "product-manager", name: "Product Manager", icon: "📋", title: "Product Manager", role: "custom" },
|
||||
{ id: "designer", name: "Designer", icon: "✏️", title: "Product Designer", role: "custom" },
|
||||
{ id: "marketing-manager", name: "Marketing Manager", icon: "📣", title: "Marketing Manager", role: "custom" },
|
||||
{ id: "technical-writer", name: "Technical Writer", icon: "📝", title: "Technical Writer", role: "custom" },
|
||||
{ id: "triage", name: "Triage Agent", icon: "🔍", title: "Task Triage Agent", role: "triage" },
|
||||
{ id: "reviewer", name: "Reviewer", icon: "👁️", title: "Code Reviewer", role: "reviewer" },
|
||||
];
|
||||
|
||||
export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAgentDialogProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [name, setName] = useState("");
|
||||
@@ -44,6 +83,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
thinkingLevel: "off",
|
||||
maxTurns: 10,
|
||||
});
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isGenerationModalOpen, setIsGenerationModalOpen] = useState(false);
|
||||
@@ -117,6 +157,16 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
setFavoriteModels(newFavorites);
|
||||
}, [favoriteModels]);
|
||||
|
||||
const handlePresetSelect = useCallback((preset: AgentPreset) => {
|
||||
setSelectedPresetId(preset.id);
|
||||
setName(preset.name);
|
||||
setIcon(preset.icon);
|
||||
setTitle(preset.title);
|
||||
setRole(preset.role);
|
||||
// Advance to Step 1 so user can review model selection
|
||||
setStep(1);
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -126,6 +176,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
setIcon("");
|
||||
setRole("custom");
|
||||
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
|
||||
setSelectedPresetId(null);
|
||||
setError(null);
|
||||
setIsGenerationModalOpen(false);
|
||||
onClose();
|
||||
@@ -188,6 +239,28 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
<div className="agent-dialog-body">
|
||||
{step === 0 && (
|
||||
<div>
|
||||
{/* Quick Start Presets */}
|
||||
<div className="agent-presets">
|
||||
<div className="agent-presets-header">
|
||||
Choose a preset or fill in details manually
|
||||
</div>
|
||||
<div className="agent-presets-grid">
|
||||
{AGENT_PRESETS.map(preset => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className={`agent-preset-card${selectedPresetId === preset.id ? " selected" : ""}`}
|
||||
data-testid={`preset-${preset.id}`}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
title={preset.title}
|
||||
>
|
||||
<span className="agent-preset-icon">{preset.icon}</span>
|
||||
<span className="agent-preset-name">{preset.name}</span>
|
||||
<span className="agent-preset-role">{preset.role}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-name">Name <span className="agent-dialog-required">*</span></label>
|
||||
<input
|
||||
|
||||
@@ -586,4 +586,202 @@ describe("NewAgentDialog", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("preset selection", () => {
|
||||
it("renders all 20 preset cards in step 0", () => {
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
const presetCards = screen.getAllByTestId(/^preset-/);
|
||||
expect(presetCards).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("shows the quick start header text", () => {
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
expect(screen.getByText("Choose a preset or fill in details manually")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking a preset populates name, title, icon, and role", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Click the "Engineer" preset
|
||||
await user.click(screen.getByTestId("preset-engineer"));
|
||||
|
||||
// Should advance to step 1 (model config), go back to verify fields
|
||||
await user.click(screen.getByText("Back"));
|
||||
|
||||
// Verify form fields were populated
|
||||
const nameInput = screen.getByLabelText(/Name/) as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("Engineer");
|
||||
|
||||
const titleInput = screen.getByLabelText(/Title/) as HTMLInputElement;
|
||||
expect(titleInput.value).toBe("Software Engineer");
|
||||
|
||||
// Verify role was set to engineer
|
||||
const roleGrid = document.querySelector(".agent-role-grid");
|
||||
const engineerRoleButton = roleGrid?.querySelector(".agent-role-option.selected");
|
||||
expect(engineerRoleButton?.textContent).toContain("Engineer");
|
||||
});
|
||||
|
||||
it("clicking a preset advances directly to step 1", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Click the "CTO" preset
|
||||
await user.click(screen.getByTestId("preset-cto"));
|
||||
|
||||
// Should be on step 1 — model dropdown visible
|
||||
expect(screen.getByTestId("custom-model-dropdown")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("selected preset card has .selected CSS class", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Click the "CEO" preset
|
||||
await user.click(screen.getByTestId("preset-ceo"));
|
||||
|
||||
// Go back to step 0 to verify visual feedback
|
||||
await user.click(screen.getByText("Back"));
|
||||
|
||||
const ceoCard = screen.getByTestId("preset-ceo");
|
||||
expect(ceoCard.classList.contains("selected")).toBe(true);
|
||||
});
|
||||
|
||||
it("clicking a different preset updates the selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Click CEO preset
|
||||
await user.click(screen.getByTestId("preset-ceo"));
|
||||
await user.click(screen.getByText("Back"));
|
||||
|
||||
// Click CTO preset
|
||||
await user.click(screen.getByTestId("preset-cto"));
|
||||
await user.click(screen.getByText("Back"));
|
||||
|
||||
// Only CTO should be selected
|
||||
expect(screen.getByTestId("preset-cto").classList.contains("selected")).toBe(true);
|
||||
expect(screen.getByTestId("preset-ceo").classList.contains("selected")).toBe(false);
|
||||
|
||||
// Name should be updated
|
||||
const nameInput = screen.getByLabelText(/Name/) as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("CTO");
|
||||
});
|
||||
|
||||
it("user can override preset values with manual entry after selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Select a preset
|
||||
await user.click(screen.getByTestId("preset-engineer"));
|
||||
await user.click(screen.getByText("Back"));
|
||||
|
||||
// Override the name manually
|
||||
const nameInput = screen.getByLabelText(/Name/) as HTMLInputElement;
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "My Custom Engineer");
|
||||
|
||||
expect(nameInput.value).toBe("My Custom Engineer");
|
||||
});
|
||||
|
||||
it("dialog reset clears preset selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Select a preset
|
||||
await user.click(screen.getByTestId("preset-ceo"));
|
||||
|
||||
// Close the dialog
|
||||
await user.click(screen.getByLabelText("Close"));
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
|
||||
// Re-open — state should be reset
|
||||
unmount();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
// Name should be empty (no preset selected)
|
||||
const nameInput = screen.getByLabelText(/Name/) as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("");
|
||||
|
||||
// No preset cards should be selected
|
||||
const selectedCards = document.querySelectorAll(".agent-preset-card.selected");
|
||||
expect(selectedCards).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates agent with preset fields through the full flow", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Click the "Reviewer" preset (advances to step 1)
|
||||
await user.click(screen.getByTestId("preset-reviewer"));
|
||||
|
||||
// Step 1: navigate to summary
|
||||
await user.click(screen.getByText("Next"));
|
||||
|
||||
// Step 2: verify summary and create
|
||||
// Verify name
|
||||
expect(screen.getByText("Reviewer")).toBeTruthy();
|
||||
// Verify icon
|
||||
expect(screen.getByText("👁️")).toBeTruthy();
|
||||
|
||||
// Create
|
||||
await user.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const createCall = mockCreateAgent.mock.calls[0][0];
|
||||
expect(createCall.name).toBe("Reviewer");
|
||||
expect(createCall.icon).toBe("👁️");
|
||||
expect(createCall.title).toBe("Code Reviewer");
|
||||
expect(createCall.role).toBe("reviewer");
|
||||
});
|
||||
|
||||
it("preset card titles show the professional title", () => {
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
const ceoCard = screen.getByTestId("preset-ceo");
|
||||
expect(ceoCard.getAttribute("title")).toBe("Chief Executive Officer");
|
||||
|
||||
const ctoCard = screen.getByTestId("preset-cto");
|
||||
expect(ctoCard.getAttribute("title")).toBe("Chief Technology Officer");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19439,6 +19439,70 @@ html .column.drag-over * {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* Agent preset quick-start cards */
|
||||
.agent-presets {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.agent-presets-header {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.agent-preset-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: var(--bg-secondary);
|
||||
font-family: var(--font-primary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-preset-card:hover {
|
||||
border-color: var(--todo);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.agent-preset-card.selected {
|
||||
border-color: var(--todo);
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
}
|
||||
|
||||
.agent-preset-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.agent-preset-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.agent-preset-role {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.agent-dialog-required {
|
||||
color: var(--state-error-text);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user