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:
gsxdsm
2026-04-05 11:03:40 -07:00
parent a731c5952b
commit cf9c3bda95
3 changed files with 335 additions and 0 deletions

View File

@@ -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

View File

@@ -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");
});
});
});

View File

@@ -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);
}