@nextclaw/ui 0.5.1 → 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,9 +1,17 @@
1
- import { useEffect, useMemo, useState } from 'react';
2
- import { Download, PackageSearch, Sparkles, Store } from 'lucide-react';
3
- import { cn } from '@/lib/utils';
1
+ /* eslint-disable max-lines-per-function */
2
+ import type { MarketplaceInstalledRecord, MarketplaceItemSummary, MarketplaceManageAction, MarketplaceSort } from '@/api/types';
3
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
4
4
  import { Tabs } from '@/components/ui/tabs-custom';
5
- import { useInstallMarketplaceItem, useMarketplaceInstalled, useMarketplaceItems, useMarketplaceRecommendations } from '@/hooks/useMarketplace';
6
- import type { MarketplaceItemSummary, MarketplaceSort } from '@/api/types';
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';
7
15
 
8
16
  const PAGE_SIZE = 12;
9
17
 
@@ -15,42 +23,147 @@ type InstallState = {
15
23
  installingSpec?: string;
16
24
  };
17
25
 
18
- type InstalledSpecSets = {
19
- plugin: Set<string>;
20
- skill: Set<string>;
26
+ type ManageState = {
27
+ isPending: boolean;
28
+ targetId?: string;
29
+ action?: MarketplaceManageAction;
21
30
  };
22
31
 
23
- function buildInstalledSpecSets(records: { pluginSpecs: string[]; skillSpecs: string[] } | undefined): InstalledSpecSets {
24
- return {
25
- plugin: new Set(records?.pluginSpecs ?? []),
26
- skill: new Set(records?.skillSpecs ?? [])
27
- };
32
+ type InstalledRenderEntry = {
33
+ key: string;
34
+ record: MarketplaceInstalledRecord;
35
+ item?: MarketplaceItemSummary;
36
+ };
37
+
38
+ function normalizeMarketplaceKey(value: string | undefined): string {
39
+ return (value ?? '').trim().toLowerCase();
28
40
  }
29
41
 
30
- function isInstalled(item: MarketplaceItemSummary, sets: InstalledSpecSets): boolean {
31
- return item.type === 'plugin'
32
- ? sets.plugin.has(item.install.spec)
33
- : sets.skill.has(item.install.spec);
42
+ function toLookupKey(type: MarketplaceItemSummary['type'], value: string | undefined): string {
43
+ const normalized = normalizeMarketplaceKey(value);
44
+ return normalized.length > 0 ? `${type}:${normalized}` : '';
34
45
  }
35
46
 
36
- function TypeBadge({ type }: { type: MarketplaceItemSummary['type'] }) {
37
- return (
38
- <span
39
- className={cn(
40
- 'text-[11px] uppercase px-2 py-1 rounded-full font-semibold',
41
- type === 'plugin' ? 'bg-blue-50 text-blue-600' : 'bg-emerald-50 text-emerald-600'
42
- )}
43
- >
44
- {type}
45
- </span>
46
- );
47
+ function buildCatalogLookup(items: MarketplaceItemSummary[]): Map<string, MarketplaceItemSummary> {
48
+ const lookup = new Map<string, MarketplaceItemSummary>();
49
+
50
+ for (const item of items) {
51
+ const candidates = [item.install.spec, item.slug, item.id];
52
+ for (const candidate of candidates) {
53
+ const lookupKey = toLookupKey(item.type, candidate);
54
+ if (!lookupKey || lookup.has(lookupKey)) {
55
+ continue;
56
+ }
57
+ lookup.set(lookupKey, item);
58
+ }
59
+ }
60
+
61
+ return lookup;
62
+ }
63
+
64
+ function buildInstalledRecordLookup(records: MarketplaceInstalledRecord[]): Map<string, MarketplaceInstalledRecord> {
65
+ const lookup = new Map<string, MarketplaceInstalledRecord>();
66
+
67
+ for (const record of records) {
68
+ const candidates = [record.spec, record.id, record.label];
69
+ for (const candidate of candidates) {
70
+ const lookupKey = toLookupKey(record.type, candidate);
71
+ if (!lookupKey || lookup.has(lookupKey)) {
72
+ continue;
73
+ }
74
+ lookup.set(lookupKey, record);
75
+ }
76
+ }
77
+
78
+ return lookup;
47
79
  }
48
80
 
49
- function InstalledBadge() {
50
- return <span className="text-[11px] px-2 py-1 rounded-full font-semibold bg-indigo-50 text-indigo-600">Installed</span>;
81
+ function findInstalledRecordForItem(
82
+ item: MarketplaceItemSummary,
83
+ installedRecordLookup: Map<string, MarketplaceInstalledRecord>
84
+ ): MarketplaceInstalledRecord | undefined {
85
+ const candidates = [item.install.spec, item.slug, item.id];
86
+ for (const candidate of candidates) {
87
+ const lookupKey = toLookupKey(item.type, candidate);
88
+ if (!lookupKey) {
89
+ continue;
90
+ }
91
+ const record = installedRecordLookup.get(lookupKey);
92
+ if (record) {
93
+ return record;
94
+ }
95
+ }
96
+ return undefined;
97
+ }
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
+
116
+ function matchInstalledSearch(
117
+ record: MarketplaceInstalledRecord,
118
+ item: MarketplaceItemSummary | undefined,
119
+ query: string
120
+ ): boolean {
121
+ const normalizedQuery = normalizeMarketplaceKey(query);
122
+ if (!normalizedQuery) {
123
+ return true;
124
+ }
125
+
126
+ const values = [
127
+ record.id,
128
+ record.spec,
129
+ record.label,
130
+ item?.name,
131
+ item?.slug,
132
+ item?.summary,
133
+ ...(item?.tags ?? [])
134
+ ];
135
+
136
+ return values
137
+ .map((value) => normalizeMarketplaceKey(value))
138
+ .filter(Boolean)
139
+ .some((value) => value.includes(normalizedQuery));
140
+ }
141
+
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);
150
+ }
151
+ return colors[Math.abs(hash) % colors.length];
152
+ }
153
+
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);
158
+ return (
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}
161
+ </div>
162
+ );
51
163
  }
