@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
@@ -1,6 +1,8 @@
1
1
  import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
2
3
  import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
3
4
  import type {
5
+ MarketplaceInstalledRecord,
4
6
  MarketplaceInstalledView,
5
7
  MarketplaceItemSummary,
6
8
  MarketplaceListView
@@ -35,6 +37,7 @@ const mocks = vi.hoisted(() => ({
35
37
  },
36
38
  manageMutation: {
37
39
  mutate: vi.fn(),
40
+ mutateAsync: vi.fn(),
38
41
  isPending: false,
39
42
  variables: undefined
40
43
  }
@@ -95,6 +98,37 @@ function createMarketplaceItem(overrides: Partial<MarketplaceItemSummary> = {}):
95
98
  };
96
99
  }
97
100
 
101
+ function createPluginMarketplaceItem(overrides: Partial<MarketplaceItemSummary> = {}): MarketplaceItemSummary {
102
+ return createMarketplaceItem({
103
+ id: 'plugin-codex-runtime',
104
+ slug: 'codex-runtime',
105
+ type: 'plugin',
106
+ name: 'Codex SDK NCP Runtime',
107
+ summary: 'Optional Codex runtime for NextClaw',
108
+ summaryI18n: { en: 'Optional Codex runtime for NextClaw' },
109
+ install: {
110
+ kind: 'npm',
111
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
112
+ command: 'npm install @nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk'
113
+ },
114
+ ...overrides
115
+ });
116
+ }
117
+
118
+ function createInstalledRecord(overrides: Partial<MarketplaceInstalledRecord> = {}): MarketplaceInstalledRecord {
119
+ return {
120
+ type: 'plugin',
121
+ id: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
122
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
123
+ label: 'Codex SDK NCP Runtime',
124
+ enabled: true,
125
+ origin: 'marketplace',
126
+ source: 'marketplace',
127
+ installedAt: '2026-03-19T00:00:00.000Z',
128
+ ...overrides
129
+ };
130
+ }
131
+
98
132
  function createItemsQuery(overrides: Partial<Record<string, unknown>> = {}) {
99
133
  return {
100
134
  data: undefined as MarketplaceListView | undefined,
@@ -129,6 +163,7 @@ describe('MarketplacePage', () => {
129
163
  mocks.confirm.mockReset();
130
164
  mocks.installMutation.mutateAsync.mockReset();
131
165
  mocks.manageMutation.mutate.mockReset();
166
+ mocks.manageMutation.mutateAsync.mockReset();
132
167
  mocks.installMutation.isPending = false;
133
168
  mocks.installMutation.variables = undefined;
134
169
  mocks.manageMutation.isPending = false;
@@ -167,4 +202,121 @@ describe('MarketplacePage', () => {
167
202
  expect(screen.queryByTestId('marketplace-list-skeleton')).toBeNull();
168
203
  expect(screen.getByText('Web Search')).toBeTruthy();
169
204
  });
205
+
206
+ it('does not render the redundant plugin type label in plugin cards', () => {
207
+ mocks.itemsQuery = createItemsQuery({
208
+ data: {
209
+ total: 1,
210
+ page: 1,
211
+ pageSize: 12,
212
+ totalPages: 1,
213
+ sort: 'relevance',
214
+ items: [
215
+ createMarketplaceItem({
216
+ id: 'plugin-codex-runtime',
217
+ slug: 'codex-runtime',
218
+ type: 'plugin',
219
+ name: 'Codex SDK NCP Runtime',
220
+ summary: 'Optional Codex runtime for NextClaw',
221
+ summaryI18n: { en: 'Optional Codex runtime for NextClaw' },
222
+ install: {
223
+ kind: 'npm',
224
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
225
+ command: 'npm install @nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk'
226
+ }
227
+ })
228
+ ]
229
+ } satisfies MarketplaceListView
230
+ });
231
+
232
+ const { container } = render(<MarketplacePage forcedType="plugins" />);
233
+ const card = container.querySelector('article');
234
+
235
+ expect(card?.textContent).toContain('@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk');
236
+ expect(card?.textContent).not.toContain('Plugin');
237
+ });
238
+
239
+ it('does not dim the loaded list during background refresh', () => {
240
+ mocks.itemsQuery = createItemsQuery({
241
+ data: {
242
+ total: 1,
243
+ page: 1,
244
+ pageSize: 12,
245
+ totalPages: 1,
246
+ sort: 'relevance',
247
+ items: [createPluginMarketplaceItem()]
248
+ } satisfies MarketplaceListView,
249
+ isFetching: true
250
+ });
251
+
252
+ const { container } = render(<MarketplacePage forcedType="plugins" />);
253
+
254
+ expect(screen.getByText('Codex SDK NCP Runtime')).toBeTruthy();
255
+ expect(container.querySelector('.opacity-70')).toBeNull();
256
+ });
257
+
258
+ it('only disables the targeted plugin action while a manage request is pending', async () => {
259
+ const user = userEvent.setup();
260
+ let resolveMutation: (() => void) | undefined;
261
+ mocks.itemsQuery = createItemsQuery({
262
+ data: {
263
+ total: 2,
264
+ page: 1,
265
+ pageSize: 12,
266
+ totalPages: 1,
267
+ sort: 'relevance',
268
+ items: [
269
+ createPluginMarketplaceItem(),
270
+ createPluginMarketplaceItem({
271
+ id: 'plugin-claude-runtime',
272
+ slug: 'claude-runtime',
273
+ name: 'Claude Agent Runtime',
274
+ install: {
275
+ kind: 'npm',
276
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-claude-code-sdk',
277
+ command: 'npm install @nextclaw/nextclaw-ncp-runtime-plugin-claude-code-sdk'
278
+ }
279
+ })
280
+ ]
281
+ } satisfies MarketplaceListView
282
+ });
283
+ mocks.installedQuery = createInstalledQuery({
284
+ data: {
285
+ type: 'plugin',
286
+ total: 2,
287
+ specs: [
288
+ '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
289
+ '@nextclaw/nextclaw-ncp-runtime-plugin-claude-code-sdk'
290
+ ],
291
+ records: [
292
+ createInstalledRecord(),
293
+ createInstalledRecord({
294
+ id: '@nextclaw/nextclaw-ncp-runtime-plugin-claude-code-sdk',
295
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-claude-code-sdk',
296
+ label: 'Claude Agent Runtime'
297
+ })
298
+ ]
299
+ } satisfies MarketplaceInstalledView
300
+ });
301
+ mocks.manageMutation.mutateAsync.mockImplementation(
302
+ () => new Promise<void>((resolve) => {
303
+ resolveMutation = resolve;
304
+ })
305
+ );
306
+
307
+ render(<MarketplacePage forcedType="plugins" />);
308
+
309
+ const disableButtons = screen.getAllByRole('button', { name: 'Disable' });
310
+ const firstDisableButton = disableButtons[0];
311
+ const secondDisableButton = disableButtons[1];
312
+
313
+ await user.click(firstDisableButton);
314
+
315
+ expect(mocks.manageMutation.mutateAsync).toHaveBeenCalledTimes(1);
316
+ expect(firstDisableButton.hasAttribute('disabled')).toBe(true);
317
+ expect(firstDisableButton.textContent).toContain('Disabling');
318
+ expect(secondDisableButton.hasAttribute('disabled')).toBe(false);
319
+
320
+ resolveMutation?.();
321
+ });
170
322
  });
@@ -2,7 +2,6 @@
2
2
  import type {
3
3
  MarketplaceInstalledRecord,
4
4
  MarketplaceItemSummary,
5
- MarketplaceLocalizedTextMap,
6
5
  MarketplaceManageAction,
7
6
  MarketplacePluginContentView,
8
7
  MarketplaceSkillContentView,
@@ -10,8 +9,6 @@ import type {
10
9
  MarketplaceItemType
11
10
  } from '@/api/types';
12
11
  import { fetchMarketplacePluginContent, fetchMarketplaceSkillContent } from '@/api/marketplace';
13
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
14
- import { Skeleton } from '@/components/ui/skeleton';
15
12
  import { Tabs } from '@/components/ui/tabs-custom';
16
13
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
17
14
  import { useDocBrowser } from '@/components/doc-browser';
@@ -23,10 +20,15 @@ import {
23
20
  useMarketplaceInstalled,
24
21
  useMarketplaceItems
25
22
  } from '@/hooks/useMarketplace';
23
+ import {
24
+ FilterPanel,
25
+ MarketplaceListSkeleton,
26
+ PaginationBar
27
+ } from '@/components/marketplace/marketplace-page-parts';
28
+ import { buildLocaleFallbacks, pickLocalizedText } from '@/components/marketplace/marketplace-localization';
26
29
  import { t } from '@/lib/i18n';
27
30
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
28
31
  import { cn } from '@/lib/utils';
29
- import { PackageSearch } from 'lucide-react';
30
32
  import { useEffect, useMemo, useState } from 'react';
31
33
  import { useNavigate, useParams } from 'react-router-dom';
32
34
 
@@ -40,9 +42,7 @@ type InstallState = {
40
42
  };
41
43
 
42
44
  type ManageState = {
43
- isPending: boolean;
44
- targetId?: string;
45
- action?: MarketplaceManageAction;
45
+ actionsByTarget: ReadonlyMap<string, MarketplaceManageAction>;
46
46
  };
47
47
 
48
48
  type InstalledRenderEntry = {
@@ -134,56 +134,6 @@ function findCatalogItemForRecord(
134
134
  return catalogLookup.get(toLookupKey(record.type, record.label));
135
135
  }
136
136
 
137
- function buildLocaleFallbacks(language: string): string[] {
138
- const normalized = language.trim().toLowerCase().replace(/_/g, '-');
139
- const base = normalized.split('-')[0];
140
- const fallbacks = [normalized, base, 'en'];
141
- return Array.from(new Set(fallbacks.filter(Boolean)));
142
- }
143
-
144
- function normalizeLocaleTag(locale: string): string {
145
- return locale.trim().toLowerCase().replace(/_/g, '-');
146
- }
147
-
148
- function pickLocalizedText(
149
- localized: MarketplaceLocalizedTextMap | undefined,
150
- fallback: string | undefined,
151
- localeFallbacks: string[]
152
- ): string {
153
- if (localized) {
154
- const entries = Object.entries(localized)
155
- .map(([locale, text]) => ({ locale: normalizeLocaleTag(locale), text: typeof text === 'string' ? text.trim() : '' }))
156
- .filter((entry) => entry.text.length > 0);
157
-
158
- if (entries.length > 0) {
159
- const exactMap = new Map(entries.map((entry) => [entry.locale, entry.text] as const));
160
-
161
- for (const locale of localeFallbacks) {
162
- const normalizedLocale = normalizeLocaleTag(locale);
163
- const exact = exactMap.get(normalizedLocale);
164
- if (exact) {
165
- return exact;
166
- }
167
- }
168
-
169
- for (const locale of localeFallbacks) {
170
- const base = normalizeLocaleTag(locale).split('-')[0];
171
- if (!base) {
172
- continue;
173
- }
174
- const matched = entries.find((entry) => entry.locale === base || entry.locale.startsWith(`${base}-`));
175
- if (matched) {
176
- return matched.text;
177
- }
178
- }
179
-
180
- return entries[0]?.text ?? '';
181
- }
182
- }
183
-
184
- return fallback?.trim() ?? '';
185
- }
186
-
187
137
  function matchInstalledSearch(
188
138
  record: MarketplaceInstalledRecord,
189
139
  item: MarketplaceItemSummary | undefined,
@@ -317,43 +267,6 @@ function buildGenericDetailDataUrl(params: {
317
267
  return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
318
268
  }
319
269
 
320
- function FilterPanel(props: {
321
- scope: ScopeType;
322
- searchText: string;
323
- searchPlaceholder: string;
324
- sort: MarketplaceSort;
325
- onSearchTextChange: (value: string) => void;
326
- onSortChange: (value: MarketplaceSort) => void;
327
- }) {
328
- return (
329
- <div className="mb-4">
330
- <div className="flex gap-3 items-center">
331
- <div className="flex-1 min-w-0 relative">
332
- <PackageSearch className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
333
- <input
334
- value={props.searchText}
335
- onChange={(event) => props.onSearchTextChange(event.target.value)}
336
- placeholder={props.searchPlaceholder}
337
- className="w-full h-9 border border-gray-200/80 rounded-xl pl-9 pr-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/40"
338
- />
339
- </div>
340
-
341
- {props.scope === 'all' && (
342
- <Select value={props.sort} onValueChange={(v) => props.onSortChange(v as MarketplaceSort)}>
343
- <SelectTrigger className="h-9 w-[150px] shrink-0 rounded-lg">
344
- <SelectValue />
345
- </SelectTrigger>
346
- <SelectContent>
347
- <SelectItem value="relevance">{t('marketplaceSortRelevance')}</SelectItem>
348
- <SelectItem value="updated">{t('marketplaceSortUpdated')}</SelectItem>
349
- </SelectContent>
350
- </Select>
351
- )}
352
- </div>
353
- </div>
354
- );
355
- }
356
-
357
270
  function MarketplaceListCard(props: {
358
271
  item?: MarketplaceItemSummary;
359
272
  record?: MarketplaceInstalledRecord;
@@ -366,14 +279,14 @@ function MarketplaceListCard(props: {
366
279
  }) {
367
280
  const { item, record, localeFallbacks, installState, manageState, onOpen, onInstall, onManage } = props;
368
281
  const pluginRecord = record?.type === 'plugin' ? record : undefined;
369
- const type = item?.type ?? record?.type;
370
282
  const title = item?.name ?? record?.label ?? record?.id ?? record?.spec ?? t('marketplaceUnknownItem');
371
283
  const summary = pickLocalizedText(item?.summaryI18n, item?.summary, localeFallbacks)
372
284
  || (record ? t('marketplaceInstalledLocalSummary') : '');
373
285
  const spec = item?.install.spec ?? record?.spec ?? '';
374
286
 
375
287
  const targetId = record?.id || record?.spec;
376
- const busyForRecord = Boolean(targetId) && manageState.isPending && manageState.targetId === targetId;
288
+ const busyAction = targetId ? manageState.actionsByTarget.get(targetId) : undefined;
289
+ const busyForRecord = Boolean(busyAction);
377
290
 
378
291
  const canToggle = Boolean(pluginRecord);
379
292
  const canUninstallPlugin = record?.type === 'plugin' && record.origin !== 'bundled';
@@ -384,8 +297,6 @@ function MarketplaceListCard(props: {
384
297
  const installSpec = item?.install.spec;
385
298
  const isInstalling = typeof installSpec === 'string' && installState.installingSpecs.has(installSpec);
386
299
 
387
- const displayType = type === 'plugin' ? t('marketplaceTypePlugin') : type === 'skill' ? t('marketplaceTypeSkill') : t('marketplaceTypeExtension');
388
-
389
300
  return (
390
301
  <article
391
302
  onClick={onOpen}
@@ -405,19 +316,15 @@ function MarketplaceListCard(props: {
405
316
  </Tooltip>
406
317
 
407
318
  <div className="flex items-center gap-1.5 mt-0.5 mb-1.5">
408
- <span className="text-[11px] text-gray-500 font-medium">{displayType}</span>
409
319
  {spec && (
410
- <>
411
- <span className="text-[10px] text-gray-300">•</span>
412
- <Tooltip>
413
- <TooltipTrigger asChild>
414
- <span className="text-[11px] text-gray-400 truncate max-w-full font-mono">{spec}</span>
415
- </TooltipTrigger>
416
- <TooltipContent className="max-w-[300px] text-xs font-mono break-all">
417
- {spec}
418
- </TooltipContent>
419
- </Tooltip>
420
- </>
320
+ <Tooltip>
321
+ <TooltipTrigger asChild>
322
+ <span className="text-[11px] text-gray-400 truncate max-w-full font-mono">{spec}</span>
323
+ </TooltipTrigger>
324
+ <TooltipContent className="max-w-[300px] text-xs font-mono break-all">
325
+ {spec}
326
+ </TooltipContent>
327
+ </Tooltip>
421
328
  )}
422
329
  </div>
423
330
 
@@ -451,29 +358,29 @@ function MarketplaceListCard(props: {
451
358
 
452
359
  {pluginRecord && canToggle && (
453
360
  <button
454
- disabled={manageState.isPending}
361
+ disabled={busyForRecord}
455
362
  onClick={(event) => {
456
363
  event.stopPropagation();
457
364
  onManage(isDisabled ? 'enable' : 'disable', pluginRecord);
458
365
  }}
459
366
  className="inline-flex items-center h-8 px-4 rounded-xl text-xs font-medium border border-gray-200/80 text-gray-600 bg-white hover:bg-gray-50 hover:border-gray-300 disabled:opacity-50 transition-colors"
460
367
  >
461
- {busyForRecord && manageState.action !== 'uninstall'
462
- ? (manageState.action === 'enable' ? t('marketplaceEnabling') : t('marketplaceDisabling'))
368
+ {busyAction && busyAction !== 'uninstall'
369
+ ? (busyAction === 'enable' ? t('marketplaceEnabling') : t('marketplaceDisabling'))
463
370
  : (isDisabled ? t('marketplaceEnable') : t('marketplaceDisable'))}
464
371
  </button>
465
372
  )}
466
373
 
467
374
  {record && canUninstall && (
468
375
  <button
469
- disabled={manageState.isPending}
376
+ disabled={busyForRecord}
470
377
  onClick={(event) => {
471
378
  event.stopPropagation();
472
379
  onManage('uninstall', record);
473
380
  }}
474
381
  className="inline-flex items-center h-8 px-4 rounded-xl text-xs font-medium border border-rose-100 text-rose-500 bg-white hover:bg-rose-50 hover:border-rose-200 disabled:opacity-50 transition-colors"
475
382
  >
476
- {busyForRecord && manageState.action === 'uninstall' ? t('marketplaceRemoving') : t('marketplaceUninstall')}
383
+ {busyAction === 'uninstall' ? t('marketplaceRemoving') : t('marketplaceUninstall')}
477
384
  </button>
478
385
  )}
479
386
  </div>
@@ -481,68 +388,6 @@ function MarketplaceListCard(props: {
481
388
  );
482
389
  }
483
390
 
484
- function MarketplaceListSkeleton(props: {
485
- count?: number;
486
- }) {
487
- const count = props.count ?? SKELETON_CARD_COUNT;
488
-
489
- return (
490
- <>
491
- {Array.from({ length: count }, (_, index) => (
492
- <article
493
- key={`marketplace-skeleton-${index}`}
494
- className="rounded-2xl border border-gray-200/40 bg-white px-5 py-4 shadow-sm"
495
- >
496
- <div className="flex items-start gap-3.5 justify-between">
497
- <div className="flex min-w-0 flex-1 gap-3">
498
- <Skeleton className="h-10 w-10 shrink-0 rounded-xl" />
499
- <div className="min-w-0 flex-1 space-y-2 pt-0.5">
500
- <Skeleton className="h-4 w-32 max-w-[70%]" />
501
- <div className="flex items-center gap-2">
502
- <Skeleton className="h-3 w-12" />
503
- <Skeleton className="h-3 w-24" />
504
- </div>
505
- <Skeleton className="h-3 w-full" />
506
- </div>
507
- </div>
508
- <Skeleton className="h-8 w-20 shrink-0 rounded-xl" />
509
- </div>
510
- </article>
511
- ))}
512
- </>
513
- );
514
- }
515
-
516
- function PaginationBar(props: {
517
- page: number;
518
- totalPages: number;
519
- busy: boolean;
520
- onPrev: () => void;
521
- onNext: () => void;
522
- }) {
523
- return (
524
- <div className="mt-4 flex items-center justify-end gap-2">
525
- <button
526
- className="h-8 px-3 rounded-xl border border-gray-200/80 text-sm text-gray-600 disabled:opacity-40"
527
- onClick={props.onPrev}
528
- disabled={props.page <= 1 || props.busy}
529
- >
530
- {t('prev')}
531
- </button>
532
- <div className="text-sm text-gray-600 min-w-20 text-center">
533
- {props.totalPages === 0 ? '0 / 0' : `${props.page} / ${props.totalPages}`}
534
- </div>
535
- <button
536
- className="h-8 px-3 rounded-xl border border-gray-200/80 text-sm text-gray-600 disabled:opacity-40"
537
- onClick={props.onNext}
538
- disabled={props.totalPages === 0 || props.page >= props.totalPages || props.busy}
539
- >
540
- {t('next')}
541
- </button>
542
- </div>
543
- );
544
- }
545
-
546
391
  export function MarketplacePage(props: MarketplacePageProps = {}) {
547
392
  const { forcedType } = props;
548
393
  const navigate = useNavigate();
@@ -609,6 +454,7 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
609
454
  const [sort, setSort] = useState<MarketplaceSort>('relevance');
610
455
  const [page, setPage] = useState(1);
611
456
  const [installingSpecs, setInstallingSpecs] = useState<ReadonlySet<string>>(new Set());
457
+ const [managingTargets, setManagingTargets] = useState<ReadonlyMap<string, MarketplaceManageAction>>(new Map());
612
458
 
613
459
  useEffect(() => {
614
460
  const timer = setTimeout(() => {
@@ -687,10 +533,6 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
687
533
  const showCatalogSkeleton = scope === 'all' && itemsQuery.isLoading && !itemsQuery.data;
688
534
  const showInstalledSkeleton = scope === 'installed' && installedQuery.isLoading && !installedQuery.data;
689
535
  const showListSkeleton = showCatalogSkeleton || showInstalledSkeleton;
690
- const isListRefreshing = !showListSkeleton && (
691
- (scope === 'all' && itemsQuery.isFetching)
692
- || (scope === 'installed' && installedQuery.isFetching)
693
- );
694
536
 
695
537
  const listSummary = useMemo(() => {
696
538
  if (scope === 'installed') {
@@ -710,9 +552,7 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
710
552
  const installState: InstallState = { installingSpecs };
711
553
 
712
554
  const manageState: ManageState = {
713
- isPending: manageMutation.isPending,
714
- targetId: manageMutation.variables?.id || manageMutation.variables?.spec,
715
- action: manageMutation.variables?.action
555
+ actionsByTarget: managingTargets
716
556
  };
717
557
 
718
558
  const scopeTabs = [
@@ -759,14 +599,13 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
759
599
  };
760
600
 
761
601
  const handleManage = async (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => {
762
- if (manageMutation.isPending) {
763
- return;
764
- }
765
-
766
602
  const targetId = record.id || record.spec;
767
603
  if (!targetId) {
768
604
  return;
769
605
  }
606
+ if (managingTargets.has(targetId)) {
607
+ return;
608
+ }
770
609
 
771
610
  if (action === 'uninstall') {
772
611
  const confirmed = await confirm({
@@ -780,12 +619,29 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
780
619
  }
781
620
  }
782
621
 
783
- manageMutation.mutate({
784
- type: record.type,
785
- action,
786
- id: targetId,
787
- spec: record.spec
622
+ setManagingTargets((previous) => {
623
+ const next = new Map(previous);
624
+ next.set(targetId, action);
625
+ return next;
788
626
  });
627
+
628
+ try {
629
+ await manageMutation.mutateAsync({
630
+ type: record.type,
631
+ action,
632
+ id: targetId,
633
+ spec: record.spec
634
+ });
635
+ } finally {
636
+ setManagingTargets((previous) => {
637
+ if (!previous.has(targetId)) {
638
+ return previous;
639
+ }
640
+ const next = new Map(previous);
641
+ next.delete(targetId);
642
+ return next;
643
+ });
644
+ }
789
645
  };
790
646
 
791
647
  const openItemDetail = async (item?: MarketplaceItemSummary, record?: MarketplaceInstalledRecord) => {
@@ -909,15 +765,12 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
909
765
  </div>
910
766
  )}
911
767
 
912
- <div className="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1" aria-busy={showListSkeleton || isListRefreshing}>
768
+ <div className="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1" aria-busy={showListSkeleton}>
913
769
  <div
914
770
  data-testid={showListSkeleton ? 'marketplace-list-skeleton' : undefined}
915
- className={cn(
916
- 'grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3 transition-opacity',
917
- isListRefreshing ? 'opacity-70' : 'opacity-100'
918
- )}
771
+ className="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3"
919
772
  >
920
- {showListSkeleton && <MarketplaceListSkeleton />}
773
+ {showListSkeleton && <MarketplaceListSkeleton count={SKELETON_CARD_COUNT} />}
921
774
 
922
775
  {!showListSkeleton && scope === 'all' && allItems.map((item) => (
923
776
  <MarketplaceListCard
@@ -0,0 +1,110 @@
1
+ import type {
2
+ MarketplaceInstalledView,
3
+ MarketplaceInstallRequest,
4
+ MarketplaceInstallResult,
5
+ MarketplaceManageRequest,
6
+ MarketplaceManageResult
7
+ } from '@/api/types';
8
+ import {
9
+ applyInstallResultToInstalledView,
10
+ applyManageResultToInstalledView
11
+ } from '@/components/marketplace/marketplace-installed-cache';
12
+
13
+ describe('marketplace-installed-cache', () => {
14
+ it('adds a plugin record immediately after install success', () => {
15
+ const request: MarketplaceInstallRequest = {
16
+ type: 'plugin',
17
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
18
+ kind: 'npm'
19
+ };
20
+ const result: MarketplaceInstallResult = {
21
+ type: 'plugin',
22
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
23
+ message: 'installed'
24
+ };
25
+
26
+ const next = applyInstallResultToInstalledView({ request, result });
27
+
28
+ expect(next.total).toBe(1);
29
+ expect(next.specs).toEqual(['@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk']);
30
+ expect(next.records[0]).toMatchObject({
31
+ type: 'plugin',
32
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
33
+ enabled: true,
34
+ origin: 'marketplace',
35
+ runtimeStatus: 'ready'
36
+ });
37
+ });
38
+
39
+ it('marks a plugin record as disabled immediately after disable success', () => {
40
+ const view: MarketplaceInstalledView = {
41
+ type: 'plugin',
42
+ total: 1,
43
+ specs: ['@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk'],
44
+ records: [
45
+ {
46
+ type: 'plugin',
47
+ id: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
48
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
49
+ label: 'Codex Runtime',
50
+ enabled: true,
51
+ origin: 'marketplace'
52
+ }
53
+ ]
54
+ };
55
+ const request: MarketplaceManageRequest = {
56
+ type: 'plugin',
57
+ action: 'disable',
58
+ id: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
59
+ spec: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk'
60
+ };
61
+ const result: MarketplaceManageResult = {
62
+ type: 'plugin',
63
+ action: 'disable',
64
+ id: '@nextclaw/nextclaw-ncp-runtime-plugin-codex-sdk',
65
+ message: 'disabled'
66
+ };
67
+
68
+ const next = applyManageResultToInstalledView({ view, request, result });
69
+
70
+ expect(next.records[0]).toMatchObject({
71
+ enabled: false,
72
+ runtimeStatus: 'disabled'
73
+ });
74
+ });
75
+
76
+ it('removes a skill record immediately after uninstall success', () => {
77
+ const view: MarketplaceInstalledView = {
78
+ type: 'skill',
79
+ total: 1,
80
+ specs: ['@nextclaw/web-search'],
81
+ records: [
82
+ {
83
+ type: 'skill',
84
+ id: 'web-search',
85
+ spec: '@nextclaw/web-search',
86
+ label: 'Web Search',
87
+ source: 'workspace'
88
+ }
89
+ ]
90
+ };
91
+ const request: MarketplaceManageRequest = {
92
+ type: 'skill',
93
+ action: 'uninstall',
94
+ id: 'web-search',
95
+ spec: '@nextclaw/web-search'
96
+ };
97
+ const result: MarketplaceManageResult = {
98
+ type: 'skill',
99
+ action: 'uninstall',
100
+ id: 'web-search',
101
+ message: 'removed'
102
+ };
103
+
104
+ const next = applyManageResultToInstalledView({ view, request, result });
105
+
106
+ expect(next.total).toBe(0);
107
+ expect(next.records).toEqual([]);
108
+ expect(next.specs).toEqual([]);
109
+ });
110
+ });