@nextclaw/ui 0.9.2 → 0.9.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.
- package/CHANGELOG.md +12 -0
- package/dist/assets/ChannelsList-DDfZIiJa.js +1 -0
- package/dist/assets/ChatPage-FpRraTxm.js +38 -0
- package/dist/assets/{DocBrowser-CVwUDJMO.js → DocBrowser-Kndx8OJj.js} +1 -1
- package/dist/assets/LogoBadge-hKHoLH9n.js +1 -0
- package/dist/assets/MarketplacePage-CZIJyfjK.js +49 -0
- package/dist/assets/McpMarketplacePage-BGrAMA37.js +40 -0
- package/dist/assets/{ModelConfig-CsX-_fyy.js → ModelConfig-BpKQeGfb.js} +1 -1
- package/dist/assets/ProvidersList-qfUL6mrW.js +1 -0
- package/dist/assets/RemoteAccessPage-BQuMsngI.js +1 -0
- package/dist/assets/{RuntimeConfig-CX2TGEG1.js → RuntimeConfig-CVlqNWKO.js} +1 -1
- package/dist/assets/{SearchConfig-C-WBTcWi.js → SearchConfig-DXFV6Mvx.js} +1 -1
- package/dist/assets/{SecretsConfig-9kbR0ZCB.js → SecretsConfig-BGW9aUqv.js} +2 -2
- package/dist/assets/{SessionsConfig-Bohn3P1q.js → SessionsConfig-BByfa1ke.js} +2 -2
- package/dist/assets/{chat-message-AWIcksDK.js → chat-message-ZwnDwDuQ.js} +1 -1
- package/dist/assets/index-BWvap_iq.js +8 -0
- package/dist/assets/index-COrhpAdh.css +1 -0
- package/dist/assets/{index-CPDASUXh.js → index-Ct7FQpxN.js} +1 -1
- package/dist/assets/{label-DD61y-4v.js → label-Bklr3fXc.js} +1 -1
- package/dist/assets/marketplace-localization-Dk31LJJJ.js +1 -0
- package/dist/assets/{page-layout-CfnoVycc.js → page-layout-sNhcbwtm.js} +1 -1
- package/dist/assets/{popover-DsugZ6rp.js → popover-C3rJrJJG.js} +1 -1
- package/dist/assets/{security-config-DIrf2Z0O.js → security-config-BueosYw1.js} +1 -1
- package/dist/assets/skeleton-CiG6msbm.js +1 -0
- package/dist/assets/status-dot-CsIV5YrS.js +1 -0
- package/dist/assets/{switch-NX5OmUXQ.js → switch-DSdHSIsC.js} +1 -1
- package/dist/assets/{tabs-custom-9ihB5Jem.js → tabs-custom-BB-VjdL2.js} +1 -1
- package/dist/assets/{useConfirmDialog-BuQnVTeR.js → useConfirmDialog-BL5s8KDC.js} +2 -2
- package/dist/assets/{vendor-DKBNiC31.js → vendor-CwsIoNvJ.js} +128 -93
- package/dist/index.html +3 -3
- package/package.json +3 -3
- package/src/App.tsx +4 -0
- package/src/api/auth.types.ts +24 -0
- package/src/api/chat-session-type.types.ts +21 -0
- package/src/api/marketplace.ts +8 -2
- package/src/api/mcp-marketplace.ts +138 -0
- package/src/api/remote.ts +77 -0
- package/src/api/remote.types.ts +104 -0
- package/src/api/types.ts +28 -34
- package/src/components/chat/ChatSidebar.test.tsx +31 -2
- package/src/components/chat/ChatSidebar.tsx +26 -2
- package/src/components/chat/chat-page-data.ts +36 -38
- package/src/components/chat/chat-page-runtime.test.ts +96 -2
- package/src/components/chat/chat-page-runtime.ts +1 -135
- package/src/components/chat/chat-session-preference-governance.ts +303 -0
- package/src/components/chat/legacy/LegacyChatPage.tsx +4 -19
- package/src/components/chat/ncp/NcpChatPage.tsx +4 -19
- package/src/components/chat/ncp/ncp-chat-page-data.test.ts +36 -0
- package/src/components/chat/ncp/ncp-chat-page-data.ts +62 -21
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +2 -0
- package/src/components/chat/ncp/ncp-session-adapter.ts +2 -0
- package/src/components/chat/stores/chat-input.store.ts +14 -1
- package/src/components/chat/useChatSessionTypeState.test.tsx +29 -0
- package/src/components/chat/useChatSessionTypeState.ts +55 -12
- package/src/components/layout/Sidebar.tsx +11 -1
- package/src/components/marketplace/MarketplacePage.test.tsx +152 -0
- package/src/components/marketplace/MarketplacePage.tsx +52 -199
- package/src/components/marketplace/marketplace-installed-cache.test.ts +110 -0
- package/src/components/marketplace/marketplace-installed-cache.ts +149 -0
- package/src/components/marketplace/marketplace-localization.ts +77 -0
- package/src/components/marketplace/marketplace-page-parts.tsx +102 -0
- package/src/components/marketplace/mcp/McpMarketplacePage.test.tsx +208 -0
- package/src/components/marketplace/mcp/McpMarketplacePage.tsx +578 -0
- package/src/components/remote/RemoteAccessPage.tsx +396 -0
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/label.tsx +1 -1
- package/src/hooks/useMarketplace.ts +36 -7
- package/src/hooks/useMcpMarketplace.ts +99 -0
- package/src/hooks/useRemoteAccess.ts +120 -0
- package/src/hooks/useWebSocket.ts +25 -16
- package/src/lib/i18n.marketplace.ts +91 -0
- package/src/lib/i18n.remote.ts +142 -0
- package/src/lib/i18n.ts +10 -68
- package/dist/assets/ChannelsList-DKD6Llid.js +0 -1
- package/dist/assets/ChatPage-BK9X4Tin.js +0 -38
- package/dist/assets/LogoBadge-CYQ_b7jk.js +0 -1
- package/dist/assets/MarketplacePage-B_2z3ii_.js +0 -49
- package/dist/assets/ProvidersList-CZstsyv7.js +0 -1
- package/dist/assets/index-BEgClaDH.js +0 -8
- package/dist/assets/index-C8GsgIUn.css +0 -1
- 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
|
-
|
|
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
|
|
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
|
-
<
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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={
|
|
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
|
-
{
|
|
462
|
-
? (
|
|
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={
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
action
|
|
786
|
-
|
|
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
|
|
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=
|
|
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
|
+
});
|