@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.
- package/CHANGELOG.md +6 -0
- package/dist/assets/ChannelsList-ZBPiF0y2.js +1 -0
- package/dist/assets/ChatPage-BOgoolWK.js +38 -0
- package/dist/assets/{DocBrowser-CVwUDJMO.js → DocBrowser-BUYNHg0Y.js} +1 -1
- package/dist/assets/LogoBadge-DXPq99LJ.js +1 -0
- package/dist/assets/MarketplacePage-Dx7nexYN.js +49 -0
- package/dist/assets/McpMarketplacePage-064wdotP.js +40 -0
- package/dist/assets/{ModelConfig-CsX-_fyy.js → ModelConfig-BDIfLesG.js} +1 -1
- package/dist/assets/ProvidersList-DrlIr46m.js +1 -0
- package/dist/assets/RemoteAccessPage-ZkUBA-Av.js +1 -0
- package/dist/assets/{RuntimeConfig-CX2TGEG1.js → RuntimeConfig-BPxXEGzM.js} +1 -1
- package/dist/assets/{SearchConfig-C-WBTcWi.js → SearchConfig-BIqnlpne.js} +1 -1
- package/dist/assets/{SecretsConfig-9kbR0ZCB.js → SecretsConfig-jKZEVF2q.js} +2 -2
- package/dist/assets/{SessionsConfig-Bohn3P1q.js → SessionsConfig-C_FXgVe1.js} +2 -2
- package/dist/assets/{chat-message-AWIcksDK.js → chat-message-DmzpZJc_.js} +1 -1
- package/dist/assets/index-Byfw276e.js +8 -0
- package/dist/assets/{index-CPDASUXh.js → index-Ct7FQpxN.js} +1 -1
- package/dist/assets/index-bhNuQis7.css +1 -0
- package/dist/assets/{label-DD61y-4v.js → label-B1MloEtn.js} +1 -1
- package/dist/assets/marketplace-localization-Dk31LJJJ.js +1 -0
- package/dist/assets/{page-layout-CfnoVycc.js → page-layout-BGg1EhM5.js} +1 -1
- package/dist/assets/{popover-DsugZ6rp.js → popover-jJMv74Fp.js} +1 -1
- package/dist/assets/{security-config-DIrf2Z0O.js → security-config-Boh9NIMz.js} +1 -1
- package/dist/assets/skeleton-CmATs_b3.js +1 -0
- package/dist/assets/status-dot-DNyCdxPZ.js +1 -0
- package/dist/assets/{switch-NX5OmUXQ.js → switch-DE_MYk7x.js} +1 -1
- package/dist/assets/{tabs-custom-9ihB5Jem.js → tabs-custom-B-zErYPr.js} +1 -1
- package/dist/assets/{useConfirmDialog-BuQnVTeR.js → useConfirmDialog-BqQ6QfhB.js} +2 -2
- package/dist/assets/{vendor-DKBNiC31.js → vendor-CwsIoNvJ.js} +128 -93
- package/dist/index.html +3 -3
- package/package.json +4 -4
- 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 +57 -0
- package/src/api/remote.types.ts +80 -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 +320 -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 +92 -0
- package/src/hooks/useWebSocket.ts +25 -16
- package/src/lib/i18n.marketplace.ts +91 -0
- package/src/lib/i18n.remote.ts +115 -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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MarketplaceInstalledRecord,
|
|
3
|
+
MarketplaceInstalledView,
|
|
4
|
+
MarketplaceInstallRequest,
|
|
5
|
+
MarketplaceInstallResult,
|
|
6
|
+
MarketplaceManageRequest,
|
|
7
|
+
MarketplaceManageResult,
|
|
8
|
+
MarketplaceItemType
|
|
9
|
+
} from '@/api/types';
|
|
10
|
+
|
|
11
|
+
function dedupeSpecs(records: MarketplaceInstalledRecord[]): string[] {
|
|
12
|
+
return Array.from(new Set(records.map((record) => record.spec).filter(Boolean)));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildInstalledRecordFromInstall(params: {
|
|
16
|
+
request: MarketplaceInstallRequest;
|
|
17
|
+
result: MarketplaceInstallResult;
|
|
18
|
+
}): MarketplaceInstalledRecord {
|
|
19
|
+
const installedAt = new Date().toISOString();
|
|
20
|
+
|
|
21
|
+
if (params.result.type === 'skill') {
|
|
22
|
+
return {
|
|
23
|
+
type: 'skill',
|
|
24
|
+
spec: params.result.spec,
|
|
25
|
+
id: params.request.skill ?? params.result.spec,
|
|
26
|
+
label: params.request.skill ?? params.result.name ?? params.result.spec,
|
|
27
|
+
source: 'workspace',
|
|
28
|
+
installPath: params.request.installPath,
|
|
29
|
+
installedAt
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
type: params.result.type,
|
|
35
|
+
spec: params.result.spec,
|
|
36
|
+
id: params.result.name ?? params.result.spec,
|
|
37
|
+
label: params.result.name ?? params.result.spec,
|
|
38
|
+
source: 'marketplace',
|
|
39
|
+
origin: 'marketplace',
|
|
40
|
+
enabled: params.request.enabled ?? true,
|
|
41
|
+
runtimeStatus: params.request.enabled === false ? 'disabled' : 'ready',
|
|
42
|
+
installedAt
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function matchesInstalledRecord(record: MarketplaceInstalledRecord, params: {
|
|
47
|
+
id?: string;
|
|
48
|
+
spec?: string;
|
|
49
|
+
}): boolean {
|
|
50
|
+
if (params.spec && record.spec === params.spec) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (params.id && record.id === params.id) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ensureInstalledView(type: MarketplaceItemType, view?: MarketplaceInstalledView): MarketplaceInstalledView {
|
|
60
|
+
return view ?? {
|
|
61
|
+
type,
|
|
62
|
+
total: 0,
|
|
63
|
+
specs: [],
|
|
64
|
+
records: []
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function applyInstallResultToInstalledView(params: {
|
|
69
|
+
view?: MarketplaceInstalledView;
|
|
70
|
+
request: MarketplaceInstallRequest;
|
|
71
|
+
result: MarketplaceInstallResult;
|
|
72
|
+
}): MarketplaceInstalledView {
|
|
73
|
+
const current = ensureInstalledView(params.result.type, params.view);
|
|
74
|
+
const optimisticRecord = buildInstalledRecordFromInstall(params);
|
|
75
|
+
const existingIndex = current.records.findIndex((record) => matchesInstalledRecord(record, {
|
|
76
|
+
id: optimisticRecord.id,
|
|
77
|
+
spec: optimisticRecord.spec
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
const nextRecords = [...current.records];
|
|
81
|
+
if (existingIndex >= 0) {
|
|
82
|
+
nextRecords[existingIndex] = {
|
|
83
|
+
...nextRecords[existingIndex],
|
|
84
|
+
...optimisticRecord
|
|
85
|
+
};
|
|
86
|
+
} else {
|
|
87
|
+
nextRecords.unshift(optimisticRecord);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
...current,
|
|
92
|
+
type: params.result.type,
|
|
93
|
+
records: nextRecords,
|
|
94
|
+
specs: dedupeSpecs(nextRecords),
|
|
95
|
+
total: nextRecords.length
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function applyManageResultToInstalledView(params: {
|
|
100
|
+
view?: MarketplaceInstalledView;
|
|
101
|
+
request: MarketplaceManageRequest;
|
|
102
|
+
result: MarketplaceManageResult;
|
|
103
|
+
}): MarketplaceInstalledView {
|
|
104
|
+
const current = ensureInstalledView(params.result.type, params.view);
|
|
105
|
+
|
|
106
|
+
if (params.result.action === 'uninstall' || params.result.action === 'remove') {
|
|
107
|
+
const nextRecords = current.records.filter((record) => !matchesInstalledRecord(record, {
|
|
108
|
+
id: params.result.id,
|
|
109
|
+
spec: params.request.spec
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
...current,
|
|
114
|
+
records: nextRecords,
|
|
115
|
+
specs: dedupeSpecs(nextRecords),
|
|
116
|
+
total: nextRecords.length
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const nextRecords = current.records.map((record) => {
|
|
121
|
+
if (!matchesInstalledRecord(record, {
|
|
122
|
+
id: params.result.id,
|
|
123
|
+
spec: params.request.spec
|
|
124
|
+
})) {
|
|
125
|
+
return record;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (params.result.action === 'disable') {
|
|
129
|
+
return {
|
|
130
|
+
...record,
|
|
131
|
+
enabled: false,
|
|
132
|
+
runtimeStatus: 'disabled'
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
...record,
|
|
138
|
+
enabled: true,
|
|
139
|
+
runtimeStatus: 'ready'
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
...current,
|
|
145
|
+
records: nextRecords,
|
|
146
|
+
specs: dedupeSpecs(nextRecords),
|
|
147
|
+
total: nextRecords.length
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { MarketplaceInstalledRecord, MarketplaceLocalizedTextMap } from '@/api/types';
|
|
2
|
+
|
|
3
|
+
export function buildLocaleFallbacks(language: string): string[] {
|
|
4
|
+
const normalized = language.trim().toLowerCase().replace(/_/g, '-');
|
|
5
|
+
const base = normalized.split('-')[0];
|
|
6
|
+
const fallbacks = [normalized, base, 'en'];
|
|
7
|
+
return Array.from(new Set(fallbacks.filter(Boolean)));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function normalizeLocaleTag(locale: string): string {
|
|
11
|
+
return locale.trim().toLowerCase().replace(/_/g, '-');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function pickLocalizedText(
|
|
15
|
+
localized: MarketplaceLocalizedTextMap | undefined,
|
|
16
|
+
fallback: string | undefined,
|
|
17
|
+
localeFallbacks: string[]
|
|
18
|
+
): string {
|
|
19
|
+
if (localized) {
|
|
20
|
+
const entries = Object.entries(localized)
|
|
21
|
+
.map(([locale, text]) => ({ locale: normalizeLocaleTag(locale), text: typeof text === 'string' ? text.trim() : '' }))
|
|
22
|
+
.filter((entry) => entry.text.length > 0);
|
|
23
|
+
|
|
24
|
+
if (entries.length > 0) {
|
|
25
|
+
const exactMap = new Map(entries.map((entry) => [entry.locale, entry.text] as const));
|
|
26
|
+
|
|
27
|
+
for (const locale of localeFallbacks) {
|
|
28
|
+
const normalizedLocale = normalizeLocaleTag(locale);
|
|
29
|
+
const exact = exactMap.get(normalizedLocale);
|
|
30
|
+
if (exact) {
|
|
31
|
+
return exact;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const locale of localeFallbacks) {
|
|
36
|
+
const base = normalizeLocaleTag(locale).split('-')[0];
|
|
37
|
+
if (!base) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const matched = entries.find((entry) => entry.locale === base || entry.locale.startsWith(`${base}-`));
|
|
41
|
+
if (matched) {
|
|
42
|
+
return matched.text;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return entries[0]?.text ?? '';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return fallback?.trim() ?? '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function pickInstalledRecordDescription(
|
|
54
|
+
record: MarketplaceInstalledRecord | undefined,
|
|
55
|
+
localeFallbacks: string[]
|
|
56
|
+
): string {
|
|
57
|
+
if (!record) {
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const locale of localeFallbacks) {
|
|
62
|
+
const base = normalizeLocaleTag(locale).split('-')[0];
|
|
63
|
+
if (base === 'zh' && record.descriptionZh?.trim()) {
|
|
64
|
+
return record.descriptionZh.trim();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (record.description?.trim()) {
|
|
69
|
+
return record.description.trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (record.descriptionZh?.trim()) {
|
|
73
|
+
return record.descriptionZh.trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { MarketplaceSort } from '@/api/types';
|
|
2
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
3
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
4
|
+
import { t } from '@/lib/i18n';
|
|
5
|
+
import { PackageSearch } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
export function FilterPanel(props: {
|
|
8
|
+
scope: 'all' | 'installed';
|
|
9
|
+
searchText: string;
|
|
10
|
+
searchPlaceholder: string;
|
|
11
|
+
sort: MarketplaceSort;
|
|
12
|
+
onSearchTextChange: (value: string) => void;
|
|
13
|
+
onSortChange: (value: MarketplaceSort) => void;
|
|
14
|
+
}) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="mb-4">
|
|
17
|
+
<div className="flex gap-3 items-center">
|
|
18
|
+
<div className="flex-1 min-w-0 relative">
|
|
19
|
+
<PackageSearch className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
|
20
|
+
<input
|
|
21
|
+
value={props.searchText}
|
|
22
|
+
onChange={(event) => props.onSearchTextChange(event.target.value)}
|
|
23
|
+
placeholder={props.searchPlaceholder}
|
|
24
|
+
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"
|
|
25
|
+
/>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
{props.scope === 'all' && (
|
|
29
|
+
<Select value={props.sort} onValueChange={(value) => props.onSortChange(value as MarketplaceSort)}>
|
|
30
|
+
<SelectTrigger className="h-9 w-[150px] shrink-0 rounded-lg">
|
|
31
|
+
<SelectValue />
|
|
32
|
+
</SelectTrigger>
|
|
33
|
+
<SelectContent>
|
|
34
|
+
<SelectItem value="relevance">{t('marketplaceSortRelevance')}</SelectItem>
|
|
35
|
+
<SelectItem value="updated">{t('marketplaceSortUpdated')}</SelectItem>
|
|
36
|
+
</SelectContent>
|
|
37
|
+
</Select>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function MarketplaceListSkeleton(props: {
|
|
45
|
+
count: number;
|
|
46
|
+
}) {
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
{Array.from({ length: props.count }, (_, index) => (
|
|
50
|
+
<article
|
|
51
|
+
key={`marketplace-skeleton-${index}`}
|
|
52
|
+
className="rounded-2xl border border-gray-200/40 bg-white px-5 py-4 shadow-sm"
|
|
53
|
+
>
|
|
54
|
+
<div className="flex items-start gap-3.5 justify-between">
|
|
55
|
+
<div className="flex min-w-0 flex-1 gap-3">
|
|
56
|
+
<Skeleton className="h-10 w-10 shrink-0 rounded-xl" />
|
|
57
|
+
<div className="min-w-0 flex-1 space-y-2 pt-0.5">
|
|
58
|
+
<Skeleton className="h-4 w-32 max-w-[70%]" />
|
|
59
|
+
<div className="flex items-center gap-2">
|
|
60
|
+
<Skeleton className="h-3 w-12" />
|
|
61
|
+
<Skeleton className="h-3 w-24" />
|
|
62
|
+
</div>
|
|
63
|
+
<Skeleton className="h-3 w-full" />
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<Skeleton className="h-8 w-20 shrink-0 rounded-xl" />
|
|
67
|
+
</div>
|
|
68
|
+
</article>
|
|
69
|
+
))}
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function PaginationBar(props: {
|
|
75
|
+
page: number;
|
|
76
|
+
totalPages: number;
|
|
77
|
+
busy: boolean;
|
|
78
|
+
onPrev: () => void;
|
|
79
|
+
onNext: () => void;
|
|
80
|
+
}) {
|
|
81
|
+
return (
|
|
82
|
+
<div className="mt-4 flex items-center justify-end gap-2">
|
|
83
|
+
<button
|
|
84
|
+
className="h-8 px-3 rounded-xl border border-gray-200/80 text-sm text-gray-600 disabled:opacity-40"
|
|
85
|
+
onClick={props.onPrev}
|
|
86
|
+
disabled={props.page <= 1 || props.busy}
|
|
87
|
+
>
|
|
88
|
+
{t('prev')}
|
|
89
|
+
</button>
|
|
90
|
+
<div className="text-sm text-gray-600 min-w-20 text-center">
|
|
91
|
+
{props.totalPages === 0 ? '0 / 0' : `${props.page} / ${props.totalPages}`}
|
|
92
|
+
</div>
|
|
93
|
+
<button
|
|
94
|
+
className="h-8 px-3 rounded-xl border border-gray-200/80 text-sm text-gray-600 disabled:opacity-40"
|
|
95
|
+
onClick={props.onNext}
|
|
96
|
+
disabled={props.totalPages === 0 || props.page >= props.totalPages || props.busy}
|
|
97
|
+
>
|
|
98
|
+
{t('next')}
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, beforeEach, expect, it, vi } from 'vitest';
|
|
3
|
+
import { McpMarketplacePage } from '@/components/marketplace/mcp/McpMarketplacePage';
|
|
4
|
+
import type { MarketplaceInstalledView, MarketplaceListView } from '@/api/types';
|
|
5
|
+
|
|
6
|
+
type ItemsQueryState = {
|
|
7
|
+
data?: MarketplaceListView;
|
|
8
|
+
isLoading: boolean;
|
|
9
|
+
isFetching: boolean;
|
|
10
|
+
isError: boolean;
|
|
11
|
+
error: Error | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type InstalledQueryState = {
|
|
15
|
+
data?: MarketplaceInstalledView;
|
|
16
|
+
isLoading: boolean;
|
|
17
|
+
isFetching: boolean;
|
|
18
|
+
isError: boolean;
|
|
19
|
+
error: Error | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const mocks = vi.hoisted(() => ({
|
|
23
|
+
itemsQuery: null as unknown as ItemsQueryState,
|
|
24
|
+
installedQuery: null as unknown as InstalledQueryState,
|
|
25
|
+
installMutation: {
|
|
26
|
+
mutateAsync: vi.fn(),
|
|
27
|
+
isPending: false
|
|
28
|
+
},
|
|
29
|
+
manageMutation: {
|
|
30
|
+
mutateAsync: vi.fn(),
|
|
31
|
+
isPending: false
|
|
32
|
+
},
|
|
33
|
+
doctorMutation: {
|
|
34
|
+
mutateAsync: vi.fn(),
|
|
35
|
+
isPending: false
|
|
36
|
+
},
|
|
37
|
+
confirm: vi.fn(),
|
|
38
|
+
docOpen: vi.fn()
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock('@tanstack/react-query', () => ({
|
|
42
|
+
useMutation: () => mocks.doctorMutation
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock('@/components/providers/I18nProvider', () => ({
|
|
46
|
+
useI18n: () => ({
|
|
47
|
+
language: 'zh',
|
|
48
|
+
setLanguage: vi.fn(),
|
|
49
|
+
toggleLanguage: vi.fn(),
|
|
50
|
+
t: (key: string) => key
|
|
51
|
+
})
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('@/components/doc-browser', () => ({
|
|
55
|
+
useDocBrowser: () => ({
|
|
56
|
+
open: mocks.docOpen
|
|
57
|
+
})
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock('@/hooks/useConfirmDialog', () => ({
|
|
61
|
+
useConfirmDialog: () => ({
|
|
62
|
+
confirm: mocks.confirm,
|
|
63
|
+
ConfirmDialog: () => null
|
|
64
|
+
})
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
vi.mock('@/hooks/useMcpMarketplace', () => ({
|
|
68
|
+
useMcpMarketplaceItems: () => mocks.itemsQuery,
|
|
69
|
+
useMcpMarketplaceInstalled: () => mocks.installedQuery,
|
|
70
|
+
useInstallMcpMarketplaceItem: () => mocks.installMutation,
|
|
71
|
+
useManageMcpMarketplaceItem: () => mocks.manageMutation
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
function createItemsQuery(overrides: Partial<ItemsQueryState> = {}): ItemsQueryState {
|
|
75
|
+
return {
|
|
76
|
+
data: undefined,
|
|
77
|
+
isLoading: false,
|
|
78
|
+
isFetching: false,
|
|
79
|
+
isError: false,
|
|
80
|
+
error: null,
|
|
81
|
+
...overrides
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createInstalledQuery(overrides: Partial<InstalledQueryState> = {}): InstalledQueryState {
|
|
86
|
+
return {
|
|
87
|
+
data: {
|
|
88
|
+
type: 'mcp',
|
|
89
|
+
total: 0,
|
|
90
|
+
specs: [],
|
|
91
|
+
records: []
|
|
92
|
+
},
|
|
93
|
+
isLoading: false,
|
|
94
|
+
isFetching: false,
|
|
95
|
+
isError: false,
|
|
96
|
+
error: null,
|
|
97
|
+
...overrides
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('McpMarketplacePage', () => {
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
mocks.installMutation.mutateAsync.mockReset();
|
|
104
|
+
mocks.manageMutation.mutateAsync.mockReset();
|
|
105
|
+
mocks.doctorMutation.mutateAsync.mockReset();
|
|
106
|
+
mocks.confirm.mockReset();
|
|
107
|
+
mocks.docOpen.mockReset();
|
|
108
|
+
mocks.itemsQuery = createItemsQuery();
|
|
109
|
+
mocks.installedQuery = createInstalledQuery();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('prefers localized summary copy for the active language', () => {
|
|
113
|
+
mocks.itemsQuery = createItemsQuery({
|
|
114
|
+
data: {
|
|
115
|
+
total: 1,
|
|
116
|
+
page: 1,
|
|
117
|
+
pageSize: 12,
|
|
118
|
+
totalPages: 1,
|
|
119
|
+
sort: 'relevance',
|
|
120
|
+
items: [
|
|
121
|
+
{
|
|
122
|
+
id: 'mcp-chrome-devtools',
|
|
123
|
+
slug: 'chrome-devtools',
|
|
124
|
+
type: 'mcp',
|
|
125
|
+
name: 'Chrome DevTools MCP',
|
|
126
|
+
summary: 'Connect MCP clients to Chrome DevTools for browser inspection and automation.',
|
|
127
|
+
summaryI18n: {
|
|
128
|
+
en: 'Connect MCP clients to Chrome DevTools for browser inspection and automation.',
|
|
129
|
+
zh: '把 MCP 客户端接入 Chrome DevTools,用于浏览器检查与自动化。'
|
|
130
|
+
},
|
|
131
|
+
tags: ['mcp', 'browser'],
|
|
132
|
+
author: 'Chrome DevTools',
|
|
133
|
+
install: {
|
|
134
|
+
kind: 'template',
|
|
135
|
+
spec: 'chrome-devtools',
|
|
136
|
+
command: 'nextclaw mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest'
|
|
137
|
+
},
|
|
138
|
+
updatedAt: '2026-03-19T00:00:00.000Z'
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
render(<McpMarketplacePage />);
|
|
145
|
+
|
|
146
|
+
expect(screen.getByText('把 MCP 客户端接入 Chrome DevTools,用于浏览器检查与自动化。')).toBeTruthy();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('hides install button when an installed record matches by spec without catalog slug', () => {
|
|
150
|
+
mocks.itemsQuery = createItemsQuery({
|
|
151
|
+
data: {
|
|
152
|
+
total: 1,
|
|
153
|
+
page: 1,
|
|
154
|
+
pageSize: 12,
|
|
155
|
+
totalPages: 1,
|
|
156
|
+
sort: 'relevance',
|
|
157
|
+
items: [
|
|
158
|
+
{
|
|
159
|
+
id: 'mcp-chrome-devtools',
|
|
160
|
+
slug: 'chrome-devtools',
|
|
161
|
+
type: 'mcp',
|
|
162
|
+
name: 'Chrome DevTools MCP',
|
|
163
|
+
summary: 'Connect MCP clients to Chrome DevTools for browser inspection and automation.',
|
|
164
|
+
summaryI18n: {
|
|
165
|
+
en: 'Connect MCP clients to Chrome DevTools for browser inspection and automation.',
|
|
166
|
+
zh: '把 MCP 客户端接入 Chrome DevTools,用于浏览器检查与自动化。'
|
|
167
|
+
},
|
|
168
|
+
tags: ['mcp', 'browser'],
|
|
169
|
+
author: 'Chrome DevTools',
|
|
170
|
+
install: {
|
|
171
|
+
kind: 'template',
|
|
172
|
+
spec: 'chrome-devtools',
|
|
173
|
+
command: 'nextclaw mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest'
|
|
174
|
+
},
|
|
175
|
+
updatedAt: '2026-03-19T00:00:00.000Z'
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
mocks.installedQuery = createInstalledQuery({
|
|
181
|
+
data: {
|
|
182
|
+
type: 'mcp',
|
|
183
|
+
total: 1,
|
|
184
|
+
specs: ['chrome-devtools'],
|
|
185
|
+
records: [
|
|
186
|
+
{
|
|
187
|
+
type: 'mcp',
|
|
188
|
+
id: 'chrome-devtools',
|
|
189
|
+
spec: 'chrome-devtools',
|
|
190
|
+
label: 'Chrome DevTools MCP',
|
|
191
|
+
enabled: true,
|
|
192
|
+
runtimeStatus: 'enabled',
|
|
193
|
+
transport: 'stdio',
|
|
194
|
+
scope: {
|
|
195
|
+
allAgents: true,
|
|
196
|
+
agents: []
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
render(<McpMarketplacePage />);
|
|
204
|
+
|
|
205
|
+
expect(screen.queryByText('Install')).toBeNull();
|
|
206
|
+
expect(screen.getByText('Disable')).toBeTruthy();
|
|
207
|
+
});
|
|
208
|
+
});
|