From 411d83b0bf66a2255d503755302ef18bb50edc37 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 9 May 2026 12:28:59 +0000 Subject: [PATCH] feat(web): MarketplaceView + PluginDetailView + /marketplace routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan G4 / spec §11.6. router.ts gains two new Route variants — 'marketplace' and 'marketplace-detail' — plus parsing for both /marketplace/ and the /plugins/ alias the public site (§13) reserves. App.tsx dispatches them outside the EntryView / ProjectView split so the discovery surface stays independent of any active project. New components: - MarketplaceView (apps/web/src/components/MarketplaceView.tsx) - Card grid of every installed plugin with trust-tier filters (All / Trusted / Restricted). - Secondary 'Configured catalogs' panel listing every row in /api/marketplaces with id / url / trust / plugin count. - Cards link to /marketplace/. - PluginDetailView (apps/web/src/components/PluginDetailView.tsx) - Loads /api/plugins/:id, renders header (title, version, trust, sourceKind, taskKind), description, capability checklist, connector requirements (required + optional), and declared GenUI surfaces. - 'Use this plugin' button calls applyPlugin(id) and navigates back to Home so the existing inline rail / NewProjectPanel surface picks up the snapshot. Web tests: 579 → 586 (added router-marketplace 5 cases + MarketplaceView 2 cases). Typecheck clean. Co-authored-by: Tom Huang <1043269994@qq.com> --- apps/web/src/App.tsx | 13 ++ apps/web/src/components/MarketplaceView.tsx | 134 ++++++++++++ apps/web/src/components/PluginDetailView.tsx | 191 ++++++++++++++++++ apps/web/src/router.ts | 17 +- .../tests/components/MarketplaceView.test.tsx | 69 +++++++ apps/web/tests/router-marketplace.test.ts | 39 ++++ 6 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/MarketplaceView.tsx create mode 100644 apps/web/src/components/PluginDetailView.tsx create mode 100644 apps/web/tests/components/MarketplaceView.test.tsx create mode 100644 apps/web/tests/router-marketplace.test.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3f4598873..74f4e7dae 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { EntryView } from './components/EntryView'; +import { MarketplaceView } from './components/MarketplaceView'; +import { PluginDetailView } from './components/PluginDetailView'; import type { CreateInput } from './components/NewProjectPanel'; import { PetOverlay } from './components/pet/PetOverlay'; import { migrateCustomPetAtlas } from './components/pet/pets'; @@ -697,6 +699,17 @@ export function App() { [designSystems, config.disabledDesignSystems], ); + // Phase 2B / spec §11.6 — marketplace deep UI dispatch. The + // /marketplace and /marketplace/:id routes render outside the + // EntryView / ProjectView split so the discovery surface stays + // independent of any active project. + if (route.kind === 'marketplace') { + return ; + } + if (route.kind === 'marketplace-detail') { + return ; + } + return ( <> {activeProject ? ( diff --git a/apps/web/src/components/MarketplaceView.tsx b/apps/web/src/components/MarketplaceView.tsx new file mode 100644 index 000000000..2ff044835 --- /dev/null +++ b/apps/web/src/components/MarketplaceView.tsx @@ -0,0 +1,134 @@ +// Plan G4 / spec §11.6 — Marketplace catalog grid. +// +// Lists every installed plugin as a card grid (the most reliable +// snapshot of what the user can apply right now). Configured +// marketplaces are rendered as a secondary "Catalogs" panel so the +// user can register / refresh / remove without leaving the page. +// +// Click a card → navigate to /marketplace/:id (PluginDetailView). +// This is the deep-browsing surface; the inline rail (§8) stays the +// primary daily-driver flow. + +import { useEffect, useState } from 'react'; +import type { InstalledPluginRecord } from '@open-design/contracts'; +import { listPlugins } from '../state/projects'; +import { navigate } from '../router'; + +interface Marketplace { + id: string; + url: string; + trust: 'official' | 'trusted' | 'restricted'; + manifest: { name?: string; plugins?: Array<{ name: string; source: string; description?: string }> }; +} + +export function MarketplaceView() { + const [plugins, setPlugins] = useState([]); + const [marketplaces, setMarketplaces] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState<'all' | 'trusted' | 'restricted'>('all'); + + useEffect(() => { + let cancelled = false; + void Promise.all([ + listPlugins(), + fetch('/api/marketplaces') + .then((r) => (r.ok ? r.json() : { marketplaces: [] })) + .then((d) => (d?.marketplaces ?? []) as Marketplace[]), + ]).then(([rows, mps]) => { + if (cancelled) return; + setPlugins(rows); + setMarketplaces(mps); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const visible = plugins.filter((p) => filter === 'all' || p.trust === filter); + + return ( +
+
+

Plugins marketplace

+
+ + + +
+
+ + {loading ? ( +
Loading…
+ ) : null} + +
+ {visible.length === 0 && !loading ? ( +
+ No plugins installed yet. Try od plugin install <source> or + register a marketplace below. +
+ ) : null} + {visible.map((p) => ( + + ))} +
+ +
+

