feat(web): MarketplaceView + PluginDetailView + /marketplace routes

Plan G4 / spec §11.6.

router.ts gains two new Route variants — 'marketplace' and
'marketplace-detail' — plus parsing for both /marketplace/<id> and
the /plugins/<id> 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/<id>.

- 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>
This commit is contained in:
Cursor Agent 2026-05-09 12:28:59 +00:00
parent b436f0b33d
commit 411d83b0bf
No known key found for this signature in database
6 changed files with 462 additions and 1 deletions

View file

@ -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 <MarketplaceView />;
}
if (route.kind === 'marketplace-detail') {
return <PluginDetailView pluginId={route.pluginId} />;
}
return (
<>
{activeProject ? (

View file

@ -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<InstalledPluginRecord[]>([]);
const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]);
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 (
<div className="marketplace-view" data-testid="marketplace-view">
<header className="marketplace-view__header">
<h1>Plugins marketplace</h1>
<div className="marketplace-view__filters">
<button
type="button"
data-active={filter === 'all'}
onClick={() => setFilter('all')}
>
All
</button>
<button
type="button"
data-active={filter === 'trusted'}
onClick={() => setFilter('trusted')}
>
Trusted
</button>
<button
type="button"
data-active={filter === 'restricted'}
onClick={() => setFilter('restricted')}
>
Restricted
</button>
</div>
</header>
{loading ? (
<div className="marketplace-view__loading">Loading</div>
) : null}
<section className="marketplace-view__grid" data-testid="marketplace-grid">
{visible.length === 0 && !loading ? (
<div className="marketplace-view__empty">
No plugins installed yet. Try <code>od plugin install &lt;source&gt;</code> or
register a marketplace below.
</div>
) : null}
{visible.map((p) => (
<button
type="button"
key={p.id}
className="marketplace-view__card"
onClick={() => navigate({ kind: 'marketplace-detail', pluginId: p.id })}
data-plugin-id={p.id}
>
<div className="marketplace-view__card-title">{p.title}</div>
{p.manifest?.description ? (
<div className="marketplace-view__card-desc">{p.manifest.description}</div>
) : null}
<div className="marketplace-view__card-meta">
<span>v{p.version}</span>
<span>trust: {p.trust}</span>
<span>{p.sourceKind}</span>
</div>
</button>
))}
</section>
<section className="marketplace-view__catalogs" data-testid="marketplace-catalogs">
<h2>Configured catalogs</h2>
{marketplaces.length === 0 ? (
<div>
None registered. Add one with <code>od marketplace add &lt;url&gt;</code>.
</div>
) : (
<ul>
{marketplaces.map((m) => (
<li key={m.id}>
<strong>{m.manifest.name ?? m.url}</strong>{' '}
<span className="marketplace-view__catalog-trust">trust: {m.trust}</span>
{' · '}
<a href={m.url} target="_blank" rel="noreferrer">{m.url}</a>
{' · '}
{m.manifest.plugins?.length ?? 0} plugin(s)
</li>
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -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<InstalledPluginRecord | null>(null);
const [error, setError] = useState<string | null>(null);
const [applying, setApplying] = useState(false);
const [applied, setApplied] = useState<ApplyResult | null>(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 (
<div className="plugin-detail" data-testid="plugin-detail">
<button type="button" onClick={() => navigate({ kind: 'marketplace' })}>
Marketplace
</button>
<div role="alert">Failed to load plugin: {error}</div>
</div>
);
}
if (!plugin) {
return (
<div className="plugin-detail" data-testid="plugin-detail">
<div>Loading plugin</div>
</div>
);
}
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 (
<div className="plugin-detail" data-testid="plugin-detail">
<button
type="button"
className="plugin-detail__back"
onClick={() => navigate({ kind: 'marketplace' })}
>
Marketplace
</button>
<header className="plugin-detail__header">
<h1>{plugin.title}</h1>
<div className="plugin-detail__meta">
<span>v{plugin.version}</span>
<span>trust: {plugin.trust}</span>
<span>source: {plugin.sourceKind}</span>
{od.taskKind ? <span>{od.taskKind}</span> : null}
</div>
</header>
{plugin.manifest?.description ? (
<p className="plugin-detail__description">{plugin.manifest.description}</p>
) : null}
<section className="plugin-detail__capabilities">
<h2>Capabilities</h2>
{capabilities.length === 0 ? (
<div>None declared (defaults to <code>prompt:inject</code>).</div>
) : (
<ul>
{capabilities.map((c: string) => (
<li key={c}>
<code>{c}</code>
</li>
))}
</ul>
)}
</section>
{required.length > 0 || optional.length > 0 ? (
<section className="plugin-detail__connectors">
<h2>Connectors</h2>
{required.length > 0 ? (
<>
<h3>Required</h3>
<ul>
{required.map((c: { id: string; tools?: string[] }) => (
<li key={c.id}>
<code>{c.id}</code>
{c.tools && c.tools.length > 0 ? ` · ${c.tools.join(', ')}` : ''}
</li>
))}
</ul>
</>
) : null}
{optional.length > 0 ? (
<>
<h3>Optional</h3>
<ul>
{optional.map((c: { id: string; tools?: string[] }) => (
<li key={c.id}>
<code>{c.id}</code>
{c.tools && c.tools.length > 0 ? ` · ${c.tools.join(', ')}` : ''}
</li>
))}
</ul>
</>
) : null}
</section>
) : null}
{surfaces.length > 0 ? (
<section className="plugin-detail__surfaces">
<h2>This plugin may ask you</h2>
<ul>
{surfaces.map((s: { id: string; kind: string; persist?: string; prompt?: string }) => (
<li key={s.id}>
<code>{s.kind}</code> · <code>{s.id}</code>
{s.persist ? <> · persists at <code>{s.persist}</code></> : null}
{s.prompt ? <> {s.prompt}</> : null}
</li>
))}
</ul>
</section>
) : null}
<footer className="plugin-detail__footer">
<button
type="button"
className="plugin-detail__use"
onClick={onUse}
disabled={applying}
data-testid="plugin-detail-use"
>
{applying ? 'Applying…' : 'Use this plugin'}
</button>
{applied ? (
<div className="plugin-detail__applied">
Applied (snapshot {applied.appliedPlugin.snapshotId.slice(0, 8)})
redirected to Home with the brief pre-filled.
</div>
) : null}
</footer>
</div>
);
}

View file

@ -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/<pluginId> → 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

View file

@ -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<typeof vi.fn>;
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(<MarketplaceView />);
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(<MarketplaceView />);
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();
});
});

View file

@ -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/<pluginId> as a detail route', () => {
expect(parseRoute('/marketplace/sample-plugin')).toEqual({
kind: 'marketplace-detail',
pluginId: 'sample-plugin',
});
});
it('parses /plugins/<pluginId> 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 });
});
});