@nextclaw/ui 0.5.2 → 0.5.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.
@@ -1,10 +1,17 @@
1
- import { useEffect, useMemo, useState } from 'react';
2
- import { Download, PackageSearch, Sparkles, Store } from 'lucide-react';
3
- import { cn } from '@/lib/utils';
4
- import { Tabs } from '@/components/ui/tabs-custom';
5
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
6
- import { useInstallMarketplaceItem, useManageMarketplaceItem, useMarketplaceInstalled, useMarketplaceItems, useMarketplaceRecommendations } from '@/hooks/useMarketplace';
1
+ /* eslint-disable max-lines-per-function */
7
2
  import type { MarketplaceInstalledRecord, MarketplaceItemSummary, MarketplaceManageAction, MarketplaceSort } from '@/api/types';
3
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
4
+ import { Tabs } from '@/components/ui/tabs-custom';
5
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
6
+ import {
7
+ useInstallMarketplaceItem,
8
+ useManageMarketplaceItem,
9
+ useMarketplaceInstalled,
10
+ useMarketplaceItems
11
+ } from '@/hooks/useMarketplace';
12
+ import { cn } from '@/lib/utils';
13
+ import { PackageSearch } from 'lucide-react';
14
+ import { useEffect, useMemo, useState } from 'react';
8
15
 
9
16
  const PAGE_SIZE = 12;
10
17
 
@@ -22,11 +29,6 @@ type ManageState = {
22
29
  action?: MarketplaceManageAction;
23
30
  };
24
31
 
