feat(FN-908): replace text input with CustomModelDropdown in NewAgentDialog
- Replace plain text input for model selection with CustomModelDropdown component in NewAgentDialog - Add provider/model selection with grouped dropdown UI and search filtering - Support editing existing agents with pre-populated model values - Add comprehensive test suite (374 lines) covering rendering, interaction, and edge cases - Wire up agent creation and editing flows to use the new dropdown for model selection
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import type { AgentCapability } from "../api";
|
||||
import { createAgent } from "../api";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { AgentCapability, ModelInfo } from "../api";
|
||||
import { createAgent, fetchModels } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
export interface NewAgentDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -40,6 +42,55 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Model dropdown state
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
|
||||
// Load models on mount (global data, not per-agent)
|
||||
useEffect(() => {
|
||||
setModelsLoading(true);
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
setFavoriteModels(response.favoriteModels);
|
||||
})
|
||||
.catch(() => {
|
||||
// Gracefully handle — dropdown will show empty list
|
||||
})
|
||||
.finally(() => setModelsLoading(false));
|
||||
}, []);
|
||||
|
||||
// Selected model in "provider/modelId" format, or "" for default
|
||||
const selectedModel = runtimeConfig.model.includes("/")
|
||||
? runtimeConfig.model
|
||||
: "";
|
||||
|
||||
const handleModelChange = useCallback((value: string) => {
|
||||
// value is "provider/modelId" or "" for default
|
||||
setRuntimeConfig(c => ({ ...c, model: value }));
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter(p => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
setFavoriteProviders(newFavorites);
|
||||
}, [favoriteProviders]);
|
||||
|
||||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||||
const currentFavorites = favoriteModels;
|
||||
const isFavorite = currentFavorites.includes(modelId);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter(m => m !== modelId)
|
||||
: [modelId, ...currentFavorites];
|
||||
setFavoriteModels(newFavorites);
|
||||
}, [favoriteModels]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -156,16 +207,23 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-model">Model ID</label>
|
||||
<input
|
||||
id="agent-model"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. claude-sonnet-4-5"
|
||||
value={runtimeConfig.model}
|
||||
onChange={e => setRuntimeConfig(c => ({ ...c, model: e.target.value }))}
|
||||
style={{ width: "100%", boxSizing: "border-box" }}
|
||||
/>
|
||||
<label>Model</label>
|
||||
{modelsLoading ? (
|
||||
<div style={{ color: "var(--text-muted)", fontSize: 13, padding: "8px 0" }}>Loading models…</div>
|
||||
) : (
|
||||
<CustomModelDropdown
|
||||
id="agent-model"
|
||||
label="Model"
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
models={availableModels}
|
||||
placeholder="Select a model…"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-thinking">Thinking Level</label>
|
||||
@@ -221,7 +279,23 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Model</span>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 13 }}>{runtimeConfig.model || <em style={{ color: "var(--text-muted)" }}>default</em>}</span>
|
||||
<span style={{ fontSize: 13 }}>
|
||||
{selectedModel ? (
|
||||
<>
|
||||
<ProviderIcon provider={selectedModel.split("/")[0]} size="sm" />
|
||||
{" "}
|
||||
{(() => {
|
||||
const slashIdx = selectedModel.indexOf("/");
|
||||
const provider = selectedModel.slice(0, slashIdx);
|
||||
const modelId = selectedModel.slice(slashIdx + 1);
|
||||
const model = availableModels.find(m => m.provider === provider && m.id === modelId);
|
||||
return model?.name || selectedModel;
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
<em style={{ color: "var(--text-muted)" }}>default</em>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Thinking</span>
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NewAgentDialog } from "../NewAgentDialog";
|
||||
import * as apiModule from "../../api";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
createAgent: vi.fn(),
|
||||
fetchModels: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock CustomModelDropdown to simplify interaction testing
|
||||
vi.mock("../CustomModelDropdown", () => ({
|
||||
CustomModelDropdown: ({ value, onChange, label }: { value: string; onChange: (v: string) => void; label: string }) => (
|
||||
<div data-testid="custom-model-dropdown">
|
||||
<span data-testid="dropdown-label">{label}</span>
|
||||
<span data-testid="dropdown-value">{value}</span>
|
||||
<button
|
||||
data-testid="dropdown-select-anthropic"
|
||||
onClick={() => onChange("anthropic/claude-sonnet-4-5")}
|
||||
>
|
||||
Select Claude
|
||||
</button>
|
||||
<button
|
||||
data-testid="dropdown-select-default"
|
||||
onClick={() => onChange("")}
|
||||
>
|
||||
Use default
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock ProviderIcon
|
||||
vi.mock("../ProviderIcon", () => ({
|
||||
ProviderIcon: ({ provider }: { provider: string }) => (
|
||||
<span data-testid={`provider-icon-${provider}`} />
|
||||
),
|
||||
}));
|
||||
|
||||
const mockCreateAgent = vi.mocked(apiModule.createAgent);
|
||||
const mockFetchModels = vi.mocked(apiModule.fetchModels);
|
||||
|
||||
const MOCK_MODELS_RESPONSE = {
|
||||
models: [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
],
|
||||
favoriteProviders: ["anthropic"],
|
||||
favoriteModels: ["anthropic/claude-sonnet-4-5"],
|
||||
};
|
||||
|
||||
describe("NewAgentDialog", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnCreated = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchModels.mockResolvedValue(MOCK_MODELS_RESPONSE);
|
||||
mockCreateAgent.mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
describe("modal visibility", () => {
|
||||
it("renders nothing when isOpen is false", () => {
|
||||
const { container } = render(
|
||||
<NewAgentDialog isOpen={false} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders the dialog when isOpen is true", () => {
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
expect(screen.getByRole("dialog", { name: "Create new agent" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("model dropdown", () => {
|
||||
it("fetches models on mount", () => {
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
expect(mockFetchModels).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows loading state then model dropdown on step 1", async () => {
|
||||
// Create a slow promise to see loading state
|
||||
let resolveModels: (v: any) => void;
|
||||
mockFetchModels.mockReturnValue(new Promise(r => { resolveModels = r; }));
|
||||
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
// Navigate to step 1 (model config step) by filling name and clicking Next
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
await fireEvent.change(nameInput, { target: { value: "Test Agent" } });
|
||||
await fireEvent.click(screen.getByText("Next"));
|
||||
|
||||
// Should show loading
|
||||
expect(screen.getByText("Loading models…")).toBeTruthy();
|
||||
|
||||
// Resolve models
|
||||
resolveModels!(MOCK_MODELS_RESPONSE);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("custom-model-dropdown")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows model dropdown on step 1 after models load", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
// Wait for models to load
|
||||
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"));
|
||||
|
||||
expect(screen.getByTestId("custom-model-dropdown")).toBeTruthy();
|
||||
expect(screen.getByTestId("dropdown-label").textContent).toBe("Model");
|
||||
expect(screen.getByTestId("dropdown-value").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("selecting a model from dropdown updates state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
// Wait for models to load
|
||||
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"));
|
||||
|
||||
// Select a model from the mocked dropdown
|
||||
await user.click(screen.getByTestId("dropdown-select-anthropic"));
|
||||
|
||||
expect(screen.getByTestId("dropdown-value").textContent).toBe("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("deselecting model sets value back to default", 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"));
|
||||
|
||||
// Select a model
|
||||
await user.click(screen.getByTestId("dropdown-select-anthropic"));
|
||||
expect(screen.getByTestId("dropdown-value").textContent).toBe("anthropic/claude-sonnet-4-5");
|
||||
|
||||
// Deselect (use default)
|
||||
await user.click(screen.getByTestId("dropdown-select-default"));
|
||||
expect(screen.getByTestId("dropdown-value").textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("summary display", () => {
|
||||
it("shows 'default' in summary when no model selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Navigate to step 2
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
await user.type(nameInput, "Test Agent");
|
||||
await user.click(screen.getByText("Next"));
|
||||
await user.click(screen.getByText("Next"));
|
||||
|
||||
// Summary should show "default" for model
|
||||
const modelRow = screen.getByText("Model").closest(".agent-dialog-summary-row");
|
||||
expect(modelRow).toBeTruthy();
|
||||
expect(modelRow!.querySelector("em")?.textContent).toBe("default");
|
||||
});
|
||||
|
||||
it("shows model name and provider icon in summary when model 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"));
|
||||
|
||||
// Select a model
|
||||
await user.click(screen.getByTestId("dropdown-select-anthropic"));
|
||||
|
||||
// Navigate to step 2
|
||||
await user.click(screen.getByText("Next"));
|
||||
|
||||
// Summary should show model name
|
||||
expect(screen.getByTestId("provider-icon-anthropic")).toBeTruthy();
|
||||
expect(screen.getByText("Claude Sonnet 4.5")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent creation", () => {
|
||||
it("creates agent with selected model", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Step 0: Fill name
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
await user.type(nameInput, "Test Agent");
|
||||
|
||||
// Step 1: Navigate and select model
|
||||
await user.click(screen.getByText("Next"));
|
||||
await user.click(screen.getByTestId("dropdown-select-anthropic"));
|
||||
|
||||
// Step 2: Navigate to summary 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.name).toBe("Test Agent");
|
||||
expect(createCall.runtimeConfig).toEqual({
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates agent without model when default selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Step 0: Fill name
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
await user.type(nameInput, "Test Agent");
|
||||
|
||||
// Step 1: Leave model as default
|
||||
await user.click(screen.getByText("Next"));
|
||||
|
||||
// Step 2: Navigate 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.name).toBe("Test Agent");
|
||||
// No runtimeConfig when all values are defaults
|
||||
expect(createCall.runtimeConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates agent with model and thinking level", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Step 0: Fill name
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
await user.type(nameInput, "Thinking Agent");
|
||||
|
||||
// Step 1: Select model and thinking level
|
||||
await user.click(screen.getByText("Next"));
|
||||
await user.click(screen.getByTestId("dropdown-select-anthropic"));
|
||||
|
||||
const thinkingSelect = screen.getByLabelText(/Thinking Level/);
|
||||
await user.selectOptions(thinkingSelect, "high");
|
||||
|
||||
// Step 2: 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.runtimeConfig).toEqual({
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("handles fetchModels failure gracefully", async () => {
|
||||
mockFetchModels.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Navigate to step 1 — should still show the dropdown (empty models)
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
await user.type(nameInput, "Test Agent");
|
||||
await user.click(screen.getByText("Next"));
|
||||
|
||||
// Dropdown should still render (just with empty models)
|
||||
expect(screen.getByTestId("custom-model-dropdown")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("close and reset", () => {
|
||||
it("resets state on close", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
|
||||
|
||||
// Fill in name
|
||||
const nameInput = screen.getByLabelText(/Name/);
|
||||
await user.type(nameInput, "Agent Name");
|
||||
|
||||
// Navigate to step 1 and select model
|
||||
await user.click(screen.getByText("Next"));
|
||||
await user.click(screen.getByTestId("dropdown-select-anthropic"));
|
||||
|
||||
// Close the dialog
|
||||
await user.click(screen.getByLabelText("Close"));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
|
||||
// Unmount and reopen - state should be reset
|
||||
unmount();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
// Name should be empty
|
||||
const newNameInput = screen.getByLabelText(/Name/) as HTMLInputElement;
|
||||
expect(newNameInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user