diff --git a/apps/web/src/pages/admin/capabilities/MarketplaceCapabilityDetail.tsx b/apps/web/src/pages/admin/capabilities/MarketplaceCapabilityDetail.tsx index 296a91d..0bd068a 100644 --- a/apps/web/src/pages/admin/capabilities/MarketplaceCapabilityDetail.tsx +++ b/apps/web/src/pages/admin/capabilities/MarketplaceCapabilityDetail.tsx @@ -14,7 +14,7 @@ import { requiredCredentialsLabel } from "../capability-ui" import type { Capability } from "../../../lib/api-types" import { UninstallMarketplaceDialog } from "./UninstallMarketplaceDialog" -export function MarketplaceCapabilityDetail({ id }: { id: string }) { +export function MarketplaceCapabilityDetail({ id, onBack }: { id: string; onBack: () => void }) { const { t, i18n } = useTranslation("admin") const workspaceID = useWorkspaceId() const installsQ = useTargetMarketplaceInstalls(workspaceID) @@ -31,7 +31,7 @@ export function MarketplaceCapabilityDetail({ id }: { id: string }) { } if (!capability) { - return navigateAdmin("capabilities")}>{t("capabilities.detail.backToList")}} /> + return {t("capabilities.detail.backToList")}} /> } const source = marketplaceSourceName(capability) @@ -41,7 +41,7 @@ export function MarketplaceCapabilityDetail({ id }: { id: string }) {
- +

{capability.name}

@@ -105,7 +105,7 @@ export function MarketplaceCapabilityDetail({ id }: { id: string }) { setUninstallOpen(open) if (!open) uninstallMut.reset() }} - onConfirm={() => uninstallMut.mutate(capability.id, { onSuccess: () => navigateAdmin("capabilities") })} + onConfirm={() => uninstallMut.mutate(capability.id, { onSuccess: onBack })} />
) diff --git a/apps/web/src/pages/admin/capabilities/index.tsx b/apps/web/src/pages/admin/capabilities/index.tsx index 07884cc..6400e7a 100644 --- a/apps/web/src/pages/admin/capabilities/index.tsx +++ b/apps/web/src/pages/admin/capabilities/index.tsx @@ -90,9 +90,13 @@ type PageTab = "workspace" | "marketplace" export function CapabilitiesPage() { const { t, i18n } = useTranslation("admin") const wid = useWorkspaceId() - const { navigate } = useAdminView() + const { navigate, tab: routeTab } = useAdminView() + const itemParam = useUrlParam("item") + const marketplaceParam = useUrlParam("marketplace") + const routedTypeFilter = marketplaceTypeFromRoute(marketplaceParam, itemParam) const [query, setQuery] = useState("") - const [typeFilter, setTypeFilter] = useState("mcp") + const [localTypeFilter, setLocalTypeFilter] = useState("mcp") + const typeFilter = routedTypeFilter ?? localTypeFilter const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) const debouncedQuery = useDebouncedValue(query, 250) @@ -135,13 +139,19 @@ export function CapabilitiesPage() { })), }) - const routeTab = useAdminView().tab - const itemParam = useUrlParam("item") // Tab is URL-driven; default lands on workspace. Marketplace tab also // owns the selected-detail state via the `item` URL param. const pageTab: PageTab = routeTab === "marketplace" || itemParam ? "marketplace" : "workspace" const setPageTab = (next: PageTab) => { - navigate("capabilities", { tab: next === "marketplace" ? "marketplace" : null, item: null }) + navigate("capabilities", { tab: next === "marketplace" ? "marketplace" : null, marketplace: typeFilter, item: null }) + } + const setCapabilityTypeFilter = (next: CapabilityTypeFilter) => { + setLocalTypeFilter(next) + navigate("capabilities", { + tab: pageTab === "marketplace" ? "marketplace" : null, + marketplace: next, + item: null, + }) } const marketplaceItem = pageTab === "marketplace" ? itemParam : null const goToAgentsForCapability = (capability: MarketplaceCapability) => { @@ -264,7 +274,7 @@ export function CapabilitiesPage() { query={query} onQueryChange={setQuery} typeFilter={typeFilter} - onTypeFilterChange={setTypeFilter} + onTypeFilterChange={setCapabilityTypeFilter} /> )} @@ -282,10 +292,19 @@ export function CapabilitiesPage() { typeFilter={typeFilter} canImport={canImportDirectory} canManage={isAdmin} - onSelectItem={(item) => navigate("capabilities", { tab: "marketplace", item })} + onSelectItem={(item) => navigate("capabilities", { + tab: "marketplace", + marketplace: marketplaceTypeFromRoute(null, item) ?? typeFilter, + item, + })} onInstall={goToAgentsForCapability} onDelete={setDeleteTarget} - onViewCapability={(capabilityID) => navigate("capabilities", { id: capabilityID, tab: null, item: null })} + onViewCapability={(capabilityID) => navigate("capabilities", { + id: capabilityID, + tab: "marketplace", + marketplace: typeFilter, + item: null, + })} /> ) : err ? ( } + action={} /> ) @@ -892,7 +937,7 @@ export function CapabilityDetailPage({ id }: { id: string }) { return ( navigateAdmin("capabilities")} className="inline-flex items-center gap-1 hover:text-fg hover:underline">{t("capabilities.detail.backToList")}} + backLink={} title={{capability.name}} description={capability.description || t("capabilities.detail.noDescription")} action={ diff --git a/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectory.tsx b/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectory.tsx index fdb5f9d..7c28ff6 100644 --- a/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectory.tsx +++ b/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectory.tsx @@ -67,15 +67,20 @@ export function SkillDirectory({ const publishedSkills = useMemo(() => { if (category || verifiedOnly) return [] const installedIDs = new Set(items.flatMap((item) => item.installed_capability_id ? [item.installed_capability_id] : [])) + const directoryNames = new Set(items.map((item) => normalizeSkillName(item.name))) const needle = query.trim().toLocaleLowerCase() return (marketplaceQ.data ?? []) .filter((item) => { if (item.type !== "skill" || installedIDs.has(item.id)) return false + // Older self-published skills may not have catalog metadata, so their + // capability ID cannot be used to deduplicate them with a catalog item. + // Keep skills from other workspaces visible even when their names match. + if ((item.self_published || item.source_workspace_id === workspaceID) && directoryNames.has(normalizeSkillName(item.name))) return false if (!needle) return true return [item.name, item.description ?? "", marketplaceSourceName(item)].join(" ").toLocaleLowerCase().includes(needle) }) .sort((left, right) => left.name.localeCompare(right.name)) - }, [category, items, marketplaceQ.data, query, verifiedOnly]) + }, [category, items, marketplaceQ.data, query, verifiedOnly, workspaceID]) const cards = useMemo(() => [ ...filtered.map((item) => ({ kind: "directory" as const, item })), ...publishedSkills.map((item) => ({ kind: "marketplace" as const, item })), @@ -198,6 +203,10 @@ function filterItems(items: SkillDirectoryItem[], query: string, category: strin }) } +function normalizeSkillName(name: string): string { + return name.trim().toLocaleLowerCase().replace(/[\s_-]+/g, " ") +} + function SuccessBanner({ success, onViewCapability }: { success: { name: string; capabilityID: string }; onViewCapability: (capabilityID: string) => void }) { const { t } = useTranslation("admin") return

