From ddf6e543658fcf6c14be2ea525dc93826a5e2573 Mon Sep 17 00:00:00 2001 From: Sam Xu Date: Mon, 1 Jun 2026 04:12:54 +0800 Subject: [PATCH] feat(v2): v2-native marketplace browse page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the legacy MUI AppsMarketplacePage on the /v2/marketplace mount with a fresh v2-native V2MarketplacePage (src/v2/marketplace/), matching the detail page's token discipline — one accent, borders not shadows, no gradients/transforms, 80-120ms transitions. Filter bar (search + kind + category + pod), Discover (listings grid + official integrations + MCP) and Installed tabs, card->detail + inline install/remove. Legacy AppsMarketplacePage stays on the v1 /apps mount unchanged. Reuses the shipped /api/marketplace/* + /api/registry/* endpoints and the ?installable detail/install wiring. Co-Authored-By: Claude Opus 4.8 --- frontend/src/v2/V2App.tsx | 4 +- .../src/v2/marketplace/V2MarketplacePage.css | 330 +++++++++++++ .../src/v2/marketplace/V2MarketplacePage.tsx | 450 ++++++++++++++++++ 3 files changed, 782 insertions(+), 2 deletions(-) create mode 100644 frontend/src/v2/marketplace/V2MarketplacePage.css create mode 100644 frontend/src/v2/marketplace/V2MarketplacePage.tsx diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index 24f10223..40312ef1 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -17,7 +17,7 @@ import Thread from '../components/Thread'; import UserProfile from '../components/UserProfile'; import Dashboard from '../components/Dashboard'; import DailyDigest from '../components/DailyDigest'; -import AppsMarketplacePage from '../components/apps/AppsMarketplacePage'; +import V2MarketplacePage from './marketplace/V2MarketplacePage'; import V2MarketplaceDetailPage from './marketplace/V2MarketplaceDetailPage'; import './marketplace/V2MarketplaceDetailPage.css'; import AgentsHub from '../components/agents/AgentsHub'; @@ -194,7 +194,7 @@ const V2App: React.FC = () => { /> )} + element={feature('Marketplace', 'Browse and install agents, apps, and integrations.', , false, false)} /> => ({ + 'x-auth-token': localStorage.getItem('token') || '', +}); + +const toMarketplaceApp = (item: any): App => { + const installableId = String(item?.installableId ?? item?._id ?? item?.id ?? ''); + const handle = installableId.replace(/^@/, ''); + const stats = item?.stats && typeof item.stats === 'object' ? item.stats : {}; + const marketplace = item?.marketplace && typeof item.marketplace === 'object' ? item.marketplace : {}; + const requires = Array.isArray(item?.requires) ? item.requires : []; + return { + ...item, + id: installableId, + installableId, + name: handle || String(item?.name || ''), + displayName: String(item?.name || installableId || 'Unknown'), + description: String(item?.description || ''), + kind: String(item?.kind || 'app'), + category: String(marketplace.category || 'other'), + verified: Boolean(marketplace.verified), + rating: Number(marketplace.rating || 0), + installs: Number(stats.totalInstalls || marketplace.installCount || 0), + logo: marketplace.logoUrl || marketplace.logo || null, + scopes: requires, + installBackend: 'registry', + }; +}; + +const toInstalledRegistryApp = (agent: any): App => { + const installableId = String(agent?.name || ''); + const profile = agent?.profile && typeof agent.profile === 'object' ? agent.profile : {}; + return { + ...agent, + id: installableId, + installableId, + name: installableId.replace(/^@/, ''), + displayName: String(agent?.displayName || installableId || 'Unknown'), + description: String(profile.purpose || ''), + kind: 'agent', + category: String(agent?.category || 'other'), + logo: agent?.iconUrl || null, + instanceId: String(agent?.instanceId || 'default'), + installBackend: 'registry', + }; +}; + +const toInstalledLegacyApp = (app: any): App => ({ + ...app, + id: String(app?.id || ''), + installBackend: 'apps', +}); + +const initial = (s?: string): string => (s || '?').trim().charAt(0).toUpperCase() || '?'; + +const V2MarketplacePage: React.FC = () => { + const navigate = useNavigate(); + const [apps, setApps] = useState([]); + const [official, setOfficial] = useState([]); + const [integrations, setIntegrations] = useState([]); + const [installed, setInstalled] = useState([]); + const [pods, setPods] = useState([]); + const [selectedPodId, setSelectedPodId] = useState(''); + const [tab, setTab] = useState<'discover' | 'installed'>('discover'); + const [search, setSearch] = useState(''); + const [kind, setKind] = useState('all'); + const [category, setCategory] = useState('all'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); + const [busyId, setBusyId] = useState(''); + + useEffect(() => { + (async () => { + try { + const res = await axios.get('/api/pods', { headers: authHeaders() }); + const list = (res.data as Pod[]) || []; + setPods(list); + setSelectedPodId((prev) => prev || list[0]?._id || ''); + } catch { + /* pods are optional for browsing */ + } + })(); + }, []); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (search) params.append('q', search); + if (category !== 'all') params.append('category', category); + if (kind !== 'all') params.append('kind', kind); + const res = await axios.get(`/api/marketplace/browse?${params.toString()}`); + if (cancelled) return; + setApps((((res.data as any)?.items) || []).map(toMarketplaceApp)); + } catch { + if (cancelled) return; + setError('Failed to load the marketplace.'); + setApps([]); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [search, category, kind]); + + useEffect(() => { + (async () => { + try { + const res = await axios.get('/api/marketplace/official'); + setOfficial((((res.data as any)?.entries) || [])); + } catch { + /* official listings are best-effort */ + } + if (!localStorage.getItem('token')) return; + try { + const res = await axios.get('/api/integrations/catalog', { headers: authHeaders() }); + setIntegrations((((res.data as any)?.entries) || [])); + } catch { + /* integration stats are best-effort */ + } + })(); + }, []); + + const fetchInstalled = useCallback(async () => { + if (!selectedPodId) return; + try { + const [legacy, registry] = await Promise.allSettled([ + axios.get(`/api/apps/pods/${selectedPodId}/apps`, { headers: authHeaders() }), + axios.get(`/api/registry/pods/${selectedPodId}/agents`, { headers: authHeaders() }), + ]); + const legacyApps = legacy.status === 'fulfilled' + ? ((((legacy.value.data as any).apps) || []).map(toInstalledLegacyApp)) : []; + const registryApps = registry.status === 'fulfilled' + ? ((((registry.value.data as any).agents) || []).map(toInstalledRegistryApp)) : []; + setInstalled([...legacyApps, ...registryApps]); + } catch { + /* leave installed list as-is on transient error */ + } + }, [selectedPodId]); + + useEffect(() => { fetchInstalled(); }, [fetchInstalled]); + + const isInstalled = (id: string): boolean => installed.some((a) => a.id === id); + + const integrationsById = useMemo(() => integrations.reduce((acc: Record, e: any) => { + acc[e.id] = e; return acc; + }, {}), [integrations]); + + const officialListings = useMemo(() => official.map((e: any) => ({ + ...e, + capabilities: integrationsById[e.id]?.catalog?.capabilities || e.capabilities || [], + activeCount: integrationsById[e.id]?.stats?.activeIntegrations, + })), [official, integrationsById]); + + const officialIntegrations = officialListings.filter((e: any) => e.type !== 'mcp-app'); + const mcpListings = officialListings.filter((e: any) => e.type === 'mcp-app'); + + const openDetail = (app: App) => { + const id = String(app.installableId || app.id || ''); + if (id) navigate(`/v2/marketplace/${encodeURIComponent(id)}`); + }; + + const install = async (app: App, e: React.MouseEvent) => { + e.stopPropagation(); + if (!selectedPodId) { setStatus('Pick a pod above to install into.'); return; } + setBusyId(app.id); + setStatus(null); + try { + await axios.post('/api/registry/install', { + agentName: String(app.installableId || app.id || ''), + podId: selectedPodId, + version: typeof app.version === 'string' ? app.version : undefined, + displayName: app.displayName || undefined, + scopes: Array.isArray(app.scopes) ? app.scopes : [], + }, { headers: authHeaders() }); + setStatus(`Installed ${app.displayName || app.name} into ${pods.find((p) => p._id === selectedPodId)?.name || 'pod'}.`); + fetchInstalled(); + } catch (err: any) { + setStatus(err?.response?.data?.error || 'Could not install — try again.'); + } finally { + setBusyId(''); + } + }; + + const remove = async (app: App, e: React.MouseEvent) => { + e.stopPropagation(); + if (!selectedPodId) return; + setBusyId(app.id); + try { + if (app.installBackend === 'registry') { + const id = encodeURIComponent(String(app.installableId || app.id || '')); + const params = new URLSearchParams(); + if (app.instanceId && app.instanceId !== 'default') params.append('instanceId', app.instanceId); + const suffix = params.toString() ? `?${params.toString()}` : ''; + await axios.delete(`/api/registry/agents/${id}/pods/${selectedPodId}${suffix}`, { headers: authHeaders() }); + } else { + await axios.delete(`/api/apps/pods/${selectedPodId}/apps/${app.installationId}`, { headers: authHeaders() }); + } + setStatus(`Removed ${app.displayName || app.name}.`); + fetchInstalled(); + } catch { + setStatus('Could not remove — try again.'); + } finally { + setBusyId(''); + } + }; + + const connect = (entry: any) => { + setStatus(`Open a pod to connect ${entry.name}.`); + navigate('/v2'); + }; + + const renderListingCard = (app: App) => { + const installedNow = isInstalled(app.id); + const busy = busyId === app.id; + return ( +
openDetail(app)} + onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openDetail(app); } }} + > +
+ {app.logo ? ( + + ) : ( +
{initial(app.displayName)}
+ )} +
+
+ {app.displayName} + {app.verified ? Verified : null} +
+ @{app.name} +
+
+