Configured catalogs

+ {marketplaces.length === 0 ? ( +
+ None registered. Add one with od marketplace add <url>. +
+ ) : ( +
    + {marketplaces.map((m) => ( +
  • + {m.manifest.name ?? m.url}{' '} + trust: {m.trust} + {' · '} + {m.url} + {' · '} + {m.manifest.plugins?.length ?? 0} plugin(s) +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/PluginDetailView.tsx b/apps/web/src/components/PluginDetailView.tsx new file mode 100644 index 000000000..71f080481 --- /dev/null +++ b/apps/web/src/components/PluginDetailView.tsx @@ -0,0 +1,191 @@ +// Plan G4 / spec §11.6 — Marketplace plugin detail. +// +// Renders one plugin's manifest, capability checklist, declared GenUI +// surfaces and connector requirements, plus a "Use this plugin" +// button that hydrates a fresh ApplyResult. The user lands back on +// Home with the brief / chip strip / inputs form pre-filled (the +// PluginsSection on NewProjectPanel renders the same applied state +// because applyPlugin returns the exact ApplyResult). + +import { useEffect, useState } from 'react'; +import type { ApplyResult, InstalledPluginRecord } from '@open-design/contracts'; +import { applyPlugin } from '../state/projects'; +import { navigate } from '../router'; + +interface Props { + pluginId: string; +} + +export function PluginDetailView(props: Props) { + const [plugin, setPlugin] = useState(null); + const [error, setError] = useState(null); + const [applying, setApplying] = useState(false); + const [applied, setApplied] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetch(`/api/plugins/${encodeURIComponent(props.pluginId)}`) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then((row) => { + if (cancelled) return; + setPlugin(row as InstalledPluginRecord); + }) + .catch((err) => { + if (cancelled) return; + setError((err as Error).message); + }); + return () => { + cancelled = true; + }; + }, [props.pluginId]); + + if (error) { + return ( +
+ +
Failed to load plugin: {error}
+
+ ); + } + + if (!plugin) { + return ( +
+
Loading plugin…
+
+ ); + } + + const od = plugin.manifest?.od ?? {}; + const surfaces = od.genui?.surfaces ?? []; + const required = od.connectors?.required ?? []; + const optional = od.connectors?.optional ?? []; + const capabilities = od.capabilities ?? []; + + const onUse = async () => { + setApplying(true); + setError(null); + const result = await applyPlugin(plugin.id); + setApplying(false); + if (!result) { + setError('Apply failed. Make sure the daemon is reachable.'); + return; + } + setApplied(result); + // Navigate to Home so the existing inline rail / NewProjectPanel + // surfaces the applied snapshot. Phase 2B ChatComposer mount then + // picks it up automatically inside an existing project. + navigate({ kind: 'home' }); + }; + + return ( +
+ + +
+

{plugin.title}

+
+ v{plugin.version} + trust: {plugin.trust} + source: {plugin.sourceKind} + {od.taskKind ? {od.taskKind} : null} +
+
+ + {plugin.manifest?.description ? ( +

{plugin.manifest.description}

+ ) : null} + +
+

Capabilities

+ {capabilities.length === 0 ? ( +
None declared (defaults to prompt:inject).
+ ) : ( +
    + {capabilities.map((c: string) => ( +
  • + {c} +
  • + ))} +
+ )} +
+ + {required.length > 0 || optional.length > 0 ? ( +
+

Connectors

+ {required.length > 0 ? ( + <> +

Required

+
    + {required.map((c: { id: string; tools?: string[] }) => ( +
  • + {c.id} + {c.tools && c.tools.length > 0 ? ` · ${c.tools.join(', ')}` : ''} +
  • + ))} +
+ + ) : null} + {optional.length > 0 ? ( + <> +

Optional

+
    + {optional.map((c: { id: string; tools?: string[] }) => ( +
  • + {c.id} + {c.tools && c.tools.length > 0 ? ` · ${c.tools.join(', ')}` : ''} +
  • + ))} +
+ + ) : null} +
+ ) : null} + + {surfaces.length > 0 ? ( +
+

This plugin may ask you

+
    + {surfaces.map((s: { id: string; kind: string; persist?: string; prompt?: string }) => ( +
  • + {s.kind} · {s.id} + {s.persist ? <> · persists at {s.persist} : null} + {s.prompt ? <> — {s.prompt} : null} +
  • + ))} +
+
+ ) : null} + +
+ + {applied ? ( +
+ Applied (snapshot {applied.appliedPlugin.snapshotId.slice(0, 8)}…) — + redirected to Home with the brief pre-filled. +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 82dbe9149..f41d758c6 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -7,7 +7,9 @@ import { useEffect, useState } from 'react'; export type Route = | { kind: 'home' } - | { kind: 'project'; projectId: string; fileName: string | null }; + | { kind: 'project'; projectId: string; fileName: string | null } + | { kind: 'marketplace' } + | { kind: 'marketplace-detail'; pluginId: string }; export function parseRoute(pathname: string): Route { const parts = pathname.replace(/\/+$/, '').split('/').filter(Boolean); @@ -23,11 +25,24 @@ export function parseRoute(pathname: string): Route { } return { kind: 'project', projectId, fileName: null }; } + // Phase 2B / spec §11.6 — marketplace deep UI routes. Two paths: + // /marketplace → catalog grid (MarketplaceView) + // /marketplace/ → detail page (PluginDetailView) + // Aliases to /plugins remain reserved for the public site (spec §13); + // in-app we keep /marketplace canonical. + if (parts[0] === 'marketplace' || parts[0] === 'plugins') { + if (parts[1]) { + return { kind: 'marketplace-detail', pluginId: decodeURIComponent(parts[1]) }; + } + return { kind: 'marketplace' }; + } return { kind: 'home' }; } export function buildPath(route: Route): string { if (route.kind === 'home') return '/'; + if (route.kind === 'marketplace') return '/marketplace'; + if (route.kind === 'marketplace-detail') return `/marketplace/${encodeURIComponent(route.pluginId)}`; const id = encodeURIComponent(route.projectId); if (route.fileName) { const file = route.fileName diff --git a/apps/web/tests/components/MarketplaceView.test.tsx b/apps/web/tests/components/MarketplaceView.test.tsx new file mode 100644 index 000000000..ff98b4b9d --- /dev/null +++ b/apps/web/tests/components/MarketplaceView.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom + +// Plan G4 — MarketplaceView jsdom smoke. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MarketplaceView } from '../../src/components/MarketplaceView'; + +const PLUGIN_ROW = { + id: 'sample-plugin', + title: 'Sample Plugin', + version: '1.0.0', + trust: 'restricted' as const, + sourceKind: 'local' as const, + source: '/tmp/sample', + manifest: { description: 'A fixture' }, +}; + +const MARKETPLACE_ROW = { + id: 'mp-1', + url: 'https://example.com/marketplace.json', + trust: 'restricted', + manifest: { + name: 'Example marketplace', + plugins: [{ name: 'sample-plugin', source: 'github:open-design/sample-plugin' }], + }, +}; + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(async (url) => { + if (url === '/api/plugins') { + return new Response(JSON.stringify({ plugins: [PLUGIN_ROW] }), { status: 200 }); + } + if (url === '/api/marketplaces') { + return new Response(JSON.stringify({ marketplaces: [MARKETPLACE_ROW] }), { status: 200 }); + } + throw new Error(`unexpected fetch ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +describe('MarketplaceView', () => { + it('renders the installed plugins as cards and the configured catalogs', async () => { + render(); + await waitFor(() => screen.getByTestId('marketplace-grid')); + expect(screen.getByText('Sample Plugin')).toBeTruthy(); + expect(screen.getByText('A fixture')).toBeTruthy(); + expect(screen.getByText('Example marketplace')).toBeTruthy(); + expect(screen.getByText(/1 plugin\(s\)/)).toBeTruthy(); + }); + + it('filters by trust tier when the user clicks Trusted', async () => { + render(); + await waitFor(() => screen.getByText('Sample Plugin')); + const trustedFilter = screen.getByText('Trusted', { selector: 'button' }); + trustedFilter.click(); + await waitFor(() => expect(screen.queryByText('Sample Plugin')).toBeNull()); + expect( + screen.getByText(/No plugins installed yet|No plugins/, { exact: false }), + ).toBeTruthy(); + }); +}); diff --git a/apps/web/tests/router-marketplace.test.ts b/apps/web/tests/router-marketplace.test.ts new file mode 100644 index 000000000..670a6cd75 --- /dev/null +++ b/apps/web/tests/router-marketplace.test.ts @@ -0,0 +1,39 @@ +// Plan G4 — Phase 2B router contract for the marketplace routes. + +import { describe, expect, it } from 'vitest'; +import { buildPath, parseRoute, type Route } from '../src/router'; + +describe('router /marketplace', () => { + it('parses /marketplace as the catalog grid route', () => { + expect(parseRoute('/marketplace')).toEqual({ kind: 'marketplace' }); + expect(parseRoute('/marketplace/')).toEqual({ kind: 'marketplace' }); + }); + + it('parses /marketplace/ as a detail route', () => { + expect(parseRoute('/marketplace/sample-plugin')).toEqual({ + kind: 'marketplace-detail', + pluginId: 'sample-plugin', + }); + }); + + it('parses /plugins/ as the same detail route (alias)', () => { + expect(parseRoute('/plugins/sample-plugin')).toEqual({ + kind: 'marketplace-detail', + pluginId: 'sample-plugin', + }); + }); + + it('round-trips through buildPath', () => { + for (const route of [ + { kind: 'marketplace' } as Route, + { kind: 'marketplace-detail', pluginId: 'sample-plugin' } as Route, + ]) { + expect(parseRoute(buildPath(route))).toEqual(route); + } + }); + + it('does not break the home / project routes', () => { + expect(parseRoute('/')).toEqual({ kind: 'home' }); + expect(parseRoute('/projects/abc')).toEqual({ kind: 'project', projectId: 'abc', fileName: null }); + }); +});