{t("capabilities.skillDirectory.import.success", { name: success.name })}

diff --git a/tests/e2e/mcp-directory.spec.ts b/tests/e2e/mcp-directory.spec.ts index d734884..ebd7eeb 100644 --- a/tests/e2e/mcp-directory.spec.ts +++ b/tests/e2e/mcp-directory.spec.ts @@ -2,6 +2,8 @@ import { expect, test, type Page, type Route } from "@playwright/test"; const WORKSPACE_ID = "00000000-0000-0000-0000-000000000011"; const CAPABILITY_ID = "00000000-0000-0000-0000-000000000033"; +const WORKSPACE_SKILL_ID = "00000000-0000-0000-0000-000000000077"; +const WORKSPACE_SKILL_VERSION_ID = "00000000-0000-0000-0000-000000000078"; const directoryItems = [ connector("context7", "Context7", "Documentation", 1), @@ -9,6 +11,39 @@ const directoryItems = [ connector("firecrawl", "Firecrawl", "Web", 3), ]; +const skillDirectoryItems = [ + { + ...skill("frontend-design", "Frontend Design", 1), + installed: true, + installed_capability_id: WORKSPACE_SKILL_ID, + }, +]; + +const workspaceSkill = { + id: WORKSPACE_SKILL_ID, + workspace_id: WORKSPACE_ID, + type: "skill", + name: "Frontend Design", + description: "Workspace Frontend Design skill.", + scope: "private", + status: "active", + required_credentials: [], + creator_id: "user-1", + created_at: "2026-07-23T00:00:00Z", + updated_at: "2026-07-23T00:00:00Z", +}; + +const workspaceSkillVersion = { + id: WORKSPACE_SKILL_VERSION_ID, + capability_id: WORKSPACE_SKILL_ID, + version: "1.0.0", + git_repo_url: "https://github.com/anthropics/skills", + git_ref: "main", + path: "skills/frontend-design", + creator_id: "user-1", + created_at: "2026-07-23T00:00:00Z", +}; + test("browses and imports a hosted MCP connector", async ({ page }) => { await mockApp(page); await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`); @@ -69,6 +104,43 @@ test("retries a failed connector directory request", async ({ page }) => { await expect(page.getByTestId("mcp-directory-card")).toHaveCount(3); }); +test("keeps the skill marketplace selected and hides a duplicate self-published skill", async ({ page }) => { + await mockApp(page); + await page.goto(`/?admin=capabilities&tab=marketplace&marketplace=skill&ws=${WORKSPACE_ID}`); + + await expect(page.getByTestId("skill-directory")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Frontend Design", exact: true })).toHaveCount(1); + await expect(page.getByTestId("marketplace-skill-card")).toHaveCount(1); + + await page.getByRole("heading", { name: "Frontend Design", exact: true }).click(); + await expect(page.getByTestId("skill-directory-detail")).toBeVisible(); + await expect(page).toHaveURL(/marketplace=skill/); + + await page.getByRole("button", { name: "Back to Skills", exact: true }).click(); + await expect(page.getByTestId("skill-directory")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Connectors", exact: true })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Frontend Design", exact: true })).toHaveCount(1); + + await page.getByRole("button", { name: "Installed", exact: true }).click(); + await expect(page.getByRole("heading", { name: /Frontend Design/ })).toBeVisible(); + await expect.poll(() => new URL(page.url()).searchParams.get("tab")).toBe("marketplace"); + await page.locator("#main-content").getByRole("button", { name: "Capabilities", exact: true }).click(); + await expect(page.getByTestId("skill-directory")).toBeVisible(); + + await page.getByRole("tab", { name: "Workspace", exact: true }).click(); + await expect(page.getByRole("button", { name: /Frontend Design Workspace/ })).toBeVisible(); + await page.getByRole("button", { name: /Frontend Design Workspace/ }).click(); + await expect.poll(() => new URL(page.url()).searchParams.get("tab")).toBe("workspace"); + await page.locator("#main-content").getByRole("button", { name: "Capabilities", exact: true }).click(); + await expect(page.getByTestId("skill-directory")).toHaveCount(0); + await expect(page.getByRole("button", { name: /Frontend Design Workspace/ })).toBeVisible(); + + await page.getByRole("tab", { name: "MCP", exact: true }).click(); + await expect.poll(() => new URL(page.url()).searchParams.get("marketplace")).toBe("mcp"); + await page.getByRole("tab", { name: "Marketplace", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Connectors", exact: true })).toBeVisible(); +}); + function connector(id: string, name: string, category: string, featuredRank: number) { return { id, @@ -86,13 +158,35 @@ function connector(id: string, name: string, category: string, featuredRank: num }; } +function skill(id: string, name: string, featuredRank: number) { + return { + id, + name, + description: `${name} catalog skill.`, + publisher: { name: "Anthropic", url: "https://www.anthropic.com" }, + repository_url: "https://github.com/anthropics/skills", + verified: true, + categories: ["Developer Tools"], + featured_rank: featuredRank, + version: "1.0.0", + license: "Apache-2.0", + slug: id, + title: name, + instruction: "Use this skill when the user asks for a design task.", + files: [{ path: "SKILL.md", content: "# Frontend Design", kind: "markdown" }], + installed: false, + installed_capability_id: null, + }; +} + async function mockApp( page: Page, directoryOverride?: (route: Route) => Promise, ) { await page.route("**/api/v1/**", async (route) => { const request = route.request(); - const path = new URL(request.url()).pathname; + const requestURL = new URL(request.url()); + const path = requestURL.pathname; if (path === "/api/v1/me") return json(route, { @@ -119,7 +213,13 @@ async function mockApp( if (path === `/api/v1/workspaces/${WORKSPACE_ID}/agents`) return json(route, { agents: [] }); if (path === `/api/v1/workspaces/${WORKSPACE_ID}/capabilities`) - return json(route, { capabilities: [], marketplace_installs: [], total: 0 }); + return requestURL.searchParams.get("type") === "mcp" + ? json(route, { capabilities: [], marketplace_installs: [], total: 0 }) + : json(route, { capabilities: [workspaceSkill], marketplace_installs: [], total: 1 }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/capabilities/${WORKSPACE_SKILL_ID}`) + return json(route, workspaceSkill); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/capabilities/${WORKSPACE_SKILL_ID}/versions`) + return json(route, { capability_id: WORKSPACE_SKILL_ID, versions: [workspaceSkillVersion] }); if (path === `/api/v1/workspaces/${WORKSPACE_ID}/capabilities/marketplace-installs`) return json(route, { capabilities: [] }); if (path === "/api/v1/capabilities/marketplace") @@ -138,6 +238,20 @@ async function mockApp( installed: false, self_published: false, }, + { + id: "00000000-0000-0000-0000-000000000066", + type: "skill", + name: "Frontend Design", + description: "A legacy workspace-published copy.", + visibility: "public", + status: "active", + required_credentials: [], + latest_version: "1.0.0", + source_workspace_id: WORKSPACE_ID, + source_workspace_name: "Directory Test", + installed: false, + self_published: true, + }, { id: "00000000-0000-0000-0000-000000000055", type: "mcp", @@ -153,6 +267,10 @@ async function mockApp( }, ], }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/skill-directory`) + return json(route, { items: skillDirectoryItems }); + if (path === `/api/v1/workspaces/${WORKSPACE_ID}/skill-directory/frontend-design`) + return json(route, skillDirectoryItems[0]); if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory`) { if (directoryOverride && (await directoryOverride(route))) return; return json(route, { items: directoryItems });