@nextclaw/ui 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/assets/ChannelsList-ZBPiF0y2.js +1 -0
  3. package/dist/assets/ChatPage-BOgoolWK.js +38 -0
  4. package/dist/assets/{DocBrowser-CVwUDJMO.js → DocBrowser-BUYNHg0Y.js} +1 -1
  5. package/dist/assets/LogoBadge-DXPq99LJ.js +1 -0
  6. package/dist/assets/MarketplacePage-Dx7nexYN.js +49 -0
  7. package/dist/assets/McpMarketplacePage-064wdotP.js +40 -0
  8. package/dist/assets/{ModelConfig-CsX-_fyy.js → ModelConfig-BDIfLesG.js} +1 -1
  9. package/dist/assets/ProvidersList-DrlIr46m.js +1 -0
  10. package/dist/assets/RemoteAccessPage-ZkUBA-Av.js +1 -0
  11. package/dist/assets/{RuntimeConfig-CX2TGEG1.js → RuntimeConfig-BPxXEGzM.js} +1 -1
  12. package/dist/assets/{SearchConfig-C-WBTcWi.js → SearchConfig-BIqnlpne.js} +1 -1
  13. package/dist/assets/{SecretsConfig-9kbR0ZCB.js → SecretsConfig-jKZEVF2q.js} +2 -2
  14. package/dist/assets/{SessionsConfig-Bohn3P1q.js → SessionsConfig-C_FXgVe1.js} +2 -2
  15. package/dist/assets/{chat-message-AWIcksDK.js → chat-message-DmzpZJc_.js} +1 -1
  16. package/dist/assets/index-Byfw276e.js +8 -0
  17. package/dist/assets/{index-CPDASUXh.js → index-Ct7FQpxN.js} +1 -1
  18. package/dist/assets/index-bhNuQis7.css +1 -0
  19. package/dist/assets/{label-DD61y-4v.js → label-B1MloEtn.js} +1 -1
  20. package/dist/assets/marketplace-localization-Dk31LJJJ.js +1 -0
  21. package/dist/assets/{page-layout-CfnoVycc.js → page-layout-BGg1EhM5.js} +1 -1
  22. package/dist/assets/{popover-DsugZ6rp.js → popover-jJMv74Fp.js} +1 -1
  23. package/dist/assets/{security-config-DIrf2Z0O.js → security-config-Boh9NIMz.js} +1 -1
  24. package/dist/assets/skeleton-CmATs_b3.js +1 -0
  25. package/dist/assets/status-dot-DNyCdxPZ.js +1 -0
  26. package/dist/assets/{switch-NX5OmUXQ.js → switch-DE_MYk7x.js} +1 -1
  27. package/dist/assets/{tabs-custom-9ihB5Jem.js → tabs-custom-B-zErYPr.js} +1 -1
  28. package/dist/assets/{useConfirmDialog-BuQnVTeR.js → useConfirmDialog-BqQ6QfhB.js} +2 -2
  29. package/dist/assets/{vendor-DKBNiC31.js → vendor-CwsIoNvJ.js} +128 -93
  30. package/dist/index.html +3 -3
  31. package/package.json +4 -4
  32. package/src/App.tsx +4 -0
  33. package/src/api/auth.types.ts +24 -0
  34. package/src/api/chat-session-type.types.ts +21 -0
  35. package/src/api/marketplace.ts +8 -2
  36. package/src/api/mcp-marketplace.ts +138 -0
  37. package/src/api/remote.ts +57 -0
  38. package/src/api/remote.types.ts +80 -0
  39. package/src/api/types.ts +28 -34
  40. package/src/components/chat/ChatSidebar.test.tsx +31 -2
  41. package/src/components/chat/ChatSidebar.tsx +26 -2
  42. package/src/components/chat/chat-page-data.ts +36 -38
  43. package/src/components/chat/chat-page-runtime.test.ts +96 -2
  44. package/src/components/chat/chat-page-runtime.ts +1 -135
  45. package/src/components/chat/chat-session-preference-governance.ts +303 -0
  46. package/src/components/chat/legacy/LegacyChatPage.tsx +4 -19
  47. package/src/components/chat/ncp/NcpChatPage.tsx +4 -19
  48. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +36 -0
  49. package/src/components/chat/ncp/ncp-chat-page-data.ts +62 -21
  50. package/src/components/chat/ncp/ncp-session-adapter.test.ts +2 -0
  51. package/src/components/chat/ncp/ncp-session-adapter.ts +2 -0
  52. package/src/components/chat/stores/chat-input.store.ts +14 -1
  53. package/src/components/chat/useChatSessionTypeState.test.tsx +29 -0
  54. package/src/components/chat/useChatSessionTypeState.ts +55 -12
  55. package/src/components/layout/Sidebar.tsx +11 -1
  56. package/src/components/marketplace/MarketplacePage.test.tsx +152 -0
  57. package/src/components/marketplace/MarketplacePage.tsx +52 -199
  58. package/src/components/marketplace/marketplace-installed-cache.test.ts +110 -0
  59. package/src/components/marketplace/marketplace-installed-cache.ts +149 -0
  60. package/src/components/marketplace/marketplace-localization.ts +77 -0
  61. package/src/components/marketplace/marketplace-page-parts.tsx +102 -0
  62. package/src/components/marketplace/mcp/McpMarketplacePage.test.tsx +208 -0
  63. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +578 -0
  64. package/src/components/remote/RemoteAccessPage.tsx +320 -0
  65. package/src/components/ui/input.tsx +1 -1
  66. package/src/components/ui/label.tsx +1 -1
  67. package/src/hooks/useMarketplace.ts +36 -7
  68. package/src/hooks/useMcpMarketplace.ts +99 -0
  69. package/src/hooks/useRemoteAccess.ts +92 -0
  70. package/src/hooks/useWebSocket.ts +25 -16
  71. package/src/lib/i18n.marketplace.ts +91 -0
  72. package/src/lib/i18n.remote.ts +115 -0
  73. package/src/lib/i18n.ts +10 -68
  74. package/dist/assets/ChannelsList-DKD6Llid.js +0 -1
  75. package/dist/assets/ChatPage-BK9X4Tin.js +0 -38
  76. package/dist/assets/LogoBadge-CYQ_b7jk.js +0 -1
  77. package/dist/assets/MarketplacePage-B_2z3ii_.js +0 -49
  78. package/dist/assets/ProvidersList-CZstsyv7.js +0 -1
  79. package/dist/assets/index-BEgClaDH.js +0 -8
  80. package/dist/assets/index-C8GsgIUn.css +0 -1
  81. package/dist/assets/skeleton-DJ-Wen2o.js +0 -1
