Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -31,7 +31,7 @@ export function MarketplaceCapabilityDetail({ id }: { id: string }) {
}

if (!capability) {
return <EmptyState icon={PackageCheck} title={t("capabilities.marketplaceDetail.notFound.title")} description={t("capabilities.marketplaceDetail.notFound.description")} action={<Button variant="outline" size="sm" onClick={() => navigateAdmin("capabilities")}>{t("capabilities.detail.backToList")}</Button>} />
return <EmptyState icon={PackageCheck} title={t("capabilities.marketplaceDetail.notFound.title")} description={t("capabilities.marketplaceDetail.notFound.description")} action={<Button variant="outline" size="sm" onClick={onBack}>{t("capabilities.detail.backToList")}</Button>} />
}

const source = marketplaceSourceName(capability)
Expand All @@ -41,7 +41,7 @@ export function MarketplaceCapabilityDetail({ id }: { id: string }) {
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<button onClick={() => navigateAdmin("capabilities")} className="inline-flex items-center gap-1 text-sm text-fg-subtle hover:text-fg hover:underline"><ArrowLeft className="h-3 w-3" />{t("capabilities.detail.backToList")}</button>
<button onClick={onBack} className="inline-flex items-center gap-1 text-sm text-fg-subtle hover:text-fg hover:underline"><ArrowLeft className="h-3 w-3" />{t("capabilities.detail.backToList")}</button>
<div className="mt-2 flex flex-wrap items-center gap-2">
<h2 className="text-2xl font-semibold tracking-display text-fg">{capability.name}</h2>
<CapabilityTypeBadge type={capability.type} />
Expand Down Expand Up @@ -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 })}
/>
</div>
)
Expand Down
73 changes: 59 additions & 14 deletions apps/web/src/pages/admin/capabilities/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CapabilityTypeFilter>("mcp")
const [localTypeFilter, setLocalTypeFilter] = useState<CapabilityTypeFilter>("mcp")
const typeFilter = routedTypeFilter ?? localTypeFilter
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const debouncedQuery = useDebouncedValue(query, 250)
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -264,7 +274,7 @@ export function CapabilitiesPage() {
query={query}
onQueryChange={setQuery}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
onTypeFilterChange={setCapabilityTypeFilter}
/>
)}

Expand All @@ -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 ? (
<ErrorState
Expand Down Expand Up @@ -355,7 +374,12 @@ export function CapabilitiesPage() {
<TableCell className="max-w-[420px]">
<button
type="button"
onClick={() => navigate("capabilities", { id: cap.id, from: fromMarketplace ? "marketplace" : null })}
onClick={() => navigate("capabilities", {
id: cap.id,
tab: "workspace",
marketplace: typeFilter,
from: fromMarketplace ? "marketplace" : null,
})}
className="flex w-full flex-col items-start text-left transition-colors hover:text-fg"
>
<span className="flex flex-wrap items-center gap-2 text-base font-medium text-fg hover:underline">
Expand Down Expand Up @@ -397,7 +421,12 @@ export function CapabilitiesPage() {
marketPending={marketPendingID === cap.id}
uninstallPending={uninstallPendingID === cap.id}
deletePending={deletePendingID === cap.id}
onView={() => navigate("capabilities", { id: cap.id, from: fromMarketplace ? "marketplace" : null })}
onView={() => navigate("capabilities", {
id: cap.id,
tab: "workspace",
marketplace: typeFilter,
from: fromMarketplace ? "marketplace" : null,
})}
onAddVersion={() => setAddVersionCapability(cap)}
onMarketAction={(action) => requestMarketAction(action, cap)}
onUninstall={() => setUninstallTarget(marketCap)}
Expand Down Expand Up @@ -581,6 +610,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({
Expand Down Expand Up @@ -833,10 +869,19 @@ export function CapabilityDetailPage({ id }: { id: string }) {
const installationSummary = useCapabilityEnabledAgents(wid, agentsQ.data?.agents ?? [], capability, versionsQ.data?.versions ?? [])
const enabledCount = installationSummary.installations.length

const fromMarketplace = new URLSearchParams(window.location.search).get("from") === "marketplace"
const routeParams = new URLSearchParams(window.location.search)
const fromMarketplace = routeParams.get("from") === "marketplace"
const returnTab: PageTab = routeParams.get("tab") === "marketplace" ? "marketplace" : "workspace"
const returnType = marketplaceTypeFromRoute(routeParams.get("marketplace"), null)
const backToList = () => navigateAdmin("capabilities", {
tab: returnTab === "marketplace" ? "marketplace" : null,
marketplace: returnType,
item: null,
from: null,
})

if (fromMarketplace) {
return <AdminLayout activeMenu="capabilities"><MarketplaceCapabilityDetail id={id} /></AdminLayout>
return <AdminLayout activeMenu="capabilities"><MarketplaceCapabilityDetail id={id} onBack={backToList} /></AdminLayout>
}

if (capQ.isLoading) {
Expand All @@ -850,7 +895,7 @@ export function CapabilityDetailPage({ id }: { id: string }) {
icon={Wrench}
title={t("capabilities.detail.notFound.title")}
description={capQ.error instanceof Error ? capQ.error.message : t("capabilities.detail.notFound.description")}
action={<Button size="sm" variant="outline" onClick={() => navigateAdmin("capabilities")}>{t("capabilities.detail.backToList")}</Button>}
action={<Button size="sm" variant="outline" onClick={backToList}>{t("capabilities.detail.backToList")}</Button>}
/>
</AdminLayout>
)
Expand Down Expand Up @@ -892,7 +937,7 @@ export function CapabilityDetailPage({ id }: { id: string }) {
return (
<AdminLayout activeMenu="capabilities">
<PageHeader
backLink={<button onClick={() => navigateAdmin("capabilities")} className="inline-flex items-center gap-1 hover:text-fg hover:underline"><ArrowLeft className="h-3 w-3" />{t("capabilities.detail.backToList")}</button>}
backLink={<button onClick={backToList} className="inline-flex items-center gap-1 hover:text-fg hover:underline"><ArrowLeft className="h-3 w-3" />{t("capabilities.detail.backToList")}</button>}
title={<span className="inline-flex items-center gap-2">{capability.name}<CapabilityTypeBadge type={capability.type} /></span>}
description={capability.description || t("capabilities.detail.noDescription")}
action={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
Expand Down Expand Up @@ -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 <div className="mb-3 flex flex-wrap items-center gap-3 rounded-lg border border-line bg-surface px-4 py-3" role="status"><span className="flex h-7 w-7 items-center justify-center rounded-full bg-surface-muted text-fg"><Check className="h-4 w-4" /></span><p className="min-w-0 flex-1 text-sm text-fg">{t("capabilities.skillDirectory.import.success", { name: success.name })}</p><Button variant="outline" size="sm" onClick={() => onViewCapability(success.capabilityID)}>{t("capabilities.skillDirectory.actions.viewCapability")}</Button></div>
Expand Down
Loading