feat(FN-3512): add eval domain store with persistence schema, plugin dashbo
This merge adds three major features: a new **eval domain store** (`eval-store.ts`, `eval-types.ts`) for AI evaluation data with persistence schema and a `PluginDashboardViewHost` for extensibility; a **plugin dashboard view registry** with navigation integration that lets plugins register custom vi Fusion-Task-Id: FN-3512
This commit is contained in:
@@ -167,6 +167,10 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-detail-import-btn {
|
||||
min-height: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
|
||||
}
|
||||
|
||||
/* Legacy class for backward compatibility */
|
||||
.agent-detail-title {
|
||||
display: flex;
|
||||
@@ -1498,6 +1502,10 @@
|
||||
min-width: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
|
||||
}
|
||||
|
||||
.agent-detail-import-btn {
|
||||
min-width: calc(var(--space-xl) * 3 + var(--space-xs));
|
||||
}
|
||||
|
||||
/* Legacy selectors for backward compatibility */
|
||||
.agent-detail-title {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Settings, FileText, ActivitySquare, X, Copy,
|
||||
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
|
||||
AlertCircle,
|
||||
ChevronDown, ChevronRight, ChevronLeft, BarChart3, BookOpen, Eye, FileEdit
|
||||
ChevronDown, ChevronRight, ChevronLeft, BarChart3, BookOpen, Eye, FileEdit, Upload
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -25,6 +25,7 @@ import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { AgentImportModal } from "./AgentImportModal";
|
||||
|
||||
/**
|
||||
* Simple className utility - joins class names conditionally
|
||||
@@ -131,6 +132,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
const [agent, setAgent] = useState<AgentDetail | null>(null);
|
||||
const { confirm } = useConfirm();
|
||||
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab ?? "dashboard");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
@@ -613,6 +615,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
|
||||
{/* Utility actions: refresh + close */}
|
||||
<div className="agent-detail-utility-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--compact agent-detail-import-btn"
|
||||
onClick={() => setIsImportModalOpen(true)}
|
||||
aria-label="Import agents"
|
||||
>
|
||||
<Upload size={14} />
|
||||
Import
|
||||
</button>
|
||||
<button className="btn-icon" onClick={() => void loadAgent()} title="Refresh" aria-label="Refresh">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
@@ -757,6 +768,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AgentImportModal
|
||||
isOpen={isImportModalOpen}
|
||||
onClose={() => setIsImportModalOpen(false)}
|
||||
onImported={() => {
|
||||
void handleSavedMutation();
|
||||
}}
|
||||
projectId={projectId}
|
||||
initialInputMethod="browse"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AgentImportModalProps {
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
projectId?: string;
|
||||
initialInputMethod?: InputMethod;
|
||||
}
|
||||
|
||||
/** Parsed agent preview item for display before import */
|
||||
@@ -126,10 +127,10 @@ function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
|
||||
*
|
||||
* Flow: Input → Preview parsed agents → Import → Show results
|
||||
*/
|
||||
export function AgentImportModal({ isOpen, onClose, onImported, projectId }: AgentImportModalProps) {
|
||||
export function AgentImportModal({ isOpen, onClose, onImported, projectId, initialInputMethod = "paste" }: AgentImportModalProps) {
|
||||
useMobileScrollLock(isOpen);
|
||||
const [step, setStep] = useState<ModalStep>("input");
|
||||
const [inputMethod, setInputMethod] = useState<InputMethod>("paste");
|
||||
const [inputMethod, setInputMethod] = useState<InputMethod>(initialInputMethod);
|
||||
const [manifestContent, setManifestContent] = useState("");
|
||||
const [directoryAgents, setDirectoryAgents] = useState<DirectoryAgentInput[]>([]);
|
||||
const [companyName, setCompanyName] = useState("Unknown");
|
||||
@@ -207,7 +208,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStep("input");
|
||||
setInputMethod("paste");
|
||||
setInputMethod(initialInputMethod);
|
||||
setManifestContent("");
|
||||
setDirectoryAgents([]);
|
||||
setCompanyName("Unknown");
|
||||
@@ -226,7 +227,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
setIsLoadingCompanies(false);
|
||||
setCompaniesError(null);
|
||||
fetchAttemptedRef.current = false;
|
||||
}, []);
|
||||
}, [initialInputMethod]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
reset();
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock("../../api", () => ({
|
||||
fetchPluginRuntimes: vi.fn(),
|
||||
upgradeAgentHeartbeatProcedure: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
fetchCompanies: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentLogViewer", () => ({
|
||||
@@ -118,7 +119,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: mockConfirm }),
|
||||
}));
|
||||
|
||||
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure, updateGlobalSettings } from "../../api";
|
||||
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api";
|
||||
import { subscribeSse } from "../../sse-bus";
|
||||
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
@@ -146,6 +147,7 @@ const mockFetchPluginRuntimes = vi.mocked(fetchPluginRuntimes);
|
||||
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
|
||||
const mockUpgradeAgentHeartbeatProcedure = vi.mocked(upgradeAgentHeartbeatProcedure);
|
||||
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
|
||||
const mockFetchCompanies = vi.mocked(fetchCompanies);
|
||||
const mockSubscribeSse = vi.mocked(subscribeSse);
|
||||
|
||||
const MOCK_SKILLS = [
|
||||
@@ -249,6 +251,7 @@ describe("AgentDetailView", () => {
|
||||
procedureFileSeeded: true,
|
||||
});
|
||||
mockUpdateGlobalSettings.mockResolvedValue({} as any);
|
||||
mockFetchCompanies.mockResolvedValue({ companies: [] });
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
@@ -936,11 +939,32 @@ describe("AgentDetailView", () => {
|
||||
|
||||
const utilityContainer = headerActions?.querySelector(".agent-detail-utility-actions");
|
||||
expect(utilityContainer).toBeTruthy();
|
||||
expect(utilityContainer?.querySelector('[aria-label="Import agents"]')).toBeTruthy();
|
||||
expect(utilityContainer?.querySelector('[title="Refresh"]')).toBeTruthy();
|
||||
expect(utilityContainer?.querySelector('[title="Close"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the import modal from agent detail in browse mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchCompanies.mockResolvedValue({ companies: [{ slug: "acme", name: "Acme AI" }] });
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Import agents" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog", { name: "Import agents" })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Search companies...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mobile inline header controls on the same row as identity", () => {
|
||||
const stylesContent = loadAllAppCss();
|
||||
|
||||
|
||||
@@ -184,4 +184,11 @@ describe("AgentImportModal", () => {
|
||||
// The browse mode should render the search input (the fetch for companies is async)
|
||||
expect(screen.getByPlaceholderText("Search companies...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens directly in browse mode when initialInputMethod is browse", () => {
|
||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} initialInputMethod="browse" />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Search companies...")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Manifest content")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ vi.mock("../../api", () => ({
|
||||
fetchAgentBudgetStatus: vi.fn(),
|
||||
resetAgentBudget: vi.fn(),
|
||||
upgradeAgentHeartbeatProcedure: vi.fn(),
|
||||
fetchCompanies: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentLogViewer", () => ({
|
||||
@@ -80,6 +81,7 @@ const mockGenerateAgentSpec = vi.mocked(api.generateAgentSpec);
|
||||
const mockCancelAgentGeneration = vi.mocked(api.cancelAgentGeneration);
|
||||
const mockFetchAgentBudgetStatus = vi.mocked(api.fetchAgentBudgetStatus);
|
||||
const mockResetAgentBudget = vi.mocked(api.resetAgentBudget);
|
||||
const mockFetchCompanies = vi.mocked(api.fetchCompanies);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -172,6 +174,7 @@ describe("agent modal mobile CSS structure", () => {
|
||||
mockCancelAgentGeneration.mockResolvedValue({ success: true });
|
||||
mockFetchAgentBudgetStatus.mockResolvedValue({ agentId: "agent-001", currentUsage: 0, budgetLimit: null, usagePercent: null, thresholdPercent: null, isOverBudget: false, isOverThreshold: false, lastResetAt: null, nextResetAt: null });
|
||||
mockResetAgentBudget.mockResolvedValue(undefined);
|
||||
mockFetchCompanies.mockResolvedValue({ companies: [] });
|
||||
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
({
|
||||
@@ -273,6 +276,12 @@ describe("agent modal mobile CSS structure", () => {
|
||||
expect(document.querySelector(".agent-import-dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("supports browse-first launch mode", () => {
|
||||
render(<AgentImportModal isOpen={true} onClose={vi.fn()} onImported={vi.fn()} initialInputMethod="browse" />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Search companies...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("file upload area has targetable class", () => {
|
||||
render(<AgentImportModal isOpen={true} onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user