@nextclaw/ui 0.5.2 → 0.5.4

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,128 @@ 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 pluginRecord = record?.type === 'plugin' ? record : undefined;
235
+ const type = props.item?.type ?? record?.type;
236
+ const title = props.item?.name ?? record?.label ?? record?.id ?? record?.spec ?? 'Unknown Item';
237
+ const summary = props.item?.summary ?? (record ? 'Installed locally. Details are currently unavailable from marketplace.' : '');
238
+ const spec = props.item?.install.spec ?? record?.spec ?? '';
393
239
 
394
- <p className="text-[12px] text-gray-500 mt-1 min-h-10">{props.item.summary}</p>
240
+ const targetId = record?.id || record?.spec;
241
+ const busyForRecord = Boolean(targetId) && props.manageState.isPending && props.manageState.targetId === targetId;
395
242
 
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>
243
+ const canToggle = Boolean(pluginRecord);
244
+ const canUninstallPlugin = record?.type === 'plugin' && record.origin !== 'bundled';
245
+ const canUninstallSkill = record?.type === 'skill' && record.source === 'workspace';
246
+ const canUninstall = Boolean(canUninstallPlugin || canUninstallSkill);
405
247
 
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
- }
248
+ const isDisabled = record ? (record.enabled === false || record.runtimeStatus === 'disabled') : false;
249
+ const isInstalling = props.installState.isPending && props.item && props.installState.installingSpec === props.item.install.spec;
426
250
 
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;
251
+ const displayType = type === 'plugin' ? 'Plugin' : type === 'skill' ? 'Skill' : 'Extension';
434
252
 
435
253
  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} />
254
+ <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]">
255
+ <div className="flex gap-3 min-w-0 flex-1 h-full items-start">
256
+ <ItemIcon name={title} fallback={spec || 'Ext'} />
257
+ <div className="min-w-0 flex-1 flex flex-col justify-center h-full">
258
+ <TooltipProvider delayDuration={400}>
259
+ <Tooltip>
260
+ <TooltipTrigger asChild>
261
+ <div className="text-[14px] font-semibold text-gray-900 truncate leading-tight cursor-default">{title}</div>
262
+ </TooltipTrigger>
263
+ <TooltipContent className="max-w-[300px] text-xs">
264
+ {title}
265
+ </TooltipContent>
266
+ </Tooltip>
267
+
268
+ <div className="flex items-center gap-1.5 mt-0.5 mb-1.5">
269
+ <span className="text-[11px] text-gray-500 font-medium">{displayType}</span>
270
+ {spec && (
271
+ <>
272
+ <span className="text-[10px] text-gray-300">•</span>
273
+ <Tooltip>
274
+ <TooltipTrigger asChild>
275
+ <span className="text-[11px] text-gray-400 truncate max-w-full font-mono cursor-default">{spec}</span>
276
+ </TooltipTrigger>
277
+ <TooltipContent className="max-w-[300px] text-xs font-mono break-all">
278
+ {spec}
279
+ </TooltipContent>
280
+ </Tooltip>
281
+ </>
282
+ )}
283
+ </div>
284
+
285
+ <Tooltip>
286
+ <TooltipTrigger asChild>
287
+ <p className="text-[12px] text-gray-500/90 line-clamp-1 transition-colors leading-relaxed text-left cursor-default">{summary}</p>
288
+ </TooltipTrigger>
289
+ {summary && (
290
+ <TooltipContent className="max-w-[400px] text-xs leading-relaxed">
291
+ {summary}
292
+ </TooltipContent>
293
+ )}
294
+ </Tooltip>
295
+ </TooltipProvider>
443
296
  </div>
444
297
  </div>
445
298
 
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>
299
+ <div className="shrink-0 flex items-center h-full">
300
+ {props.item && !record && (
301
+ <button
302
+ onClick={() => props.onInstall(props.item as MarketplaceItemSummary)}
303
+ disabled={props.installState.isPending}
304
+ 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"
305
+ >
306
+ {isInstalling ? 'Installing...' : 'Install'}
307
+ </button>
308
+ )}
447
309
 
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>
310
+ {pluginRecord && canToggle && (
311
+ <button
312
+ disabled={props.manageState.isPending}
313
+ onClick={() => props.onManage(isDisabled ? 'enable' : 'disable', pluginRecord)}
314
+ 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"
315
+ >
316
+ {busyForRecord && props.manageState.action !== 'uninstall'
317
+ ? (props.manageState.action === 'enable' ? 'Enabling...' : 'Disabling...')
318
+ : (isDisabled ? 'Enable' : 'Disable')}
319
+ </button>
320
+ )}
451
321
 
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
- />
322
+ {record && canUninstall && (
323
+ <button
324
+ disabled={props.manageState.isPending}
325
+ onClick={() => props.onManage('uninstall', record)}
326
+ 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"
327
+ >
328
+ {busyForRecord && props.manageState.action === 'uninstall' ? 'Removing...' : 'Uninstall'}
329
+ </button>
330
+ )}
459
331
  </div>
460
332
  </article>
461
333
  );