@@ -0,0 +1,578 @@
1
+ /* eslint-disable max-lines-per-function */
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import { useMutation } from '@tanstack/react-query';
4
+ import { PageHeader, PageLayout } from '@/components/layout/page-layout';
5
+ import { Tabs } from '@/components/ui/tabs-custom';
6
+ import { Input } from '@/components/ui/input';
7
+ import { Switch } from '@/components/ui/switch';
8
+ import { Button } from '@/components/ui/button';
9
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
10
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
11
+ import { Skeleton } from '@/components/ui/skeleton';
12
+ import { useConfirmDialog } from '@/hooks/useConfirmDialog';
13
+ import {
14
+ fetchMcpMarketplaceContent,
15
+ doctorMcpMarketplaceItem
16
+ } from '@/api/mcp-marketplace';
17
+ import {
18
+ useInstallMcpMarketplaceItem,
19
+ useManageMcpMarketplaceItem,
20
+ useMcpMarketplaceInstalled,
21
+ useMcpMarketplaceItems
22
+ } from '@/hooks/useMcpMarketplace';
23
+ import type {
24
+ MarketplaceInstalledRecord,
25
+ MarketplaceItemSummary,
26
+ MarketplaceMcpDoctorResult,
27
+ MarketplaceMcpInstallSpec,
28
+ MarketplaceSort
29
+ } from '@/api/types';
30
+ import { useDocBrowser } from '@/components/doc-browser';
31
+ import { useI18n } from '@/components/providers/I18nProvider';
32
+ import {
33
+ buildLocaleFallbacks,
34
+ pickInstalledRecordDescription,
35
+ pickLocalizedText
36
+ } from '@/components/marketplace/marketplace-localization';
37
+ import { t } from '@/lib/i18n';
38
+ import { cn } from '@/lib/utils';
39
+
40
+ type ScopeType = 'catalog' | 'installed';
41
+
42
+ const PAGE_SIZE = 12;
43
+
44
+ function normalizeMarketplaceKey(value: string | undefined): string {
45
+ return (value ?? '').trim().toLowerCase();
46
+ }
47
+
48
+ function buildInstalledRecordLookup(records: MarketplaceInstalledRecord[]): Map<string, MarketplaceInstalledRecord> {
49
+ const lookup = new Map<string, MarketplaceInstalledRecord>();
50
+
51
+ for (const record of records) {
52
+ const candidates = [record.catalogSlug, record.spec, record.id, record.label];
53
+ for (const candidate of candidates) {
54
+ const normalized = normalizeMarketplaceKey(candidate);
55
+ if (!normalized || lookup.has(normalized)) {
56
+ continue;
57
+ }
58
+ lookup.set(normalized, record);
59
+ }
60
+ }
61
+
62
+ return lookup;
63
+ }
64
+
65
+ function findInstalledRecordForItem(
66
+ item: MarketplaceItemSummary,
67
+ installedRecordLookup: Map<string, MarketplaceInstalledRecord>
68
+ ): MarketplaceInstalledRecord | undefined {
69
+ const candidates = [item.slug, item.install.spec, item.id, item.name];
70
+ for (const candidate of candidates) {
71
+ const normalized = normalizeMarketplaceKey(candidate);
72
+ if (!normalized) {
73
+ continue;
74
+ }
75
+ const record = installedRecordLookup.get(normalized);
76
+ if (record) {
77
+ return record;
78
+ }
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ function buildDocDataUrl(title: string, metadata: string, content: string, sourceUrl?: string, summary?: string): string {
84
+ const escape = (value: string) =>
85
+ value
86
+ .replace(/&/g, '&amp;')
87
+ .replace(/</g, '&lt;')
88
+ .replace(/>/g, '&gt;')
89
+ .replace(/"/g, '&quot;')
90
+ .replace(/'/g, '&#39;');
91
+
92
+ const html = `<!doctype html>
93
+ <html>
94
+ <head>
95
+ <meta charset="utf-8" />
96
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
97
+ <title>${escape(title)}</title>
98
+ <style>
99
+ body { margin: 0; background: #f8fafc; color: #0f172a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
100
+ .wrap { max-width: 980px; margin: 0 auto; padding: 28px 20px 40px; }
101
+ .hero { border: 1px solid #dbeafe; border-radius: 16px; background: linear-gradient(180deg, #eff6ff, #ffffff); padding: 20px; }
102
+ .hero h1 { margin: 0; font-size: 26px; }
103
+ .grid { display: grid; grid-template-columns: 280px 1fr; gap: 14px; margin-top: 16px; }
104
+ .card { border: 1px solid #e2e8f0; background: #fff; border-radius: 14px; overflow: hidden; }
105
+ .card h2 { margin: 0; padding: 12px 14px; font-size: 13px; font-weight: 700; color: #1d4ed8; border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
106
+ .body { padding: 12px 14px; }
107
+ pre { margin: 0; white-space: pre-wrap; line-height: 1.7; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
108
+ a { color: #2563eb; text-decoration: none; }
109
+ @media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
110
+ </style>
111
+ </head>
112
+ <body>
113
+ <main class="wrap">
114
+ <section class="hero">
115
+ <h1>${escape(title)}</h1>
116
+ ${summary ? `<p>${escape(summary)}</p>` : ''}
117
+ ${sourceUrl ? `<p><a href="${escape(sourceUrl)}" target="_blank" rel="noopener noreferrer">${escape(sourceUrl)}</a></p>` : ''}
118
+ </section>
119
+ <section class="grid">
120
+ <article class="card">
121
+ <h2>Metadata</h2>
122
+ <div class="body"><pre>${escape(metadata)}</pre></div>
123
+ </article>
124
+ <article class="card">
125
+ <h2>Content</h2>
126
+ <div class="body"><pre>${escape(content)}</pre></div>
127
+ </article>
128
+ </section>
129
+ </main>
130
+ </body>
131
+ </html>`;
132
+
133
+ return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
134
+ }
135
+
136
+ function readSummary(localeFallbacks: string[], item?: MarketplaceItemSummary, record?: MarketplaceInstalledRecord): string {
137
+ const localizedSummary = pickLocalizedText(item?.summaryI18n, item?.summary, localeFallbacks);
138
+ if (localizedSummary) {
139
+ return localizedSummary;
140
+ }
141
+
142
+ const localizedRecordDescription = pickInstalledRecordDescription(record, localeFallbacks);
143
+ return localizedRecordDescription || t('marketplaceInstalledLocalSummary');
144
+ }
145
+
146
+ function readTransportLabel(item?: MarketplaceItemSummary, record?: MarketplaceInstalledRecord): string {
147
+ if (record?.transport) {
148
+ return record.transport.toUpperCase();
149
+ }
150
+ const install = item?.install as MarketplaceMcpInstallSpec | undefined;
151
+ return (install?.transportTypes ?? []).map((entry) => entry.toUpperCase()).join(' / ') || 'MCP';
152
+ }
153
+
154
+ function InstallDialog(props: {
155
+ item: MarketplaceItemSummary | null;
156
+ open: boolean;
157
+ pending: boolean;
158
+ onOpenChange: (open: boolean) => void;
159
+ onSubmit: (payload: { name: string; allAgents: boolean; inputs: Record<string, string> }) => Promise<void>;
160
+ }) {
161
+ const template = props.item?.install as MarketplaceMcpInstallSpec | undefined;
162
+ const [name, setName] = useState('');
163
+ const [allAgents, setAllAgents] = useState(true);
164
+ const [inputs, setInputs] = useState<Record<string, string>>({});
165
+
166
+ useEffect(() => {
167
+ setName(template?.defaultName ?? '');
168
+ setAllAgents(true);
169
+ setInputs(
170
+ Object.fromEntries((template?.inputs ?? []).map((field) => [field.id, field.defaultValue ?? '']))
171
+ );
172
+ }, [template, props.open]);
173
+
174
+ return (
175
+ <Dialog open={props.open} onOpenChange={props.onOpenChange}>
176
+ <DialogContent>
177
+ <DialogHeader>
178
+ <DialogTitle>{t('marketplaceMcpInstallDialogTitle')}</DialogTitle>
179
+ <DialogDescription>{props.item?.name ?? '-'}</DialogDescription>
180
+ </DialogHeader>
181
+
182
+ <div className="space-y-4">
183
+ <div className="space-y-2">
184
+ <div className="text-sm font-medium text-gray-800">{t('marketplaceMcpServerName')}</div>
185
+ <Input value={name} onChange={(event) => setName(event.target.value)} placeholder={template?.defaultName ?? 'mcp-server'} />
186
+ </div>
187
+
188
+ <div className="flex items-center justify-between rounded-xl border border-gray-200 px-3 py-3">
189
+ <div>
190
+ <div className="text-sm font-medium text-gray-900">{t('marketplaceMcpAllAgents')}</div>
191
+ <div className="text-xs text-gray-500">{t('marketplaceMcpAllAgentsDescription')}</div>
192
+ </div>
193
+ <Switch checked={allAgents} onCheckedChange={setAllAgents} />
194
+ </div>
195
+
196
+ {(template?.inputs ?? []).map((field) => (
197
+ <div key={field.id} className="space-y-2">
198
+ <div className="text-sm font-medium text-gray-800">{field.label}</div>
199
+ {field.description && <div className="text-xs text-gray-500">{field.description}</div>}
200
+ <Input
201
+ type={field.secret ? 'password' : 'text'}
202
+ value={inputs[field.id] ?? ''}
203
+ onChange={(event) => setInputs((current) => ({ ...current, [field.id]: event.target.value }))}
204
+ placeholder={field.defaultValue ?? ''}
205
+ />
206
+ </div>
207
+ ))}
208
+ </div>
209
+
210
+ <DialogFooter>
211
+ <Button variant="outline" onClick={() => props.onOpenChange(false)} disabled={props.pending}>
212
+ {t('cancel')}
213
+ </Button>
214
+ <Button
215
+ onClick={() => void props.onSubmit({ name, allAgents, inputs })}
216
+ disabled={props.pending || !name.trim()}
217
+ >
218
+ {props.pending ? t('marketplaceInstalling') : t('marketplaceInstall')}
219
+ </Button>
220
+ </DialogFooter>
221
+ </DialogContent>
222
+ </Dialog>
223
+ );
224
+ }
225
+
226
+ function DoctorDialog(props: {
227
+ result: MarketplaceMcpDoctorResult | null;
228
+ targetName: string | null;
229
+ open: boolean;
230
+ pending: boolean;
231
+ onOpenChange: (open: boolean) => void;
232
+ }) {
233
+ return (
234
+ <Dialog open={props.open} onOpenChange={props.onOpenChange}>
235
+ <DialogContent>
236
+ <DialogHeader>
237
+ <DialogTitle>{t('marketplaceMcpDoctorTitle')}</DialogTitle>
238
+ <DialogDescription>{props.targetName ?? '-'}</DialogDescription>
239
+ </DialogHeader>
240
+ {props.pending && <div className="text-sm text-gray-500">{t('loading')}</div>}
241
+ {!props.pending && props.result && (
242
+ <div className="space-y-3 text-sm text-gray-700">
243
+ <div>{t('marketplaceMcpDoctorAccessible')}: {props.result.accessible ? t('statusReady') : t('marketplaceOperationFailed')}</div>
244
+ <div>{t('marketplaceMcpDoctorTransport')}: {props.result.transport.toUpperCase()}</div>
245
+ <div>{t('marketplaceMcpDoctorTools')}: {props.result.toolCount}</div>
246
+ {props.result.error && <div className="rounded-lg bg-rose-50 px-3 py-2 text-rose-600">{props.result.error}</div>}
247
+ </div>
248
+ )}
249
+ </DialogContent>
250
+ </Dialog>
251
+ );
252
+ }
253
+
254
+ export function McpMarketplacePage() {
255
+ const [scope, setScope] = useState<ScopeType>('catalog');
256
+ const [searchText, setSearchText] = useState('');
257
+ const [query, setQuery] = useState('');
258
+ const [sort, setSort] = useState<MarketplaceSort>('relevance');
259
+ const [page, setPage] = useState(1);
260
+ const [installingItem, setInstallingItem] = useState<MarketplaceItemSummary | null>(null);
261
+ const [doctorTarget, setDoctorTarget] = useState<string | null>(null);
262
+ const [doctorResult, setDoctorResult] = useState<MarketplaceMcpDoctorResult | null>(null);
263
+ const { language } = useI18n();
264
+ const docBrowser = useDocBrowser();
265
+ const { confirm, ConfirmDialog } = useConfirmDialog();
266
+ const localeFallbacks = useMemo(() => buildLocaleFallbacks(language), [language]);
267
+
268
+ useEffect(() => {
269
+ const timer = window.setTimeout(() => {
270
+ setPage(1);
271
+ setQuery(searchText.trim());
272
+ }, 250);
273
+ return () => window.clearTimeout(timer);
274
+ }, [searchText]);
275
+
276
+ const itemsQuery = useMcpMarketplaceItems({
277
+ q: query || undefined,
278
+ sort,
279
+ page,
280
+ pageSize: PAGE_SIZE
281
+ });
282
+ const installedQuery = useMcpMarketplaceInstalled();
283
+
284
+ const installMutation = useInstallMcpMarketplaceItem();
285
+ const manageMutation = useManageMcpMarketplaceItem();
286
+ const doctorMutation = useMutation({
287
+ mutationFn: doctorMcpMarketplaceItem,
288
+ onSuccess: (result, name) => {
289
+ setDoctorTarget(name);
290
+ setDoctorResult(result);
291
+ }
292
+ });
293
+
294
+ const installedRecordLookup = useMemo(() => {
295
+ return buildInstalledRecordLookup(installedQuery.data?.records ?? []);
296
+ }, [installedQuery.data?.records]);
297
+
298
+ const installedRecords = useMemo(() => {
299
+ const entries = installedQuery.data?.records ?? [];
300
+ return entries.filter((record) => {
301
+ const text = [
302
+ record.id ?? '',
303
+ record.label ?? '',
304
+ record.catalogSlug ?? '',
305
+ record.description ?? '',
306
+ record.descriptionZh ?? ''
307
+ ].join(' ').toLowerCase();
308
+ return query ? text.includes(query.toLowerCase()) : true;
309
+ });
310
+ }, [installedQuery.data?.records, query]);
311
+
312
+ const openDoc = async (item?: MarketplaceItemSummary, record?: MarketplaceInstalledRecord) => {
313
+ const title = item?.name ?? record?.label ?? record?.id ?? 'MCP';
314
+ const summary = readSummary(localeFallbacks, item, record);
315
+ if (!item) {
316
+ const url = buildDocDataUrl(
317
+ title,
318
+ JSON.stringify(record ?? {}, null, 2),
319
+ t('marketplaceInstalledLocalSummary'),
320
+ record?.docsUrl,
321
+ summary
322
+ );
323
+ docBrowser.open(url, { newTab: true, title, kind: 'content' });
324
+ return;
325
+ }
326
+ try {
327
+ const content = await fetchMcpMarketplaceContent(item.slug);
328
+ const url = buildDocDataUrl(
329
+ title,
330
+ content.metadataRaw || JSON.stringify(item, null, 2),
331
+ content.bodyRaw || content.raw,
332
+ content.sourceUrl,
333
+ summary
334
+ );
335
+ docBrowser.open(url, { newTab: true, title, kind: 'content' });
336
+ } catch (error) {
337
+ const url = buildDocDataUrl(
338
+ title,
339
+ JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2),
340
+ summary,
341
+ undefined,
342
+ summary
343
+ );
344
+ docBrowser.open(url, { newTab: true, title, kind: 'content' });
345
+ }
346
+ };
347
+
348
+ const handleInstall = async (payload: { name: string; allAgents: boolean; inputs: Record<string, string> }) => {
349
+ if (!installingItem) {
350
+ return;
351
+ }
352
+ await installMutation.mutateAsync({
353
+ spec: installingItem.slug,
354
+ name: payload.name.trim(),
355
+ allAgents: payload.allAgents,
356
+ inputs: payload.inputs
357
+ });
358
+ setInstallingItem(null);
359
+ };
360
+
361
+ const handleManage = async (action: 'enable' | 'disable' | 'remove', record: MarketplaceInstalledRecord) => {
362
+ const target = record.id || record.spec;
363
+ if (!target) {
364
+ return;
365
+ }
366
+ if (action === 'remove') {
367
+ const confirmed = await confirm({
368
+ title: `${t('marketplaceMcpRemoveTitle')} ${target}?`,
369
+ description: t('marketplaceMcpRemoveDescription'),
370
+ confirmLabel: t('marketplaceMcpRemove'),
371
+ variant: 'destructive'
372
+ });
373
+ if (!confirmed) {
374
+ return;
375
+ }
376
+ }
377
+ await manageMutation.mutateAsync({
378
+ action,
379
+ id: target,
380
+ spec: record.spec
381
+ });
382
+ };
383
+
384
+ const renderCard = (item?: MarketplaceItemSummary, record?: MarketplaceInstalledRecord) => {
385
+ const installed = record ?? (item ? findInstalledRecordForItem(item, installedRecordLookup) : undefined);
386
+ const name = item?.name ?? record?.label ?? record?.id ?? 'MCP';
387
+ const summary = readSummary(localeFallbacks, item, record);
388
+ const transport = readTransportLabel(item, record);
389
+ const status = installed ? (installed.enabled === false ? t('marketplaceDisable') : t('statusReady')) : null;
390
+
391
+ return (
392
+ <article
393
+ key={`${item?.id ?? record?.id ?? record?.spec}`}
394
+ onClick={() => void openDoc(item, record)}
395
+ className="cursor-pointer rounded-2xl border border-gray-200/70 bg-white p-4 shadow-sm transition hover:border-blue-300 hover:shadow-md"
396
+ >
397
+ <div className="flex items-start justify-between gap-3">
398
+ <div className="min-w-0">
399
+ <div className="text-sm font-semibold text-gray-900">{name}</div>
400
+ <div className="mt-1 text-xs text-gray-500">{transport}</div>
401
+ <div className="mt-2 line-clamp-2 text-sm text-gray-600">{summary}</div>
402
+ <div className="mt-3 flex flex-wrap gap-2">
403
+ {(item?.tags ?? []).map((tag) => (
404
+ <span key={tag} className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] text-slate-600">{tag}</span>
405
+ ))}
406
+ {status && <span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] text-emerald-700">{status}</span>}
407
+ </div>
408
+ </div>
409
+
410
+ <div className="flex shrink-0 flex-col gap-2">
411
+ {!installed && item && (
412
+ <button
413
+ className="rounded-xl bg-primary px-3 py-1.5 text-xs font-medium text-white"
414
+ onClick={(event) => {
415
+ event.stopPropagation();
416
+ setInstallingItem(item);
417
+ }}
418
+ >
419
+ {t('marketplaceInstall')}
420
+ </button>
421
+ )}
422
+
423
+ {installed && (
424
+ <>
425
+ <button
426
+ className="rounded-xl border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-700"
427
+ onClick={(event) => {
428
+ event.stopPropagation();
429
+ void handleManage(installed.enabled === false ? 'enable' : 'disable', installed);
430
+ }}
431
+ >
432
+ {installed.enabled === false ? t('marketplaceEnable') : t('marketplaceDisable')}
433
+ </button>
434
+ <button
435
+ className="rounded-xl border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700"
436
+ onClick={(event) => {
437
+ event.stopPropagation();
438
+ setDoctorTarget(installed.id ?? null);
439
+ setDoctorResult(null);
440
+ void doctorMutation.mutateAsync(installed.id ?? '');
441
+ }}
442
+ >
443
+ {t('marketplaceMcpDoctor')}
444
+ </button>
445
+ <button
446
+ className="rounded-xl border border-rose-200 px-3 py-1.5 text-xs font-medium text-rose-600"
447
+ onClick={(event) => {
448
+ event.stopPropagation();
449
+ void handleManage('remove', installed);
450
+ }}
451
+ >
452
+ {t('marketplaceMcpRemove')}
453
+ </button>
454
+ </>
455
+ )}
456
+ </div>
457
+ </div>
458
+ </article>
459
+ );
460
+ };
461
+
462
+ return (
463
+ <PageLayout className="flex h-full min-h-0 flex-col pb-0">
464
+ <PageHeader title={t('marketplaceMcpPageTitle')} description={t('marketplaceMcpPageDescription')} />
465
+
466
+ <Tabs
467
+ tabs={[
468
+ { id: 'catalog', label: t('marketplaceMcpTabCatalog') },
469
+ { id: 'installed', label: t('marketplaceMcpTabInstalled'), count: installedQuery.data?.total ?? 0 }
470
+ ]}
471
+ activeTab={scope}
472
+ onChange={(value) => setScope(value as ScopeType)}
473
+ className="mb-4"
474
+ />
475
+
476
+ <div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
477
+ <Input
478
+ value={searchText}
479
+ onChange={(event) => setSearchText(event.target.value)}
480
+ placeholder={t('marketplaceMcpSearchPlaceholder')}
481
+ className="md:max-w-sm"
482
+ />
483
+
484
+ <Select value={sort} onValueChange={(value) => setSort(value as MarketplaceSort)}>
485
+ <SelectTrigger className="h-9 w-[180px] rounded-lg">
486
+ <SelectValue />
487
+ </SelectTrigger>
488
+ <SelectContent>
489
+ <SelectItem value="relevance">{t('marketplaceSortRelevance')}</SelectItem>
490
+ <SelectItem value="updated">{t('marketplaceSortUpdated')}</SelectItem>
491
+ </SelectContent>
492
+ </Select>
493
+ </div>
494
+
495
+ <section className="flex min-h-0 flex-1 flex-col">
496
+ <div className="mb-3 flex items-center justify-between">
497
+ <h3 className="text-sm font-semibold text-gray-900">
498
+ {scope === 'catalog' ? t('marketplaceMcpSectionCatalog') : t('marketplaceMcpSectionInstalled')}
499
+ </h3>
500
+ <span className="text-xs text-gray-500">
501
+ {scope === 'catalog' ? (itemsQuery.data?.total ?? 0) : (installedQuery.data?.total ?? 0)}
502
+ </span>
503
+ </div>
504
+
505
+ <div className="min-h-0 flex-1 overflow-y-auto pr-1">
506
+ <div className={cn('grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3')}>
507
+ {scope === 'catalog' && itemsQuery.isLoading && Array.from({ length: 6 }, (_, index) => (
508
+ <div key={index} className="rounded-2xl border border-gray-200/70 bg-white p-4 shadow-sm">
509
+ <Skeleton className="h-4 w-32" />
510
+ <Skeleton className="mt-2 h-3 w-20" />
511
+ <Skeleton className="mt-3 h-3 w-full" />
512
+ <Skeleton className="mt-2 h-3 w-2/3" />
513
+ </div>
514
+ ))}
515
+
516
+ {scope === 'catalog' && !itemsQuery.isLoading && (itemsQuery.data?.items ?? []).map((item) => renderCard(item))}
517
+ {scope === 'installed' && installedRecords.map((record) => renderCard(undefined, record))}
518
+ </div>
519
+
520
+ {scope === 'catalog' && itemsQuery.isError && (
521
+ <div className="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
522
+ {itemsQuery.error.message}
523
+ </div>
524
+ )}
525
+ {scope === 'installed' && installedQuery.isError && (
526
+ <div className="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
527
+ {installedQuery.error.message}
528
+ </div>
529
+ )}
530
+ {scope === 'catalog' && !itemsQuery.isLoading && (itemsQuery.data?.items?.length ?? 0) === 0 && (
531
+ <div className="py-8 text-center text-sm text-gray-500">{t('marketplaceNoMcp')}</div>
532
+ )}
533
+ {scope === 'installed' && installedRecords.length === 0 && (
534
+ <div className="py-8 text-center text-sm text-gray-500">{t('marketplaceNoInstalledMcp')}</div>
535
+ )}
536
+ </div>
537
+ </section>
538
+
539
+ {scope === 'catalog' && (
540
+ <div className="mt-4 flex items-center justify-end gap-2">
541
+ <button
542
+ className="h-8 rounded-xl border border-gray-200/80 px-3 text-sm text-gray-600 disabled:opacity-40"
543
+ onClick={() => setPage((current) => Math.max(1, current - 1))}
544
+ disabled={page <= 1 || itemsQuery.isFetching}
545
+ >
546
+ {t('prev')}
547
+ </button>
548
+ <div className="min-w-20 text-center text-sm text-gray-600">
549
+ {itemsQuery.data?.totalPages ? `${page} / ${itemsQuery.data.totalPages}` : '0 / 0'}
550
+ </div>
551
+ <button
552
+ className="h-8 rounded-xl border border-gray-200/80 px-3 text-sm text-gray-600 disabled:opacity-40"
553
+ onClick={() => setPage((current) => current + 1)}
554
+ disabled={!itemsQuery.data?.totalPages || page >= itemsQuery.data.totalPages || itemsQuery.isFetching}
555
+ >
556
+ {t('next')}
557
+ </button>
558
+ </div>
559
+ )}
560
+
561
+ <InstallDialog
562
+ item={installingItem}
563
+ open={Boolean(installingItem)}
564
+ pending={installMutation.isPending}
565
+ onOpenChange={(open) => !open && setInstallingItem(null)}
566
+ onSubmit={handleInstall}
567
+ />
568
+ <DoctorDialog
569
+ open={Boolean(doctorTarget)}
570
+ targetName={doctorTarget}
571
+ result={doctorResult}
572
+ pending={doctorMutation.isPending}
573
+ onOpenChange={(open) => !open && setDoctorTarget(null)}
574
+ />
575
+ <ConfirmDialog />
576
+ </PageLayout>
577
+ );
578
+ }