52
164
 
53
165
  function FilterPanel(props: {
166
+ scope: ScopeType;
54
167
  searchText: string;
55
168
  typeFilter: FilterType;
56
169
  sort: MarketplaceSort;
@@ -59,162 +172,160 @@ function FilterPanel(props: {
59
172
  onSortChange: (value: MarketplaceSort) => void;
60
173
  }) {
61
174
  return (
62
- <div className="bg-white border border-gray-200 rounded-2xl p-4 mb-5">
63
- <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
64
- <div className="md:col-span-2 relative">
175
+ <div className="bg-white border border-gray-200 rounded-xl p-4 mb-4">
176
+ <div className="flex gap-3 items-center">
177
+ <div className="flex-1 min-w-0 relative">
65
178
  <PackageSearch className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
66
179
  <input
67
180
  value={props.searchText}
68
181
  onChange={(event) => props.onSearchTextChange(event.target.value)}
69
- placeholder="Search by name, slug, tags..."
70
- className="w-full h-10 border border-gray-200 rounded-lg pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
182
+ placeholder="Search extensions..."
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"
71
184
  />
72
185
  </div>
73
186
 
74
- <div className="flex gap-2">
75
- <select
76
- className="flex-1 h-10 border border-gray-200 rounded-lg px-3 text-sm bg-white"
77
- value={props.typeFilter}
78
- onChange={(event) => props.onTypeFilterChange(event.target.value as FilterType)}
79
- >
80
- <option value="all">All</option>
81
- <option value="plugin">Plugins</option>
82
- <option value="skill">Skills</option>
83
- </select>
84
- <select
85
- className="flex-1 h-10 border border-gray-200 rounded-lg px-3 text-sm bg-white"
86
- value={props.sort}
87
- onChange={(event) => props.onSortChange(event.target.value as MarketplaceSort)}
88
- >
89
- <option value="relevance">Relevance</option>
90
- <option value="updated">Recently Updated</option>
91
- <option value="downloads">Downloads</option>
92
- </select>
187
+ <div className="inline-flex h-9 rounded-lg bg-gray-100 p-0.5 shrink-0">
188
+ {([
189
+ { value: 'all', label: 'All' },
190
+ { value: 'plugin', label: 'Plugins' },
191
+ { value: 'skill', label: 'Skills' },
192
+ ] as const).map((opt) => (
193
+ <button
194
+ key={opt.value}
195
+ type="button"
196
+ onClick={() => props.onTypeFilterChange(opt.value)}
197
+ className={cn(
198
+ 'px-3 rounded-md text-sm font-medium transition-all whitespace-nowrap',
199
+ props.typeFilter === opt.value
200
+ ? 'bg-white text-gray-900 shadow-sm'
201
+ : 'text-gray-500 hover:text-gray-700'
202
+ )}
203
+ >
204
+ {opt.label}
205
+ </button>
206
+ ))}
93
207
  </div>
208
+
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
+ )}
94
220
  </div>
95
221
  </div>
96
222
  );
97
223
  }