25
- type InstalledSpecSets = {
26
- plugin: Set<string>;
27
- skill: Set<string>;
28
- };
29
-
30
32
  type InstalledRenderEntry = {
31
33
  key: string;
32
34
  record: MarketplaceInstalledRecord;
@@ -42,37 +44,6 @@ function toLookupKey(type: MarketplaceItemSummary['type'], value: string | undef
42
44
  return normalized.length > 0 ? `${type}:${normalized}` : '';
43
45
  }
44
46
 
45
- function buildInstalledSpecSets(
46
- records: { pluginSpecs: string[]; skillSpecs: string[]; records: MarketplaceInstalledRecord[] } | undefined
47
- ): InstalledSpecSets {
48
- const plugin = new Set((records?.pluginSpecs ?? []).map((value) => normalizeMarketplaceKey(value)).filter(Boolean));
49
- const skill = new Set((records?.skillSpecs ?? []).map((value) => normalizeMarketplaceKey(value)).filter(Boolean));
50
-
51
- for (const record of records?.records ?? []) {
52
- const target = record.type === 'plugin' ? plugin : skill;
53
- const specKey = normalizeMarketplaceKey(record.spec);
54
- if (specKey) {
55
- target.add(specKey);
56
- }
57
- const labelKey = normalizeMarketplaceKey(record.label);
58
- if (labelKey) {
59
- target.add(labelKey);
60
- }
61
- const idKey = normalizeMarketplaceKey(record.id);
62
- if (idKey) {
63
- target.add(idKey);
64
- }
65
- }
66
-
67
- return { plugin, skill };
68
- }
69
-
70
- function isInstalled(item: MarketplaceItemSummary, sets: InstalledSpecSets): boolean {
71
- const target = item.type === 'plugin' ? sets.plugin : sets.skill;
72
- const candidateKeys = [item.install.spec, item.slug, item.id].map((value) => normalizeMarketplaceKey(value)).filter(Boolean);
73
- return candidateKeys.some((key) => target.has(key));
74
- }
75
-
76
47
  function buildCatalogLookup(items: MarketplaceItemSummary[]): Map<string, MarketplaceItemSummary> {
77
48
  const lookup = new Map<string, MarketplaceItemSummary>();
78
49
 
@@ -94,7 +65,7 @@ function buildInstalledRecordLookup(records: MarketplaceInstalledRecord[]): Map<
94
65
  const lookup = new Map<string, MarketplaceInstalledRecord>();
95
66
 
96
67
  for (const record of records) {
97
- const candidates = [record.spec, record.label, record.id];
68
+ const candidates = [record.spec, record.id, record.label];
98
69
  for (const candidate of candidates) {
99
70
  const lookupKey = toLookupKey(record.type, candidate);
100
71
  if (!lookupKey || lookup.has(lookupKey)) {
@@ -107,21 +78,6 @@ function buildInstalledRecordLookup(records: MarketplaceInstalledRecord[]): Map<
107
78
  return lookup;
108
79
  }
109
80
 
110
- function findCatalogItemForRecord(
111
- record: MarketplaceInstalledRecord,
112
- catalogLookup: Map<string, MarketplaceItemSummary>
113
- ): MarketplaceItemSummary | undefined {
114
- const bySpec = catalogLookup.get(toLookupKey(record.type, record.spec));
115
- if (bySpec) {
116
- return bySpec;
117
- }
118
- const byId = catalogLookup.get(toLookupKey(record.type, record.id));
119
- if (byId) {
120
- return byId;
121
- }
122
- return catalogLookup.get(toLookupKey(record.type, record.label));
123
- }
124
-
125
81
  function findInstalledRecordForItem(
126
82
  item: MarketplaceItemSummary,
127
83
  installedRecordLookup: Map<string, MarketplaceInstalledRecord>
@@ -140,6 +96,23 @@ function findInstalledRecordForItem(
140
96
  return undefined;
141
97
  }
142
98
 
99
+ function findCatalogItemForRecord(
100
+ record: MarketplaceInstalledRecord,
101
+ catalogLookup: Map<string, MarketplaceItemSummary>
102
+ ): MarketplaceItemSummary | undefined {
103
+ const bySpec = catalogLookup.get(toLookupKey(record.type, record.spec));
104
+ if (bySpec) {
105
+ return bySpec;
106
+ }
107
+
108
+ const byId = catalogLookup.get(toLookupKey(record.type, record.id));
109
+ if (byId) {
110
+ return byId;
111
+ }
112
+
113
+ return catalogLookup.get(toLookupKey(record.type, record.label));
114
+ }
115
+
143
116
  function matchInstalledSearch(
144
117
  record: MarketplaceInstalledRecord,
145
118
  item: MarketplaceItemSummary | undefined,
@@ -154,8 +127,6 @@ function matchInstalledSearch(
154
127
  record.id,
155
128
  record.spec,
156
129
  record.label,
157
- record.source,
158
- record.runtimeStatus,
159
130
  item?.name,
160
131
  item?.slug,
161
132
  item?.summary,
@@ -168,72 +139,31 @@ function matchInstalledSearch(
168
139
  .some((value) => value.includes(normalizedQuery));
169
140
  }
170
141
 
171
- function TypeBadge({ type }: { type: MarketplaceItemSummary['type'] }) {
172
- return (
173
- <span
174
- className={cn(
175
- 'text-[11px] uppercase px-2 py-1 rounded-full font-semibold',
176
- type === 'plugin' ? 'bg-blue-50 text-blue-600' : 'bg-emerald-50 text-emerald-600'
177
- )}
178
- >
179
- {type}
180
- </span>
181
- );
182
- }
183
-
184
- function InstalledBadge() {
185
- return <span className="text-[11px] px-2 py-1 rounded-full font-semibold bg-indigo-50 text-indigo-600">Installed</span>;
186
- }
187
-
188
- function EnabledStateBadge(props: { enabled?: boolean }) {
189
- if (props.enabled === undefined) {
190
- return null;
142
+ function getAvatarColor(text: string) {
143
+ const colors = [
144
+ 'bg-blue-500', 'bg-indigo-500', 'bg-purple-500', 'bg-pink-500',
145
+ 'bg-rose-500', 'bg-orange-500', 'bg-emerald-500', 'bg-teal-500', 'bg-cyan-500'
146
+ ];
147
+ let hash = 0;
148
+ for (let i = 0; i < text.length; i++) {
149
+ hash = text.charCodeAt(i) + ((hash << 5) - hash);
191
150
  }
192
-
193
- return props.enabled
194
- ? <span className="text-[11px] px-2 py-1 rounded-full font-semibold bg-emerald-50 text-emerald-600">Enabled</span>
195
- : <span className="text-[11px] px-2 py-1 rounded-full font-semibold bg-amber-50 text-amber-700">Disabled</span>;
151
+ return colors[Math.abs(hash) % colors.length];
196
152
  }
197
153
 
198
- function RecordActionButtons(props: {
199
- record: MarketplaceInstalledRecord;
200
- state: ManageState;
201
- onAction: (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => void;
202
- }) {
203
- const targetId = props.record.id || props.record.spec;
204
- const busyForRecord = props.state.isPending && props.state.targetId === targetId;
205
- const canToggle = props.record.type === 'plugin';
206
- const canUninstallPlugin = props.record.type === 'plugin' && props.record.origin !== 'bundled';
207
- const canUninstallSkill = props.record.type === 'skill' && props.record.source === 'workspace';
208
- const canUninstall = canUninstallPlugin || canUninstallSkill;
209
-
154
+ function ItemIcon({ name, fallback }: { name?: string; fallback: string }) {
155
+ const displayName = name || fallback;
156
+ const letters = displayName.substring(0, 2).toUpperCase();
157
+ const colorClass = getAvatarColor(displayName);
210
158
  return (
211
- <div className="flex items-center gap-2">
212
- {canToggle && (
213
- <button
214
- disabled={props.state.isPending}
215
- onClick={() => props.onAction(props.record.enabled === false ? 'enable' : 'disable', props.record)}
216
- className="inline-flex items-center h-8 px-3 rounded-lg text-xs font-semibold border border-gray-200 text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
217
- >
218
- {busyForRecord && props.state.action !== 'uninstall'
219
- ? (props.state.action === 'enable' ? 'Enabling...' : 'Disabling...')
220
- : (props.record.enabled === false ? 'Enable' : 'Disable')}
221
- </button>
222
- )}
223
- {canUninstall && (
224
- <button
225
- disabled={props.state.isPending}
226
- onClick={() => props.onAction('uninstall', props.record)}
227
- className="inline-flex items-center h-8 px-3 rounded-lg text-xs font-semibold border border-rose-200 text-rose-600 bg-white hover:bg-rose-50 disabled:opacity-50"
228
- >
229
- {busyForRecord && props.state.action === 'uninstall' ? 'Uninstalling...' : 'Uninstall'}
230
- </button>
231
- )}
159
+ <div className={cn("flex items-center justify-center w-11 h-11 rounded-2xl text-white font-bold text-base shrink-0 shadow-sm", colorClass)}>
160
+ {letters}
232
161
  </div>
233
162
  );
234
163
  }
235
164
 
236
165
  function FilterPanel(props: {
166
+ scope: ScopeType;
237
167
  searchText: string;
238
168
  typeFilter: FilterType;
239
169
  sort: MarketplaceSort;
@@ -242,19 +172,18 @@ function FilterPanel(props: {
242
172
  onSortChange: (value: MarketplaceSort) => void;
243
173
  }) {
244
174
  return (
245
- <div className="bg-white border border-gray-200 rounded-2xl p-4 mb-5">
175
+ <div className="bg-white border border-gray-200 rounded-xl p-4 mb-4">
246
176
  <div className="flex gap-3 items-center">
247
177
  <div className="flex-1 min-w-0 relative">
248
178
  <PackageSearch className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
249
179
  <input
250
180
  value={props.searchText}
251
181
  onChange={(event) => props.onSearchTextChange(event.target.value)}
252
- placeholder="Search by name, slug, tags..."
182
+ placeholder="Search extensions..."
253
183
  className="w-full h-9 border border-gray-200 rounded-lg pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
254
184
  />
255
185
  </div>
256
186
 
257
- {/* Segmented type filter */}
258
187
  <div className="inline-flex h-9 rounded-lg bg-gray-100 p-0.5 shrink-0">
259
188
  {([
260
189
  { value: 'all', label: 'All' },
@@ -277,185 +206,126 @@ function FilterPanel(props: {
277
206
  ))}
278
207
  </div>
279
208
 
280
- <Select value={props.sort} onValueChange={(v) => props.onSortChange(v as MarketplaceSort)}>
281
- <SelectTrigger className="h-9 w-[150px] shrink-0 rounded-lg">
282
- <SelectValue />
283
- </SelectTrigger>
284
- <SelectContent>
285
- <SelectItem value="relevance">Relevance</SelectItem>
286
- <SelectItem value="updated">Recently Updated</SelectItem>
287
- </SelectContent>
288
- </Select>
209
+ {props.scope === 'all' && (
210
+ <Select value={props.sort} onValueChange={(v) => props.onSortChange(v as MarketplaceSort)}>
211
+ <SelectTrigger className="h-9 w-[150px] shrink-0 rounded-lg">
212
+ <SelectValue />
213
+ </SelectTrigger>
214
+ <SelectContent>
215
+ <SelectItem value="relevance">Relevance</SelectItem>
216
+ <SelectItem value="updated">Recently Updated</SelectItem>
217
+ </SelectContent>
218
+ </Select>
219
+ )}
289
220
  </div>
290
221
  </div>
291
222
  );
292
223
  }
293
224
 
294
- function InstallButton(props: {
295
- item: MarketplaceItemSummary;
296
- installState: InstallState;
297
- installed: boolean;
298
- onInstall: (item: MarketplaceItemSummary) => void;
299
- }) {
300
- const isInstalling = props.installState.isPending && props.installState.installingSpec === props.item.install.spec;
301
-
302
- if (props.installed) {
303
- return (
304
- <button
305
- disabled
306
- className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg text-xs font-semibold bg-gray-100 text-gray-500 cursor-not-allowed"
307
- >
308
- Installed
309
- </button>
310
- );
311
- }
312
-
313
- return (
314
- <button
315
- onClick={() => props.onInstall(props.item)}
316
- disabled={props.installState.isPending}
317
- className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg text-xs font-semibold bg-gray-900 text-white hover:bg-black disabled:opacity-50"
318
- >
319
- <Download className="h-3.5 w-3.5" />
320
- {isInstalling ? 'Installing...' : 'Install'}
321
- </button>
322
- );
323
- }
324
-
325
- function RecommendationSection(props: {
326
- items: MarketplaceItemSummary[];
327
- loading: boolean;
328
- installState: InstallState;
329
- installedSets: InstalledSpecSets;
330
- onInstall: (item: MarketplaceItemSummary) => void;
331
- }) {
332
- return (
333
- <section className="mb-6">
334
- <div className="flex items-center gap-2 mb-3">
335
- <Sparkles className="h-4 w-4 text-amber-500" />
336
- <h3 className="text-[15px] font-bold text-gray-900">Recommended</h3>
337
- </div>
338
-
339
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
340
- {props.items.map((item) => {
341
- const installed = isInstalled(item, props.installedSets);
342
- return (
343
- <div key={item.id} className="bg-white border border-gray-200 rounded-xl p-4">
344
- <div className="flex items-start justify-between gap-3">
345
- <div>
346
- <div className="text-[14px] font-semibold text-gray-900">{item.name}</div>
347
- <div className="text-[12px] text-gray-500 mt-0.5">{item.summary}</div>
348
- </div>
349
- <div className="flex items-center gap-2">
350
- <TypeBadge type={item.type} />
351
- {installed && <InstalledBadge />}
352
- </div>
353
- </div>
354
- <div className="mt-3 flex items-center justify-between">
355
- <code className="text-[11px] text-gray-500 bg-gray-100 rounded px-2 py-1">{item.install.spec}</code>
356
- <InstallButton
357
- item={item}
358
- installed={installed}
359
- installState={props.installState}
360
- onInstall={props.onInstall}
361
- />
362
- </div>
363
- </div>
364
- );
365
- })}
366
-
367
- {props.loading && <div className="text-[13px] text-gray-500">Loading recommendations...</div>}
368
- {!props.loading && props.items.length === 0 && <div className="text-[13px] text-gray-500">No recommendations yet.</div>}
369
- </div>
370
- </section>
371
- );
372
- }
373
-
374
- function MarketplaceItemCard(props: {
375
- item: MarketplaceItemSummary;
376
- installedRecord?: MarketplaceInstalledRecord;
225
+ function MarketplaceListCard(props: {
226
+ item?: MarketplaceItemSummary;
227
+ record?: MarketplaceInstalledRecord;
377
228
  installState: InstallState;
378
229
  manageState: ManageState;
379
- installed: boolean;
380
230
  onInstall: (item: MarketplaceItemSummary) => void;
381
231
  onManage: (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => void;
382
232
  }) {
383
- return (
384
- <article className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-sm transition-shadow">
385
- <div className="flex items-start justify-between gap-2">
386
- <h4 className="text-[14px] font-semibold text-gray-900">{props.item.name}</h4>
387
- <div className="flex items-center gap-2">
388
- <TypeBadge type={props.item.type} />
389
- {props.installed && <InstalledBadge />}
390
- {props.installed && <EnabledStateBadge enabled={props.installedRecord?.enabled} />}
391
- </div>
392
- </div>
233
+ const record = props.record;
234
+ const type = props.item?.type ?? record?.type;
235
+ const title = props.item?.name ?? record?.label ?? record?.id ?? record?.spec ?? 'Unknown Item';
236
+ const summary = props.item?.summary ?? (record ? 'Installed locally. Details are currently unavailable from marketplace.' : '');
237
+ const spec = props.item?.install.spec ?? record?.spec ?? '';
393
238
 
394
- <p className="text-[12px] text-gray-500 mt-1 min-h-10">{props.item.summary}</p>
239
+ const targetId = record?.id || record?.spec;
240
+ const busyForRecord = Boolean(targetId) && props.manageState.isPending && props.manageState.targetId === targetId;
395
241
 
396
- <div className="flex flex-wrap gap-1 mt-2">
397
- {props.item.tags.slice(0, 3).map((tag) => (
398
- <span key={`${props.item.id}-${tag}`} className="text-[11px] px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
399
- {tag}
400
- </span>
401
- ))}
402
- </div>
403
-
404
- <div className="mt-3 text-[11px] text-gray-500">By {props.item.author}</div>
242
+ const canToggle = record?.type === 'plugin';
243
+ const canUninstallPlugin = record?.type === 'plugin' && record.origin !== 'bundled';
244
+ const canUninstallSkill = record?.type === 'skill' && record.source === 'workspace';
245
+ const canUninstall = Boolean(canUninstallPlugin || canUninstallSkill);
405
246
 
406
- <div className="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between gap-2">
407
- <code className="text-[11px] text-gray-500 bg-gray-100 rounded px-2 py-1 truncate">{props.item.install.spec}</code>
408
- {props.installed && props.installedRecord ? (
409
- <RecordActionButtons
410
- record={props.installedRecord}
411
- state={props.manageState}
412
- onAction={props.onManage}
413
- />
414
- ) : (
415
- <InstallButton
416
- item={props.item}
417
- installed={props.installed}
418
- installState={props.installState}
419
- onInstall={props.onInstall}
420
- />
421
- )}
422
- </div>
423
- </article>
424
- );
425
- }
247
+ const isInstalling = props.installState.isPending && props.item && props.installState.installingSpec === props.item.install.spec;
426
248
 
427
- function InstalledRecordCard(props: {
428
- record: MarketplaceInstalledRecord;
429
- manageState: ManageState;
430
- onManage: (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => void;
431
- }) {
432
- const installedAt = props.record.installedAt ? new Date(props.record.installedAt).toLocaleString() : undefined;
433
- const sourceHint = props.record.source ? `source: ${props.record.source}` : undefined;
249
+ const displayType = type === 'plugin' ? 'Plugin' : type === 'skill' ? 'Skill' : 'Extension';
434
250
 
435
251
  return (
436
- <article className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-sm transition-shadow" title={sourceHint}>
437
- <div className="flex items-start justify-between gap-2">
438
- <h4 className="text-[14px] font-semibold text-gray-900">{props.record.label || props.record.spec}</h4>
439
- <div className="flex items-center gap-2">
440
- <TypeBadge type={props.record.type} />
441
- <InstalledBadge />
442
- <EnabledStateBadge enabled={props.record.enabled} />
252
+ <article className="group bg-white border border-transparent hover:border-gray-200 rounded-xl px-4 py-3 hover:shadow-sm transition-all flex items-start gap-4 justify-between cursor-default h-[90px]">
253
+ <div className="flex gap-3 min-w-0 flex-1 h-full items-start">
254
+ <ItemIcon name={title} fallback={spec || 'Ext'} />
255
+ <div className="min-w-0 flex-1 flex flex-col justify-center h-full">
256
+ <TooltipProvider delayDuration={400}>
257
+ <Tooltip>
258
+ <TooltipTrigger asChild>
259
+ <div className="text-[14px] font-semibold text-gray-900 truncate leading-tight cursor-default">{title}</div>
260
+ </TooltipTrigger>
261
+ <TooltipContent className="max-w-[300px] text-xs">
262
+ {title}
263
+ </TooltipContent>
264
+ </Tooltip>
265
+
266
+ <div className="flex items-center gap-1.5 mt-0.5 mb-1.5">
267
+ <span className="text-[11px] text-gray-500 font-medium">{displayType}</span>
268
+ {spec && (
269
+ <>
270
+ <span className="text-[10px] text-gray-300">•</span>
271
+ <Tooltip>
272
+ <TooltipTrigger asChild>
273
+ <span className="text-[11px] text-gray-400 truncate max-w-full font-mono cursor-default">{spec}</span>
274
+ </TooltipTrigger>
275
+ <TooltipContent className="max-w-[300px] text-xs font-mono break-all">
276
+ {spec}
277
+ </TooltipContent>
278
+ </Tooltip>
279
+ </>
280
+ )}
281
+ </div>
282
+
283
+ <Tooltip>
284
+ <TooltipTrigger asChild>
285
+ <p className="text-[12px] text-gray-500/90 line-clamp-1 transition-colors leading-relaxed text-left cursor-default">{summary}</p>
286
+ </TooltipTrigger>
287
+ {summary && (
288
+ <TooltipContent className="max-w-[400px] text-xs leading-relaxed">
289
+ {summary}
290
+ </TooltipContent>
291
+ )}
292
+ </Tooltip>
293
+ </TooltipProvider>
443
294
  </div>
444
295
  </div>
445
296
 
446
- <p className="text-[12px] text-gray-500 mt-1 min-h-10">Installed locally. This item is not in the current marketplace catalog.</p>
297
+ <div className="shrink-0 flex items-center h-full">
298
+ {props.item && !record && (
299
+ <button
300
+ onClick={() => props.onInstall(props.item as MarketplaceItemSummary)}
301
+ disabled={props.installState.isPending}
302
+ className="inline-flex items-center gap-1.5 h-8 px-4 rounded-full text-xs font-semibold bg-gray-900 text-white hover:bg-black disabled:opacity-50 transition-colors"
303
+ >
304
+ {isInstalling ? 'Installing...' : 'Install'}
305
+ </button>
306
+ )}
447
307
 
448
- <div className="flex flex-wrap gap-1 mt-2">
449
- {installedAt && <span className="text-[11px] px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">{installedAt}</span>}
450
- </div>
308
+ {record && canToggle && (
309
+ <button
310
+ disabled={props.manageState.isPending}
311
+ onClick={() => props.onManage(record.enabled === false ? 'enable' : 'disable', record)}
312
+ className="inline-flex items-center h-8 px-4 rounded-full text-xs font-semibold border border-gray-200 text-gray-700 bg-white hover:bg-gray-50 hover:border-gray-300 disabled:opacity-50 transition-colors"
313
+ >
314
+ {busyForRecord && props.manageState.action !== 'uninstall'
315
+ ? (props.manageState.action === 'enable' ? 'Enabling...' : 'Disabling...')
316
+ : (record.enabled === false ? 'Enable' : 'Disable')}
317
+ </button>
318
+ )}
451
319
 
452
- <div className="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between gap-2">
453
- <code className="text-[11px] text-gray-500 bg-gray-100 rounded px-2 py-1 truncate" title={sourceHint}>{props.record.spec}</code>
454
- <RecordActionButtons
455
- record={props.record}
456
- state={props.manageState}
457
- onAction={props.onManage}
458
- />
320
+ {record && canUninstall && (
321
+ <button
322
+ disabled={props.manageState.isPending}
323
+ onClick={() => props.onManage('uninstall', record)}
324
+ className="inline-flex items-center h-8 px-4 rounded-full text-xs font-semibold border border-rose-100 text-rose-600 bg-white hover:bg-rose-50 hover:border-rose-200 disabled:opacity-50 transition-colors"
325
+ >
326
+ {busyForRecord && props.manageState.action === 'uninstall' ? 'Removing...' : 'Uninstall'}
327
+ </button>
328
+ )}
459
329
  </div>
460
330
  </article>
461
331
  );
@@ -469,7 +339,7 @@ function PaginationBar(props: {
469
339
  onNext: () => void;
470
340
  }) {
471
341
  return (
472
- <div className="mt-5 flex items-center justify-end gap-2">
342
+ <div className="mt-4 flex items-center justify-end gap-2">
473
343
  <button
474
344
  className="h-8 px-3 rounded-lg border border-gray-200 text-sm text-gray-700 disabled:opacity-40"
475
345
  onClick={props.onPrev}
@@ -516,7 +386,7 @@ export function MarketplacePage() {
516
386
  page,
517
387
  pageSize: PAGE_SIZE
518
388
  });
519
- const recommendationsQuery = useMarketplaceRecommendations({ scene: 'default', limit: 4 });
389
+
520
390
  const installMutation = useInstallMarketplaceItem();
521
391
  const manageMutation = useManageMarketplaceItem();
522
392
 
@@ -524,36 +394,30 @@ export function MarketplacePage() {
524
394
  () => installedQuery.data?.records ?? [],
525
395
  [installedQuery.data?.records]
526
396
  );
527
- const installedSets = buildInstalledSpecSets(installedQuery.data);
528
- const installedRecordLookup = useMemo(
529
- () => buildInstalledRecordLookup(installedRecords),
530
- [installedRecords]
531
- );
397
+
532
398
  const allItems = useMemo(
533
399
  () => itemsQuery.data?.items ?? [],
534
400
  [itemsQuery.data?.items]
535
401
  );
536
- const recommendations = useMemo(
537
- () => recommendationsQuery.data?.items ?? [],
538
- [recommendationsQuery.data?.items]
539
- );
540
402
 
541
403
  const catalogLookup = useMemo(
542
- () => buildCatalogLookup([...allItems, ...recommendations]),
543
- [allItems, recommendations]
404
+ () => buildCatalogLookup(allItems),
405
+ [allItems]
406
+ );
407
+
408
+ const installedRecordLookup = useMemo(
409
+ () => buildInstalledRecordLookup(installedRecords),
410
+ [installedRecords]
544
411
  );
545
412
 
546
413
  const installedEntries = useMemo<InstalledRenderEntry[]>(() => {
547
414
  const entries = installedRecords
548
415
  .filter((record) => (typeFilter === 'all' ? true : record.type === typeFilter))
549
- .map((record) => {
550
- const item = findCatalogItemForRecord(record, catalogLookup);
551
- return {
552
- key: `${record.type}:${record.spec}:${record.label ?? ''}`,
553
- record,
554
- item
555
- };
556
- })
416
+ .map((record) => ({
417
+ key: `${record.type}:${record.spec}:${record.id ?? ''}`,
418
+ record,
419
+ item: findCatalogItemForRecord(record, catalogLookup)
420
+ }))
557
421
  .filter((entry) => matchInstalledSearch(entry.record, entry.item, query));
558
422
 
559
423
  entries.sort((left, right) => {
@@ -572,9 +436,7 @@ export function MarketplacePage() {
572
436
  return entries;
573
437
  }, [installedRecords, typeFilter, catalogLookup, query]);
574
438
 
575
- const total = scope === 'installed'
576
- ? installedEntries.length
577
- : (itemsQuery.data?.total ?? 0);
439
+ const total = scope === 'installed' ? installedEntries.length : (itemsQuery.data?.total ?? 0);
578
440
  const totalPages = scope === 'installed' ? 1 : (itemsQuery.data?.totalPages ?? 0);
579
441
 
580
442
  const listSummary = useMemo(() => {
@@ -582,21 +444,15 @@ export function MarketplacePage() {
582
444
  if (installedQuery.isLoading) {
583
445
  return 'Loading...';
584
446
  }
585
- if (installedEntries.length === 0) {
586
- return 'No installed items';
587
- }
588
- const installedTotal = installedQuery.data?.total ?? installedEntries.length;
589
- return `Showing ${installedEntries.length} / ${installedTotal}`;
447
+ return `${installedEntries.length} installed`;
590
448
  }
591
449
 
592
450
  if (!itemsQuery.data) {
593
451
  return 'Loading...';
594
452
  }
595
- if (allItems.length === 0) {
596
- return 'No results';
597
- }
598
- return `Showing ${allItems.length} / ${total}`;
599
- }, [scope, installedQuery.isLoading, installedQuery.data, installedEntries.length, itemsQuery.data, allItems.length, total]);
453
+
454
+ return `${allItems.length} / ${total}`;
455
+ }, [scope, installedQuery.isLoading, installedEntries.length, itemsQuery.data, allItems.length, total]);
600
456
 
601
457
  const installState: InstallState = {
602
458
  isPending: installMutation.isPending,
@@ -648,15 +504,9 @@ export function MarketplacePage() {
648
504
 
649
505
  return (
650
506
  <div className="animate-fade-in pb-20">
651
- <div className="flex items-center justify-between mb-6">
652
- <div>
653
- <h2 className="text-2xl font-bold text-gray-900">Marketplace</h2>
654
- <p className="text-[13px] text-gray-500 mt-1">Search, discover and install plugins/skills.</p>
655
- </div>
656
- <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-indigo-50 text-indigo-600 text-xs font-semibold">
657
- <Store className="h-3.5 w-3.5" />
658
- Read-only Catalog
659
- </div>
507
+ <div className="mb-5">
508
+ <h2 className="text-2xl font-bold text-gray-900">Marketplace</h2>
509
+ <p className="text-[13px] text-gray-500 mt-1">A cleaner extension list focused on install / enable / disable.</p>
660
510
  </div>
661
511
 
662
512
  <Tabs
@@ -666,10 +516,11 @@ export function MarketplacePage() {
666
516
  setScope(value as ScopeType);
667
517
  setPage(1);
668
518
  }}
669
- className="mb-5"
519
+ className="mb-4"
670
520
  />
671
521
 
672
522
  <FilterPanel
523
+ scope={scope}
673
524
  searchText={searchText}
674
525
  typeFilter={typeFilter}
675
526
  sort={sort}
@@ -684,19 +535,9 @@ export function MarketplacePage() {
684
535
  }}
685
536
  />
686
537
 
687
- {scope === 'all' && (
688
- <RecommendationSection
689
- items={recommendations}
690
- loading={recommendationsQuery.isLoading}
691
- installState={installState}
692
- installedSets={installedSets}
693
- onInstall={handleInstall}
694
- />
695
- )}
696
-
697
538
  <section>
698
539
  <div className="flex items-center justify-between mb-3">
699
- <h3 className="text-[15px] font-bold text-gray-900">{scope === 'installed' ? 'Installed Items' : 'All Items'}</h3>
540
+ <h3 className="text-[15px] font-bold text-gray-900">{scope === 'installed' ? 'Installed' : 'Extensions'}</h3>
700
541
  <span className="text-[12px] text-gray-500">{listSummary}</span>
701
542
  </div>
702
543
 
@@ -711,13 +552,12 @@ export function MarketplacePage() {
711
552
  </div>
712
553
  )}
713
554
 
714
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
555
+ <div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
715
556
  {scope === 'all' && allItems.map((item) => (
716
- <MarketplaceItemCard
557
+ <MarketplaceListCard
717
558
  key={item.id}
718
559
  item={item}
719
- installedRecord={findInstalledRecordForItem(item, installedRecordLookup)}
720
- installed={isInstalled(item, installedSets)}
560
+ record={findInstalledRecordForItem(item, installedRecordLookup)}
721
561
  installState={installState}
722
562
  manageState={manageState}
723
563
  onInstall={handleInstall}
@@ -726,20 +566,15 @@ export function MarketplacePage() {
726
566
  ))}
727
567
 
728
568
  {scope === 'installed' && installedEntries.map((entry) => (
729
- entry.item
730
- ? (
731
- <MarketplaceItemCard
732
- key={`catalog:${entry.key}:${entry.item.id}`}
733
- item={entry.item}
734
- installedRecord={entry.record}
735
- installed
736
- installState={installState}
737
- manageState={manageState}
738
- onInstall={handleInstall}
739
- onManage={handleManage}
740
- />
741
- )
742
- : <InstalledRecordCard key={`local:${entry.key}`} record={entry.record} manageState={manageState} onManage={handleManage} />
569
+ <MarketplaceListCard
570
+ key={entry.key}
571
+ item={entry.item}
572
+ record={entry.record}
573
+ installState={installState}
574
+ manageState={manageState}
575
+ onInstall={handleInstall}
576
+ onManage={handleManage}
577
+ />
743
578
  ))}
744
579
  </div>
745
580