@@ -469,7 +341,7 @@ function PaginationBar(props: {
469
341
  onNext: () => void;
470
342
  }) {
471
343
  return (
472
- <div className="mt-5 flex items-center justify-end gap-2">
344
+ <div className="mt-4 flex items-center justify-end gap-2">
473
345
  <button
474
346
  className="h-8 px-3 rounded-lg border border-gray-200 text-sm text-gray-700 disabled:opacity-40"
475
347
  onClick={props.onPrev}
@@ -516,7 +388,7 @@ export function MarketplacePage() {
516
388
  page,
517
389
  pageSize: PAGE_SIZE
518
390
  });
519
- const recommendationsQuery = useMarketplaceRecommendations({ scene: 'default', limit: 4 });
391
+
520
392
  const installMutation = useInstallMarketplaceItem();
521
393
  const manageMutation = useManageMarketplaceItem();
522
394
 
@@ -524,36 +396,30 @@ export function MarketplacePage() {
524
396
  () => installedQuery.data?.records ?? [],
525
397
  [installedQuery.data?.records]
526
398
  );
527
- const installedSets = buildInstalledSpecSets(installedQuery.data);
528
- const installedRecordLookup = useMemo(
529
- () => buildInstalledRecordLookup(installedRecords),
530
- [installedRecords]
531
- );
399
+
532
400
  const allItems = useMemo(
533
401
  () => itemsQuery.data?.items ?? [],
534
402
  [itemsQuery.data?.items]
535
403
  );
536
- const recommendations = useMemo(
537
- () => recommendationsQuery.data?.items ?? [],
538
- [recommendationsQuery.data?.items]
539
- );
540
404
 
541
405
  const catalogLookup = useMemo(
542
- () => buildCatalogLookup([...allItems, ...recommendations]),
543
- [allItems, recommendations]
406
+ () => buildCatalogLookup(allItems),
407
+ [allItems]
408
+ );
409
+
410
+ const installedRecordLookup = useMemo(
411
+ () => buildInstalledRecordLookup(installedRecords),
412
+ [installedRecords]
544
413
  );
545
414
 
546
415
  const installedEntries = useMemo<InstalledRenderEntry[]>(() => {
547
416
  const entries = installedRecords
548
417
  .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
- })
418
+ .map((record) => ({
419
+ key: `${record.type}:${record.spec}:${record.id ?? ''}`,
420
+ record,
421
+ item: findCatalogItemForRecord(record, catalogLookup)
422
+ }))
557
423
  .filter((entry) => matchInstalledSearch(entry.record, entry.item, query));
558
424
 
559
425
  entries.sort((left, right) => {
@@ -572,9 +438,7 @@ export function MarketplacePage() {
572
438
  return entries;
573
439
  }, [installedRecords, typeFilter, catalogLookup, query]);
574
440
 
575
- const total = scope === 'installed'
576
- ? installedEntries.length
577
- : (itemsQuery.data?.total ?? 0);
441
+ const total = scope === 'installed' ? installedEntries.length : (itemsQuery.data?.total ?? 0);
578
442
  const totalPages = scope === 'installed' ? 1 : (itemsQuery.data?.totalPages ?? 0);
579
443
 
580
444
  const listSummary = useMemo(() => {
@@ -582,21 +446,15 @@ export function MarketplacePage() {
582
446
  if (installedQuery.isLoading) {
583
447
  return 'Loading...';
584
448
  }
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}`;
449
+ return `${installedEntries.length} installed`;
590
450
  }
591
451
 
592
452
  if (!itemsQuery.data) {
593
453
  return 'Loading...';
594
454
  }
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]);
455
+
456
+ return `${allItems.length} / ${total}`;
457
+ }, [scope, installedQuery.isLoading, installedEntries.length, itemsQuery.data, allItems.length, total]);
600
458
 
601
459
  const installState: InstallState = {
602
460
  isPending: installMutation.isPending,
@@ -648,15 +506,9 @@ export function MarketplacePage() {
648
506
 
649
507
  return (
650
508
  <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>
509
+ <div className="mb-5">
510
+ <h2 className="text-2xl font-bold text-gray-900">Marketplace</h2>
511
+ <p className="text-[13px] text-gray-500 mt-1">A cleaner extension list focused on install / enable / disable.</p>
660
512
  </div>
661
513
 
662
514
  <Tabs
@@ -666,10 +518,11 @@ export function MarketplacePage() {
666
518
  setScope(value as ScopeType);
667
519
  setPage(1);
668
520
  }}
669
- className="mb-5"
521
+ className="mb-4"
670
522
  />
671
523
 
672
524
  <FilterPanel
525
+ scope={scope}
673
526
  searchText={searchText}
674
527
  typeFilter={typeFilter}
675
528
  sort={sort}
@@ -684,19 +537,9 @@ export function MarketplacePage() {
684
537
  }}
685
538
  />
686
539
 
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
540
  <section>
698
541
  <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>
542
+ <h3 className="text-[15px] font-bold text-gray-900">{scope === 'installed' ? 'Installed' : 'Extensions'}</h3>
700
543
  <span className="text-[12px] text-gray-500">{listSummary}</span>
701
544
  </div>
702
545
 
@@ -711,13 +554,12 @@ export function MarketplacePage() {
711
554
  </div>
712
555
  )}
713
556
 
714
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
557
+ <div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
715
558
  {scope === 'all' && allItems.map((item) => (
716
- <MarketplaceItemCard
559
+ <MarketplaceListCard
717
560
  key={item.id}
718
561
  item={item}
719
- installedRecord={findInstalledRecordForItem(item, installedRecordLookup)}
720
- installed={isInstalled(item, installedSets)}
562
+ record={findInstalledRecordForItem(item, installedRecordLookup)}
721
563
  installState={installState}
722
564
  manageState={manageState}
723
565
  onInstall={handleInstall}
@@ -726,20 +568,15 @@ export function MarketplacePage() {
726
568
  ))}
727
569
 
728
570
  {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} />
571
+ <MarketplaceListCard
572
+ key={entry.key}
573
+ item={entry.item}
574
+ record={entry.record}
575
+ installState={installState}
576
+ manageState={manageState}
577
+ onInstall={handleInstall}
578
+ onManage={handleManage}
579
+ />
743
580
  ))}
744
581
  </div>
745
582