98
224
 
99
- function InstallButton(props: {
100
- item: MarketplaceItemSummary;
225
+ function MarketplaceListCard(props: {
226
+ item?: MarketplaceItemSummary;
227
+ record?: MarketplaceInstalledRecord;
101
228
  installState: InstallState;
102
- installed: boolean;
229
+ manageState: ManageState;
103
230
  onInstall: (item: MarketplaceItemSummary) => void;
231
+ onManage: (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => void;
104
232
  }) {
105
- const isInstalling = props.installState.isPending && props.installState.installingSpec === props.item.install.spec;
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 ?? '';
106
238
 
107
- if (props.installed) {
108
- return (
109
- <button
110
- disabled
111
- 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"
112
- >
113
- Installed
114
- </button>
115
- );
116
- }
239
+ const targetId = record?.id || record?.spec;
240
+ const busyForRecord = Boolean(targetId) && props.manageState.isPending && props.manageState.targetId === targetId;
117
241
 
118
- return (
119
- <button
120
- onClick={() => props.onInstall(props.item)}
121
- disabled={props.installState.isPending}
122
- 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"
123
- >
124
- <Download className="h-3.5 w-3.5" />
125
- {isInstalling ? 'Installing...' : 'Install'}
126
- </button>
127
- );
128
- }
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);
129
246
 
130
- function RecommendationSection(props: {
131
- items: MarketplaceItemSummary[];
132
- loading: boolean;
133
- installState: InstallState;
134
- installedSets: InstalledSpecSets;
135
- onInstall: (item: MarketplaceItemSummary) => void;
136
- }) {
137
- return (
138
- <section className="mb-6">
139
- <div className="flex items-center gap-2 mb-3">
140
- <Sparkles className="h-4 w-4 text-amber-500" />
141
- <h3 className="text-[15px] font-bold text-gray-900">Recommended</h3>
142
- </div>
143
-
144
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
145
- {props.items.map((item) => {
146
- const installed = isInstalled(item, props.installedSets);
147
- return (
148
- <div key={item.id} className="bg-white border border-gray-200 rounded-xl p-4">
149
- <div className="flex items-start justify-between gap-3">
150
- <div>
151
- <div className="text-[14px] font-semibold text-gray-900">{item.name}</div>
152
- <div className="text-[12px] text-gray-500 mt-0.5">{item.summary}</div>
153
- </div>
154
- <div className="flex items-center gap-2">
155
- <TypeBadge type={item.type} />
156
- {installed && <InstalledBadge />}
157
- </div>
158
- </div>
159
- <div className="mt-3 flex items-center justify-between">
160
- <code className="text-[11px] text-gray-500 bg-gray-100 rounded px-2 py-1">{item.install.spec}</code>
161
- <InstallButton
162
- item={item}
163
- installed={installed}
164
- installState={props.installState}
165
- onInstall={props.onInstall}
166
- />
167
- </div>
168
- </div>
169
- );
170
- })}
247
+ const isInstalling = props.installState.isPending && props.item && props.installState.installingSpec === props.item.install.spec;
171
248
 
172
- {props.loading && <div className="text-[13px] text-gray-500">Loading recommendations...</div>}
173
- {!props.loading && props.items.length === 0 && <div className="text-[13px] text-gray-500">No recommendations yet.</div>}
174
- </div>
175
- </section>
176
- );
177
- }
178
-
179
- function MarketplaceItemCard(props: {
180
- item: MarketplaceItemSummary;
181
- installState: InstallState;
182
- installed: boolean;
183
- onInstall: (item: MarketplaceItemSummary) => void;
184
- }) {
185
- const downloads = props.item.metrics?.downloads30d;
249
+ const displayType = type === 'plugin' ? 'Plugin' : type === 'skill' ? 'Skill' : 'Extension';
186
250
 
187
251
  return (
188
- <article className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-sm transition-shadow">
189
- <div className="flex items-start justify-between gap-2">
190
- <h4 className="text-[14px] font-semibold text-gray-900">{props.item.name}</h4>
191
- <div className="flex items-center gap-2">
192
- <TypeBadge type={props.item.type} />
193
- {props.installed && <InstalledBadge />}
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>
194
294
  </div>
195
295
  </div>
196
296
 
197
- <p className="text-[12px] text-gray-500 mt-1 min-h-10">{props.item.summary}</p>
198
-
199
- <div className="flex flex-wrap gap-1 mt-2">
200
- {props.item.tags.slice(0, 3).map((tag) => (
201
- <span key={`${props.item.id}-${tag}`} className="text-[11px] px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
202
- {tag}
203
- </span>
204
- ))}
205
- </div>
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
+ )}
206
307
 
207
- <div className="mt-3 text-[11px] text-gray-500">By {props.item.author}</div>
208
- <div className="mt-1 text-[11px] text-gray-500">{downloads ? `${downloads} downloads / 30d` : 'No metrics'}</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
+ )}
209
319
 