{app.description || 'No description provided.'}

+
+ {String(app.kind || 'app')} + {Number(app.installs || 0)} installs +
+
+ {installedNow ? ( + + ) : ( + + )} +
+
+ ); + }; + + const renderOfficialCard = (entry: any) => { + const isMcp = entry.type === 'mcp-app'; + const caps: string[] = Array.isArray(entry.capabilities) ? entry.capabilities : []; + return ( +
+
+ {entry.logoUrl ? ( + + ) : ( +
{initial(entry.name)}
+ )} +
+ {entry.name} + {isMcp ? 'MCP app' : entry.type || 'integration'}{entry.category ? ` · ${entry.category}` : ''} +
+
+

{entry.description || ''}

+ {caps.length > 0 && ( +
+ {caps.slice(0, 4).map((c) => {c})} +
+ )} +
+ + {entry.docsUrl && ( + Docs + )} +
+
+ ); + }; + + return ( +
+
+

Marketplace

+

Browse and install agents, apps, and integrations.

+
+ +
+ setSearch(e.target.value)} + /> + + + {pods.length > 0 && ( + + )} +
+ +
+ + +
+ + {status &&
{status}
} + {error &&
{error}
} + + {tab === 'discover' ? ( + <> +
+

{search || kind !== 'all' || category !== 'all' ? 'Results' : 'All listings'}

+ {loading ? ( +
+ {[0, 1, 2, 3].map((i) =>
)} +
+ ) : apps.length === 0 ? ( +
+
No listings yet
+
Nothing matches your filters. Try a broader search, or publish the first one.
+
+ ) : ( +
{apps.map(renderListingCard)}
+ )} +
+ + {officialIntegrations.length > 0 && ( +
+

Official integrations

+

Curated by Commonly — connect from a pod.

+
{officialIntegrations.map(renderOfficialCard)}
+
+ )} + + {mcpListings.length > 0 && ( +
+

MCP apps (preview)

+

Interactive UI rendered inside MCP-compatible hosts.

+
{mcpListings.map(renderOfficialCard)}
+
+ )} + + ) : ( +
+ {!selectedPodId ? ( +
Pick a pod to see what's installed.
+ ) : installed.length === 0 ? ( +
+
Nothing installed here yet
+
Browse Discover and install your first one.
+
+ ) : ( +
{installed.map(renderListingCard)}
+ )} +
+ )} +
+ ); +}; + +export default V2MarketplacePage;