From 1285ca911159a81ae54b664d8b9f5df5bde41ba4 Mon Sep 17 00:00:00 2001 From: Sam Xu Date: Sun, 31 May 2026 21:10:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2):=20wire=20marketplace=20discover=20?= =?UTF-8?q?=E2=86=92=20inspect=20=E2=86=92=20install=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marketplace had three disconnected pieces: a (legacy-MUI) browse page, a v2-native detail page (#439) nothing linked to, and a detail "Install" button that bounced to /v2/agents/browse?installable= — a param AgentsHub never read, so nothing installed. This connects them: - Browse cards (featured + All Apps) link to the v2 detail page in the v2 shell (gated on useV2Embedded; the detail route has no v1 equivalent). - AgentsHub consumes ?installable= and auto-opens its existing install dialog for the matching catalog agent (matches installableId/name/id; no-ops gracefully if the catalog doesn't carry it — no regression). Activates already-shipped backend (#215/#230) + detail page (#439) into a working funnel. Publish/fork UI intentionally deferred. Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/agents/AgentsHub.tsx | 24 +++++++++++++++++++ frontend/src/components/apps/AppCard.tsx | 11 ++++++++- .../apps/AppsMarketplacePage.test.tsx | 5 ++-- .../components/apps/AppsMarketplacePage.tsx | 11 +++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/agents/AgentsHub.tsx b/frontend/src/components/agents/AgentsHub.tsx index ccf1654e7..fefaff29b 100644 --- a/frontend/src/components/agents/AgentsHub.tsx +++ b/frontend/src/components/agents/AgentsHub.tsx @@ -173,6 +173,10 @@ const AgentsHub = ({ currentPodId: propPodId = null }) => { const params = new URLSearchParams(location.search); return params.get('tab') || ''; }, [location.search]); + const queryInstallable = useMemo(() => { + const params = new URLSearchParams(location.search); + return params.get('installable') || ''; + }, [location.search]); const [selectedPodId, setSelectedPodId] = useState(propPodId || queryPodId); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -258,6 +262,7 @@ const AgentsHub = ({ currentPodId: propPodId = null }) => { const runtimeLogsInputRef = useRef(null); const runtimeLogsScrollRef = useRef({ top: 0, atBottom: true }); const deepLinkHandledRef = useRef(''); + const installableDeepLinkHandledRef = useRef(''); const [userTokenValue, setUserTokenValue] = useState(''); const [userTokenScopes, setUserTokenScopes] = useState([]); const [userTokenMeta, setUserTokenMeta] = useState({ @@ -478,6 +483,25 @@ const AgentsHub = ({ currentPodId: propPodId = null }) => { } }, [queryAgentName, queryInstanceId, queryView, selectedPodId, installedAgents]); + // Deep-link from the marketplace detail page: /v2/agents/browse?installable= + // opens the install dialog pre-targeted to that listing. The marketplace keys + // on installableId; our catalog (/api/registry/agents) keys on name — match on + // either (plus id/displayName) and no-op if the catalog doesn't carry it, so a + // miss just leaves the user on the browse list (no regression). + useEffect(() => { + if (!queryInstallable || !agents.length) return; + if (installableDeepLinkHandledRef.current === queryInstallable) return; + const target = String(queryInstallable).trim().toLowerCase(); + const matched = agents.find((a) => ( + [a.installableId, a.name, a.id, a._id, a.displayName] + .some((v) => String(v || '').trim().toLowerCase() === target) + )); + if (!matched) return; + installableDeepLinkHandledRef.current = queryInstallable; + setActiveTab(TAB_DISCOVER); + openInstallDialog(matched); + }, [queryInstallable, agents]); + const fetchAgents = async () => { setLoading(true); setError(null); diff --git a/frontend/src/components/apps/AppCard.tsx b/frontend/src/components/apps/AppCard.tsx index fcda0f21a..404f7a915 100644 --- a/frontend/src/components/apps/AppCard.tsx +++ b/frontend/src/components/apps/AppCard.tsx @@ -31,6 +31,7 @@ import { SvgIconComponent } from '@mui/icons-material'; export interface AppCardApp { id?: string; _id?: string; + installableId?: string; name?: string; displayName?: string; description?: string; @@ -50,6 +51,7 @@ interface AppCardProps { installed?: boolean; onInstall?: (app: AppCardApp) => void; onRemove?: (app: AppCardApp) => void; + onViewDetail?: (app: AppCardApp) => void; loading?: boolean; showScopes?: boolean; } @@ -73,6 +75,7 @@ const AppCard: React.FC = ({ installed = false, onInstall, onRemove, + onViewDetail, loading = false, showScopes = false, }) => { @@ -143,7 +146,13 @@ const AppCard: React.FC = ({ textTransform: 'capitalize', }} /> - + onViewDetail(app) : undefined} + role={onViewDetail ? 'button' : undefined} + tabIndex={onViewDetail ? 0 : undefined} + onKeyDown={onViewDetail ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onViewDetail(app); } } : undefined} + > { }); it('renders official marketplace listings', async () => { - render(); + render(); expect(await screen.findByText('Official Marketplace')).toBeInTheDocument(); expect(await screen.findByText('Discord')).toBeInTheDocument(); @@ -115,7 +116,7 @@ describe('AppsMarketplacePage', () => { }); it('renders installable browse results and installs via registry', async () => { - render(); + render(); expect((await screen.findAllByText('Community Agent')).length).toBeGreaterThan(0); expect(screen.getAllByText('@sam/community-agent').length).toBeGreaterThan(0); diff --git a/frontend/src/components/apps/AppsMarketplacePage.tsx b/frontend/src/components/apps/AppsMarketplacePage.tsx index 11232969f..2abd20b38 100644 --- a/frontend/src/components/apps/AppsMarketplacePage.tsx +++ b/frontend/src/components/apps/AppsMarketplacePage.tsx @@ -5,6 +5,7 @@ */ import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Box, Container, @@ -161,6 +162,14 @@ const toInstalledLegacyApp = (app: any): App => ({ const AppsMarketplacePage: React.FC = () => { const v2Embedded = useV2Embedded(); + const navigate = useNavigate(); + + // Open the v2-native manifest detail page. Only wired in the v2 shell — + // the detail route lives at /v2/marketplace/:id and has no v1 equivalent. + const handleViewDetail = (app: { installableId?: string; id?: string }) => { + const id = String(app?.installableId || app?.id || ''); + if (id) navigate(`/v2/marketplace/${encodeURIComponent(id)}`); + }; const theme = useTheme(); const [apps, setApps] = useState([]); const [featured, setFeatured] = useState([]); @@ -687,6 +696,7 @@ const AppsMarketplacePage: React.FC = () => { installed={isInstalled(app.id)} onInstall={handleInstall} onRemove={handleRemove} + onViewDetail={v2Embedded ? handleViewDetail : undefined} /> ))} @@ -806,6 +816,7 @@ const AppsMarketplacePage: React.FC = () => { installed={isInstalled(app.id)} onInstall={handleInstall} onRemove={handleRemove} + onViewDetail={v2Embedded ? handleViewDetail : undefined} /> ))}