210
- <div className="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between gap-2">
211
- <code className="text-[11px] text-gray-500 bg-gray-100 rounded px-2 py-1 truncate">{props.item.install.spec}</code>
212
- <InstallButton
213
- item={props.item}
214
- installed={props.installed}
215
- installState={props.installState}
216
- onInstall={props.onInstall}
217
- />
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
+ )}
218
329
  </div>
219
330
  </article>
220
331
  );
@@ -228,7 +339,7 @@ function PaginationBar(props: {
228
339
  onNext: () => void;
229
340
  }) {
230
341
  return (
231
- <div className="mt-5 flex items-center justify-end gap-2">
342
+ <div className="mt-4 flex items-center justify-end gap-2">
232
343
  <button
233
344
  className="h-8 px-3 rounded-lg border border-gray-200 text-sm text-gray-700 disabled:opacity-40"
234
345
  onClick={props.onPrev}
@@ -267,46 +378,93 @@ export function MarketplacePage() {
267
378
  }, [searchText]);
268
379
 
269
380
  const installedQuery = useMarketplaceInstalled();
270
- const requestPage = scope === 'installed' ? 1 : page;
271
- const requestPageSize = scope === 'installed' ? 100 : PAGE_SIZE;
272
381
 
273
382
  const itemsQuery = useMarketplaceItems({
274
383
  q: query || undefined,
275
384
  type: typeFilter === 'all' ? undefined : typeFilter,
276
385
  sort,
277
- page: requestPage,
278
- pageSize: requestPageSize
386
+ page,
387
+ pageSize: PAGE_SIZE
279
388
  });
280
- const recommendationsQuery = useMarketplaceRecommendations({ scene: 'default', limit: 4 });
389
+
281
390
  const installMutation = useInstallMarketplaceItem();
391
+ const manageMutation = useManageMarketplaceItem();
282
392
 
283
- const installedSets = buildInstalledSpecSets(installedQuery.data);
284
- const allItems = itemsQuery.data?.items ?? [];
285
- const items = scope === 'installed'
286
- ? allItems.filter((item) => isInstalled(item, installedSets))
287
- : allItems;
393
+ const installedRecords = useMemo(
394
+ () => installedQuery.data?.records ?? [],
395
+ [installedQuery.data?.records]
396
+ );
397
+
398
+ const allItems = useMemo(
399
+ () => itemsQuery.data?.items ?? [],
400
+ [itemsQuery.data?.items]
401
+ );
402
+
403
+ const catalogLookup = useMemo(
404
+ () => buildCatalogLookup(allItems),
405
+ [allItems]
406
+ );
407
+
408
+ const installedRecordLookup = useMemo(
409
+ () => buildInstalledRecordLookup(installedRecords),
410
+ [installedRecords]
411
+ );
288
412
 
289
- const recommendations = recommendationsQuery.data?.items ?? [];
290
- const total = scope === 'installed'
291
- ? items.length
292
- : (itemsQuery.data?.total ?? 0);
413
+ const installedEntries = useMemo<InstalledRenderEntry[]>(() => {
414
+ const entries = installedRecords
415
+ .filter((record) => (typeFilter === 'all' ? true : record.type === typeFilter))
416
+ .map((record) => ({
417
+ key: `${record.type}:${record.spec}:${record.id ?? ''}`,
418
+ record,
419
+ item: findCatalogItemForRecord(record, catalogLookup)
420
+ }))
421
+ .filter((entry) => matchInstalledSearch(entry.record, entry.item, query));
422
+
423
+ entries.sort((left, right) => {
424
+ const leftTs = left.record.installedAt ? Date.parse(left.record.installedAt) : Number.NaN;
425
+ const rightTs = right.record.installedAt ? Date.parse(right.record.installedAt) : Number.NaN;
426
+ const leftValid = !Number.isNaN(leftTs);
427
+ const rightValid = !Number.isNaN(rightTs);
428
+
429
+ if (leftValid && rightValid && leftTs !== rightTs) {
430
+ return rightTs - leftTs;
431
+ }
432
+
433
+ return left.record.spec.localeCompare(right.record.spec);
434
+ });
435
+
436
+ return entries;
437
+ }, [installedRecords, typeFilter, catalogLookup, query]);
438
+
439
+ const total = scope === 'installed' ? installedEntries.length : (itemsQuery.data?.total ?? 0);
293
440
  const totalPages = scope === 'installed' ? 1 : (itemsQuery.data?.totalPages ?? 0);
294
441
 
295
442
  const listSummary = useMemo(() => {
443
+ if (scope === 'installed') {
444
+ if (installedQuery.isLoading) {
445
+ return 'Loading...';
446
+ }
447
+ return `${installedEntries.length} installed`;
448
+ }
449
+
296
450
  if (!itemsQuery.data) {
297
451
  return 'Loading...';
298
452
  }
299
- if (items.length === 0) {
300
- return scope === 'installed' ? 'No installed items on this page' : 'No results';
301
- }
302
- return `Showing ${items.length} / ${total}`;
303
- }, [items.length, itemsQuery.data, scope, total]);
453
+
454
+ return `${allItems.length} / ${total}`;
455
+ }, [scope, installedQuery.isLoading, installedEntries.length, itemsQuery.data, allItems.length, total]);
304
456
 
