From 84145228bd9890f2e95ea79ccf46431c659e6020 Mon Sep 17 00:00:00 2001 From: kapelame <168134658+kapelame@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:32:27 +0800 Subject: [PATCH 1/2] fix: correct skill marketplace navigation --- .../src/pages/admin/capabilities/index.tsx | 33 +++++++--- .../skill-directory/SkillDirectory.tsx | 11 +++- tests/e2e/mcp-directory.spec.ts | 61 +++++++++++++++++++ 3 files changed, 97 insertions(+), 8 deletions(-) diff --git a/apps/web/src/pages/admin/capabilities/index.tsx b/apps/web/src/pages/admin/capabilities/index.tsx index 07884cc..6cee1e2 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,17 @@ 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) + if (pageTab === "marketplace") { + navigate("capabilities", { tab: "marketplace", marketplace: next, item: null }) + } } const marketplaceItem = pageTab === "marketplace" ? itemParam : null const goToAgentsForCapability = (capability: MarketplaceCapability) => { @@ -264,7 +272,7 @@ export function CapabilitiesPage() { query={query} onQueryChange={setQuery} typeFilter={typeFilter} - onTypeFilterChange={setTypeFilter} + onTypeFilterChange={setCapabilityTypeFilter} /> )} @@ -282,7 +290,11 @@ 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 })} @@ -581,6 +593,13 @@ function useUrlParam(name: string): string | null { return value } +function marketplaceTypeFromRoute(marketplace: string | null, item: string | null): CapabilityTypeFilter | null { + if (item?.startsWith("skill:")) return "skill" + if (item?.startsWith("mcp:")) return "mcp" + if (marketplace === "skill" || marketplace === "mcp") return marketplace + return null +} + const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const function CapabilitiesPagination({ 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..1e18424 100644 --- a/tests/e2e/mcp-directory.spec.ts +++ b/tests/e2e/mcp-directory.spec.ts @@ -9,6 +9,10 @@ const directoryItems = [ connector("firecrawl", "Firecrawl", "Web", 3), ]; +const skillDirectoryItems = [ + skill("frontend-design", "Frontend Design", 1), +]; + 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 +73,24 @@ 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); +}); + function connector(id: string, name: string, category: string, featuredRank: number) { return { id, @@ -86,6 +108,27 @@ 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, @@ -138,6 +181,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 +210,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 }); From 7a5b7db94e00f2b277939935acd4aa7b4471092d Mon Sep 17 00:00:00 2001 From: kapelame <168134658+kapelame@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:10:11 +0800 Subject: [PATCH 2/2] fix: restore capability list context --- .../MarketplaceCapabilityDetail.tsx | 8 +-- .../src/pages/admin/capabilities/index.tsx | 46 +++++++++++--- tests/e2e/mcp-directory.spec.ts | 63 ++++++++++++++++++- 3 files changed, 100 insertions(+), 17 deletions(-) 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 6cee1e2..6400e7a 100644 --- a/apps/web/src/pages/admin/capabilities/index.tsx +++ b/apps/web/src/pages/admin/capabilities/index.tsx @@ -147,9 +147,11 @@ export function CapabilitiesPage() { } const setCapabilityTypeFilter = (next: CapabilityTypeFilter) => { setLocalTypeFilter(next) - if (pageTab === "marketplace") { - navigate("capabilities", { tab: "marketplace", marketplace: next, item: null }) - } + navigate("capabilities", { + tab: pageTab === "marketplace" ? "marketplace" : null, + marketplace: next, + item: null, + }) } const marketplaceItem = pageTab === "marketplace" ? itemParam : null const goToAgentsForCapability = (capability: MarketplaceCapability) => { @@ -297,7 +299,12 @@ export function CapabilitiesPage() { })} 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={} /> ) @@ -911,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/tests/e2e/mcp-directory.spec.ts b/tests/e2e/mcp-directory.spec.ts index 1e18424..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), @@ -10,9 +12,38 @@ const directoryItems = [ ]; const skillDirectoryItems = [ - skill("frontend-design", "Frontend Design", 1), + { + ...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}`); @@ -89,6 +120,25 @@ test("keeps the skill marketplace selected and hides a duplicate self-published 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) { @@ -135,7 +185,8 @@ async function mockApp( ) { 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, { @@ -162,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")