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:
Fusion
2026-05-05 12:31:54 -07:00
committed by gsxdsm
parent 80cdb15b19
commit b41884e51d
8 changed files with 82 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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