305
457
  const installState: InstallState = {
306
458
  isPending: installMutation.isPending,
307
459
  installingSpec: installMutation.variables?.spec
308
460
  };
309
461
 
462
+ const manageState: ManageState = {
463
+ isPending: manageMutation.isPending,
464
+ targetId: manageMutation.variables?.id || manageMutation.variables?.spec,
465
+ action: manageMutation.variables?.action
466
+ };
467
+
310
468
  const tabs = [
311
469
  { id: 'all', label: 'Marketplace' },
312
470
  { id: 'installed', label: 'Installed', count: installedQuery.data?.total ?? 0 }
@@ -316,20 +474,39 @@ export function MarketplacePage() {
316
474
  if (installMutation.isPending) {
317
475
  return;
318
476
  }
319
- installMutation.mutate({ type: item.type, spec: item.install.spec });
477
+ installMutation.mutate({ type: item.type, spec: item.install.spec, kind: item.install.kind });
478
+ };
479
+
480
+ const handleManage = (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => {
481
+ if (manageMutation.isPending) {
482
+ return;
483
+ }
484
+
485
+ const targetId = record.id || record.spec;
486
+ if (!targetId) {
487
+ return;
488
+ }
489
+
490
+ if (action === 'uninstall') {
491
+ const confirmed = window.confirm(`Confirm ${action} ${targetId}?`);
492
+ if (!confirmed) {
493
+ return;
494
+ }
495
+ }
496
+
497
+ manageMutation.mutate({
498
+ type: record.type,
499
+ action,
500
+ id: targetId,
501
+ spec: record.spec
502
+ });
320
503
  };
321
504
 
322
505
  return (
323
506
  <div className="animate-fade-in pb-20">
324
- <div className="flex items-center justify-between mb-6">
325
- <div>
326
- <h2 className="text-2xl font-bold text-gray-900">Marketplace</h2>
327
- <p className="text-[13px] text-gray-500 mt-1">Search, discover and install plugins/skills.</p>
328
- </div>
329
- <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">
330
- <Store className="h-3.5 w-3.5" />
331
- Read-only Catalog
332
- </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>
333
510
  </div>
334
511
 
335
512
  <Tabs
@@ -339,10 +516,11 @@ export function MarketplacePage() {
339
516
  setScope(value as ScopeType);
340
517
  setPage(1);
341
518
  }}
342
- className="mb-5"
519
+ className="mb-4"
343
520
  />
344
521
 
345
522
  <FilterPanel
523
+ scope={scope}
346
524
  searchText={searchText}
347
525
  typeFilter={typeFilter}
348
526
  sort={sort}
@@ -357,41 +535,55 @@ export function MarketplacePage() {
357
535
  }}
358
536
  />
359
537
 
360
- <RecommendationSection
361
- items={recommendations}
362
- loading={recommendationsQuery.isLoading}
363
- installState={installState}
364
- installedSets={installedSets}
365
- onInstall={handleInstall}
366
- />
367
-
368
538
  <section>
369
539
  <div className="flex items-center justify-between mb-3">
370
- <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>
371
541
  <span className="text-[12px] text-gray-500">{listSummary}</span>
372
542
  </div>
373
543
 
374
- {itemsQuery.isError && (
544
+ {scope === 'all' && itemsQuery.isError && (
375
545
  <div className="p-4 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 text-sm">
376
546
  Failed to load marketplace data: {itemsQuery.error.message}
377
547
  </div>
378
548
  )}
549
+ {scope === 'installed' && installedQuery.isError && (
550
+ <div className="p-4 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 text-sm">
551
+ Failed to load installed items: {installedQuery.error.message}
552
+ </div>
553
+ )}
379
554
 
380
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
381
- {items.map((item) => (
382
- <MarketplaceItemCard
555
+ <div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
556
+ {scope === 'all' && allItems.map((item) => (
557
+ <MarketplaceListCard
383
558
  key={item.id}
384
559
  item={item}
385
- installed={isInstalled(item, installedSets)}
560
+ record={findInstalledRecordForItem(item, installedRecordLookup)}
386
561
  installState={installState}
562
+ manageState={manageState}
387
563
  onInstall={handleInstall}
564
+ onManage={handleManage}
565
+ />
566
+ ))}
567
+
568
+ {scope === 'installed' && installedEntries.map((entry) => (
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}
388
577
  />
389
578
  ))}
390
579
  </div>
391
580
 
392
- {!itemsQuery.isLoading && !itemsQuery.isError && items.length === 0 && (
581
+ {scope === 'all' && !itemsQuery.isLoading && !itemsQuery.isError && allItems.length === 0 && (
393
582
  <div className="text-[13px] text-gray-500 py-8 text-center">No items found.</div>
394
583
  )}
584
+ {scope === 'installed' && !installedQuery.isLoading && !installedQuery.isError && installedEntries.length === 0 && (
585
+ <div className="text-[13px] text-gray-500 py-8 text-center">No installed items found.</div>
586
+ )}
395
587
  </section>
396
588
 
397
589
  {scope === 'all' && (