@nextclaw/ui 0.6.14 → 0.7.0

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 (94) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +2 -0
  3. package/dist/assets/ChannelsList-DF2U-LY1.js +1 -0
  4. package/dist/assets/ChatPage-BX39y0U5.js +36 -0
  5. package/dist/assets/DocBrowser-B9ws5JL7.js +1 -0
  6. package/dist/assets/{LogoBadge-BxZJ9BJT.js → LogoBadge-DvGAzkZ3.js} +1 -1
  7. package/dist/assets/MarketplacePage-DG5mHWJ8.js +49 -0
  8. package/dist/assets/ModelConfig-BL_HsOsm.js +1 -0
  9. package/dist/assets/ProvidersList-CH5z00YT.js +1 -0
  10. package/dist/assets/RuntimeConfig-BplBgkwo.js +1 -0
  11. package/dist/assets/SearchConfig-BhaI0fUf.js +1 -0
  12. package/dist/assets/{SecretsConfig-9OABNssV.js → SecretsConfig-CFoimOh9.js} +2 -2
  13. package/dist/assets/SessionsConfig-BHTAYn9T.js +2 -0
  14. package/dist/assets/index-BLeJkJ0o.css +1 -0
  15. package/dist/assets/index-DK4TS5ev.js +8 -0
  16. package/dist/assets/index-X5J6Mm--.js +1 -0
  17. package/dist/assets/{index-CkqvHQAt.js → index-uMsNsQX6.js} +1 -1
  18. package/dist/assets/{label-BIjHWZUm.js → label-D8ly4a2P.js} +1 -1
  19. package/dist/assets/page-layout-BSYfvwbp.js +1 -0
  20. package/dist/assets/security-config-DlKEYHNN.js +1 -0
  21. package/dist/assets/{session-run-status-BZEH0QZp.js → session-run-status-TkIuGbVw.js} +1 -1
  22. package/dist/assets/skeleton-CWbsNx2h.js +1 -0
  23. package/dist/assets/{switch-CnGQpdTp.js → switch-Ce_g9lpN.js} +1 -1
  24. package/dist/assets/tabs-custom-Cf5azvT5.js +1 -0
  25. package/dist/assets/useConfirmDialog-A8Ek8Wu7.js +5 -0
  26. package/dist/assets/vendor-B7ozqnFC.js +412 -0
  27. package/dist/index.html +3 -3
  28. package/package.json +9 -10
  29. package/src/App.tsx +49 -27
  30. package/src/api/client.ts +1 -0
  31. package/src/api/config.ts +60 -0
  32. package/src/api/types.ts +29 -1
  33. package/src/api/websocket.ts +2 -0
  34. package/src/components/auth/login-page.tsx +69 -0
  35. package/src/components/chat/ChatConversationPanel.tsx +12 -54
  36. package/src/components/chat/ChatSidebar.tsx +7 -1
  37. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +80 -0
  38. package/src/components/chat/adapters/chat-input-bar.adapter.ts +329 -0
  39. package/src/components/chat/adapters/chat-message.adapter.test.ts +137 -0
  40. package/src/components/chat/adapters/chat-message.adapter.ts +200 -0
  41. package/src/components/chat/chat-input/chat-input-bar.controller.test.tsx +128 -0
  42. package/src/components/chat/chat-input/chat-input-bar.controller.ts +105 -0
  43. package/src/components/chat/containers/chat-input-bar.container.tsx +270 -0
  44. package/src/components/chat/containers/chat-message-list.container.tsx +67 -0
  45. package/src/components/chat/index.ts +1 -0
  46. package/src/components/chat/managers/chat-thread.manager.ts +3 -1
  47. package/src/components/chat/nextclaw/index.ts +23 -0
  48. package/src/components/common/BrandHeader.tsx +4 -1
  49. package/src/components/common/StatusBadge.tsx +32 -20
  50. package/src/components/config/runtime-security-card.tsx +276 -0
  51. package/src/components/config/security-config.tsx +12 -0
  52. package/src/components/layout/Sidebar.tsx +6 -1
  53. package/src/components/marketplace/MarketplacePage.test.tsx +170 -0
  54. package/src/components/marketplace/MarketplacePage.tsx +77 -28
  55. package/src/hooks/use-auth.ts +111 -0
  56. package/src/hooks/useMarketplace.ts +9 -0
  57. package/src/hooks/useWebSocket.ts +53 -1
  58. package/src/lib/i18n.ts +72 -0
  59. package/src/test/setup.ts +16 -0
  60. package/tsconfig.json +3 -2
  61. package/vite.config.ts +2 -1
  62. package/vitest.config.ts +16 -0
  63. package/.eslintrc.cjs +0 -48
  64. package/dist/assets/ChannelsList-DiSnpiW0.js +0 -1
  65. package/dist/assets/ChatPage-DsaIrNHN.js +0 -36
  66. package/dist/assets/DocBrowser-CnfcptGM.js +0 -1
  67. package/dist/assets/MarketplacePage-BI_J_DBQ.js +0 -49
  68. package/dist/assets/ModelConfig-DfL8F4tN.js +0 -1
  69. package/dist/assets/ProvidersList-DpT_oFHZ.js +0 -1
  70. package/dist/assets/RuntimeConfig-BNYR_Iag.js +0 -1
  71. package/dist/assets/SearchConfig-TDBl7Fjh.js +0 -1
  72. package/dist/assets/SessionsConfig-BRwntUDz.js +0 -2
  73. package/dist/assets/card-BYnT3Mxo.js +0 -1
  74. package/dist/assets/index-BCfS4UY1.css +0 -1
  75. package/dist/assets/index-BnUxgevr.js +0 -8
  76. package/dist/assets/input-oaepEtqu.js +0 -1
  77. package/dist/assets/page-layout-B6JXiSQB.js +0 -1
  78. package/dist/assets/popover-LJQgv5l1.js +0 -1
  79. package/dist/assets/tabs-custom-CpSv7pDl.js +0 -1
  80. package/dist/assets/useConfirmDialog-pqAlPdQZ.js +0 -5
  81. package/dist/assets/vendor-BKtTvQYU.js +0 -407
  82. package/src/components/chat/ChatThread.tsx +0 -402
  83. package/src/components/chat/SkillsPicker.tsx +0 -137
  84. package/src/components/chat/chat-input/ChatInputBarView.tsx +0 -82
  85. package/src/components/chat/chat-input/ChatInputBottomToolbar.tsx +0 -83
  86. package/src/components/chat/chat-input/components/ChatInputModelStateHint.tsx +0 -39
  87. package/src/components/chat/chat-input/components/ChatInputSelectedSkillsSection.tsx +0 -31
  88. package/src/components/chat/chat-input/components/ChatInputSlashPanelSection.tsx +0 -112
  89. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton.tsx +0 -24
  90. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector.tsx +0 -58
  91. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls.tsx +0 -56
  92. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector.tsx +0 -40
  93. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputThinkingSelector.tsx +0 -74
  94. package/src/components/chat/chat-input/useChatInputBarController.ts +0 -322
@@ -1,6 +1,7 @@
1
1
  import { cn } from '@/lib/utils';
2
2
  import { Loader2 } from 'lucide-react';
3
3
  import { t } from '@/lib/i18n';
4
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
4
5
 
5
6
  type Status = 'connected' | 'disconnected' | 'connecting';
6
7
 
@@ -11,22 +12,16 @@ interface StatusBadgeProps {
11
12
 
12
13
  const statusConfig: Record<
13
14
  Status,
14
- { dotClass: string; textClass: string; bgClass: string }
15
+ { dotClass: string }
15
16
  > = {
16
17
  connected: {
17
18
  dotClass: 'bg-emerald-500',
18
- textClass: 'text-emerald-600',
19
- bgClass: 'bg-emerald-50',
20
19
  },
21
20
  disconnected: {
22
- dotClass: 'bg-gray-300',
23
- textClass: 'text-gray-400',
24
- bgClass: 'bg-gray-100/80',
21
+ dotClass: 'h-2.5 w-2.5 rounded-full border border-gray-400 bg-transparent',
25
22
  },
26
23
  connecting: {
27
- dotClass: 'bg-amber-400',
28
- textClass: 'text-amber-600',
29
- bgClass: 'bg-amber-50',
24
+ dotClass: 'text-amber-600',
30
25
  }
31
26
  };
32
27
 
@@ -35,16 +30,33 @@ export function StatusBadge({ status, className }: StatusBadgeProps) {
35
30
  const label = status === 'connected' ? t('connected') : status === 'disconnected' ? t('disconnected') : t('connecting');
36
31
 
37
32
  return (
38
- <div className={cn(
39
- 'flex items-center gap-1.5 px-2 py-0.5 rounded-full',
40
- config.bgClass,
41
- className
42
- )}>
43
- <span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
44
- <span className={cn('text-[11px] font-medium flex items-center gap-1', config.textClass)}>
45
- {label}
46
- {status === 'connecting' && <Loader2 className="h-2.5 w-2.5 animate-spin" />}
47
- </span>
48
- </div>
33
+ <TooltipProvider delayDuration={250}>
34
+ <Tooltip>
35
+ <TooltipTrigger asChild>
36
+ <span
37
+ role="status"
38
+ aria-label={label}
39
+ className={cn(
40
+ 'inline-flex h-5 w-5 items-center justify-center',
41
+ className
42
+ )}
43
+ >
44
+ {status === 'connecting' ? (
45
+ <Loader2 className={cn('h-3 w-3 animate-spin', config.dotClass)} />
46
+ ) : (
47
+ <span
48
+ className={cn(
49
+ status === 'connected' ? 'h-2 w-2 rounded-full' : '',
50
+ config.dotClass
51
+ )}
52
+ />
53
+ )}
54
+ </span>
55
+ </TooltipTrigger>
56
+ <TooltipContent side="bottom" className="text-xs">
57
+ {label}
58
+ </TooltipContent>
59
+ </Tooltip>
60
+ </TooltipProvider>
49
61
  );
50
62
  }
@@ -0,0 +1,276 @@
1
+ import { useState } from 'react';
2
+ import { Button } from '@/components/ui/button';
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
4
+ import { Input } from '@/components/ui/input';
5
+ import { Label } from '@/components/ui/label';
6
+ import { Switch } from '@/components/ui/switch';
7
+ import {
8
+ useAuthStatus,
9
+ useLogoutAuth,
10
+ useSetupAuth,
11
+ useUpdateAuthEnabled,
12
+ useUpdateAuthPassword
13
+ } from '@/hooks/use-auth';
14
+ import { t } from '@/lib/i18n';
15
+ import { toast } from 'sonner';
16
+
17
+ const MIN_PASSWORD_LENGTH = 8;
18
+
19
+ function hasValidPasswordLength(password: string): boolean {
20
+ return password.trim().length >= MIN_PASSWORD_LENGTH;
21
+ }
22
+
23
+ function validatePasswordConfirmation(password: string, confirmPassword: string): boolean {
24
+ if (password !== confirmPassword) {
25
+ toast.error(t('authPasswordMismatch'));
26
+ return false;
27
+ }
28
+ return true;
29
+ }
30
+
31
+ export function RuntimeSecurityCard() {
32
+ const authStatus = useAuthStatus();
33
+ const setupAuth = useSetupAuth();
34
+ const updateAuthEnabled = useUpdateAuthEnabled();
35
+ const updateAuthPassword = useUpdateAuthPassword();
36
+ const logoutAuth = useLogoutAuth();
37
+
38
+ const [setupUsername, setSetupUsername] = useState('');
39
+ const [setupPassword, setSetupPassword] = useState('');
40
+ const [setupConfirmPassword, setSetupConfirmPassword] = useState('');
41
+ const [nextPassword, setNextPassword] = useState('');
42
+ const [nextConfirmPassword, setNextConfirmPassword] = useState('');
43
+
44
+ const auth = authStatus.data;
45
+ const canSubmitSetup = (
46
+ setupUsername.trim().length > 0 &&
47
+ hasValidPasswordLength(setupPassword) &&
48
+ setupPassword === setupConfirmPassword &&
49
+ !setupAuth.isPending
50
+ );
51
+ const canUpdatePassword = (
52
+ hasValidPasswordLength(nextPassword) &&
53
+ nextPassword === nextConfirmPassword &&
54
+ !updateAuthPassword.isPending
55
+ );
56
+
57
+ const handleSetup = async () => {
58
+ if (!validatePasswordConfirmation(setupPassword, setupConfirmPassword)) {
59
+ return;
60
+ }
61
+ try {
62
+ await setupAuth.mutateAsync({
63
+ username: setupUsername.trim(),
64
+ password: setupPassword
65
+ });
66
+ setSetupPassword('');
67
+ setSetupConfirmPassword('');
68
+ } catch {
69
+ // handled by mutation toast
70
+ }
71
+ };
72
+
73
+ const handlePasswordUpdate = async () => {
74
+ if (!validatePasswordConfirmation(nextPassword, nextConfirmPassword)) {
75
+ return;
76
+ }
77
+ try {
78
+ await updateAuthPassword.mutateAsync({
79
+ password: nextPassword
80
+ });
81
+ setNextPassword('');
82
+ setNextConfirmPassword('');
83
+ } catch {
84
+ // handled by mutation toast
85
+ }
86
+ };
87
+
88
+ const handleEnabledChange = async (enabled: boolean) => {
89
+ try {
90
+ await updateAuthEnabled.mutateAsync({ enabled });
91
+ } catch {
92
+ // handled by mutation toast
93
+ }
94
+ };
95
+
96
+ const handleLogout = async () => {
97
+ try {
98
+ await logoutAuth.mutateAsync();
99
+ } catch {
100
+ // handled by mutation toast
101
+ }
102
+ };
103
+
104
+ if (authStatus.isLoading && !auth) {
105
+ return (
106
+ <Card>
107
+ <CardHeader>
108
+ <CardTitle>{t('authSecurityTitle')}</CardTitle>
109
+ <CardDescription>{t('authSecurityDescription')}</CardDescription>
110
+ </CardHeader>
111
+ <CardContent className="text-sm text-gray-500">{t('loading')}</CardContent>
112
+ </Card>
113
+ );
114
+ }
115
+
116
+ if (authStatus.isError || !auth) {
117
+ return (
118
+ <Card>
119
+ <CardHeader>
120
+ <CardTitle>{t('authSecurityTitle')}</CardTitle>
121
+ <CardDescription>{t('authSecurityDescription')}</CardDescription>
122
+ </CardHeader>
123
+ <CardContent className="space-y-4">
124
+ <p className="text-sm text-gray-500">{t('authStatusLoadFailed')}</p>
125
+ <Button
126
+ variant="outline"
127
+ onClick={() => {
128
+ void authStatus.refetch();
129
+ }}
130
+ >
131
+ {t('authRetryStatus')}
132
+ </Button>
133
+ </CardContent>
134
+ </Card>
135
+ );
136
+ }
137
+
138
+ if (!auth.configured) {
139
+ return (
140
+ <Card>
141
+ <CardHeader>
142
+ <CardTitle>{t('authSecurityTitle')}</CardTitle>
143
+ <CardDescription>{t('authSecurityDescription')}</CardDescription>
144
+ </CardHeader>
145
+ <CardContent className="space-y-5">
146
+ <div className="rounded-xl border border-dashed border-gray-200 bg-gray-50/70 p-4">
147
+ <p className="text-sm font-medium text-gray-900">{t('authSetupTitle')}</p>
148
+ <p className="mt-1 text-sm text-gray-500">{t('authSetupDescription')}</p>
149
+ </div>
150
+
151
+ <div className="grid gap-4 md:grid-cols-2">
152
+ <div className="space-y-2">
153
+ <Label htmlFor="auth-setup-username">{t('authUsername')}</Label>
154
+ <Input
155
+ id="auth-setup-username"
156
+ value={setupUsername}
157
+ onChange={(event) => setSetupUsername(event.target.value)}
158
+ placeholder={t('authUsernamePlaceholder')}
159
+ />
160
+ </div>
161
+ <div className="space-y-2">
162
+ <Label htmlFor="auth-setup-password">{t('authPassword')}</Label>
163
+ <Input
164
+ id="auth-setup-password"
165
+ type="password"
166
+ value={setupPassword}
167
+ onChange={(event) => setSetupPassword(event.target.value)}
168
+ placeholder={t('authPasswordPlaceholder')}
169
+ />
170
+ </div>
171
+ </div>
172
+
173
+ <div className="space-y-2">
174
+ <Label htmlFor="auth-setup-confirm">{t('authConfirmPassword')}</Label>
175
+ <Input
176
+ id="auth-setup-confirm"
177
+ type="password"
178
+ value={setupConfirmPassword}
179
+ onChange={(event) => setSetupConfirmPassword(event.target.value)}
180
+ placeholder={t('authConfirmPasswordPlaceholder')}
181
+ />
182
+ <p className="text-xs text-gray-500">{t('authPasswordMinLengthHint')}</p>
183
+ </div>
184
+
185
+ <Button type="button" disabled={!canSubmitSetup} onClick={() => void handleSetup()}>
186
+ {setupAuth.isPending ? t('authSettingUp') : t('authSetupAction')}
187
+ </Button>
188
+ </CardContent>
189
+ </Card>
190
+ );
191
+ }
192
+
193
+ return (
194
+ <Card>
195
+ <CardHeader>
196
+ <CardTitle>{t('authSecurityTitle')}</CardTitle>
197
+ <CardDescription>{t('authSecurityDescription')}</CardDescription>
198
+ </CardHeader>
199
+ <CardContent className="space-y-6">
200
+ <div className="rounded-xl border border-gray-200 p-4">
201
+ <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
202
+ <div className="space-y-1">
203
+ <p className="text-sm font-medium text-gray-900">{t('authStatusLabel')}</p>
204
+ <p className="text-sm text-gray-600">
205
+ {t('authStatusConfiguredUser').replace('{username}', auth.username ?? '')}
206
+ </p>
207
+ <p className="text-xs text-gray-500">{t('authUsernameFixedHelp')}</p>
208
+ </div>
209
+ <span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700">
210
+ {auth.enabled ? t('enabled') : t('disabled')}
211
+ </span>
212
+ </div>
213
+
214
+ <div className="mt-4 flex flex-col gap-4 border-t border-gray-200 pt-4 md:flex-row md:items-center md:justify-between">
215
+ <div className="space-y-1">
216
+ <p className="text-sm font-medium text-gray-900">{t('authEnableLabel')}</p>
217
+ <p className="text-xs text-gray-500">
218
+ {auth.enabled ? t('authEnableOnHelp') : t('authEnableOffHelp')}
219
+ </p>
220
+ </div>
221
+ <Switch
222
+ checked={auth.enabled}
223
+ disabled={updateAuthEnabled.isPending}
224
+ onCheckedChange={(checked) => {
225
+ void handleEnabledChange(checked);
226
+ }}
227
+ />
228
+ </div>
229
+ </div>
230
+
231
+ <div className="rounded-xl border border-gray-200 p-4 space-y-4">
232
+ <div className="space-y-1">
233
+ <p className="text-sm font-medium text-gray-900">{t('authPasswordSectionTitle')}</p>
234
+ <p className="text-xs text-gray-500">{t('authPasswordSectionDescription')}</p>
235
+ </div>
236
+
237
+ <div className="grid gap-4 md:grid-cols-2">
238
+ <div className="space-y-2">
239
+ <Label htmlFor="auth-password-next">{t('authPassword')}</Label>
240
+ <Input
241
+ id="auth-password-next"
242
+ type="password"
243
+ value={nextPassword}
244
+ onChange={(event) => setNextPassword(event.target.value)}
245
+ placeholder={t('authPasswordPlaceholder')}
246
+ />
247
+ </div>
248
+ <div className="space-y-2">
249
+ <Label htmlFor="auth-password-confirm">{t('authConfirmPassword')}</Label>
250
+ <Input
251
+ id="auth-password-confirm"
252
+ type="password"
253
+ value={nextConfirmPassword}
254
+ onChange={(event) => setNextConfirmPassword(event.target.value)}
255
+ placeholder={t('authConfirmPasswordPlaceholder')}
256
+ />
257
+ </div>
258
+ </div>
259
+
260
+ <div className="flex flex-wrap items-center gap-3">
261
+ <Button type="button" disabled={!canUpdatePassword} onClick={() => void handlePasswordUpdate()}>
262
+ {updateAuthPassword.isPending ? t('authPasswordUpdating') : t('authPasswordAction')}
263
+ </Button>
264
+ {auth.enabled && auth.authenticated ? (
265
+ <Button type="button" variant="outline" disabled={logoutAuth.isPending} onClick={() => void handleLogout()}>
266
+ {logoutAuth.isPending ? t('authLoggingOut') : t('authLogoutAction')}
267
+ </Button>
268
+ ) : null}
269
+ </div>
270
+
271
+ <p className="text-xs text-gray-500">{t('authSessionMemoryNotice')}</p>
272
+ </div>
273
+ </CardContent>
274
+ </Card>
275
+ );
276
+ }
@@ -0,0 +1,12 @@
1
+ import { RuntimeSecurityCard } from '@/components/config/runtime-security-card';
2
+ import { PageHeader, PageLayout } from '@/components/layout/page-layout';
3
+ import { t } from '@/lib/i18n';
4
+
5
+ export function SecurityConfig() {
6
+ return (
7
+ <PageLayout className="space-y-6">
8
+ <PageHeader title={t('authSecurityTitle')} description={t('authSecurityDescription')} />
9
+ <RuntimeSecurityCard />
10
+ </PageLayout>
11
+ );
12
+ }
@@ -1,7 +1,7 @@
1
1
  import { cn } from '@/lib/utils';
2
2
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
3
3
  import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
4
- import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft, Search } from 'lucide-react';
4
+ import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft, Search, Shield } from 'lucide-react';
5
5
  import { NavLink } from 'react-router-dom';
6
6
  import { useDocBrowser } from '@/components/doc-browser';
7
7
  import { BrandHeader } from '@/components/common/BrandHeader';
@@ -82,6 +82,11 @@ export function Sidebar({ mode }: SidebarProps) {
82
82
  label: t('runtime'),
83
83
  icon: GitBranch,
84
84
  },
85
+ {
86
+ target: '/security',
87
+ label: t('security'),
88
+ icon: Shield,
89
+ },
85
90
  {
86
91
  target: '/sessions',
87
92
  label: t('sessions'),
@@ -0,0 +1,170 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
3
+ import type {
4
+ MarketplaceInstalledView,
5
+ MarketplaceItemSummary,
6
+ MarketplaceListView
7
+ } from '@/api/types';
8
+
9
+ type ItemsQueryState = {
10
+ data?: MarketplaceListView;
11
+ isLoading: boolean;
12
+ isFetching: boolean;
13
+ isError: boolean;
14
+ error: Error | null;
15
+ };
16
+
17
+ type InstalledQueryState = {
18
+ data?: MarketplaceInstalledView;
19
+ isLoading: boolean;
20
+ isFetching: boolean;
21
+ isError: boolean;
22
+ error: Error | null;
23
+ };
24
+
25
+ const mocks = vi.hoisted(() => ({
26
+ navigate: vi.fn(),
27
+ docOpen: vi.fn(),
28
+ confirm: vi.fn(),
29
+ itemsQuery: null as unknown as ItemsQueryState,
30
+ installedQuery: null as unknown as InstalledQueryState,
31
+ installMutation: {
32
+ mutateAsync: vi.fn(),
33
+ isPending: false,
34
+ variables: undefined
35
+ },
36
+ manageMutation: {
37
+ mutate: vi.fn(),
38
+ isPending: false,
39
+ variables: undefined
40
+ }
41
+ }));
42
+
43
+ vi.mock('react-router-dom', async () => {
44
+ const actual = await vi.importActual('react-router-dom');
45
+ return {
46
+ ...(actual as object),
47
+ useNavigate: () => mocks.navigate,
48
+ useParams: () => ({})
49
+ };
50
+ });
51
+
52
+ vi.mock('@/components/doc-browser', () => ({
53
+ useDocBrowser: () => ({
54
+ open: mocks.docOpen
55
+ })
56
+ }));
57
+
58
+ vi.mock('@/components/providers/I18nProvider', () => ({
59
+ useI18n: () => ({
60
+ language: 'en'
61
+ })
62
+ }));
63
+
64
+ vi.mock('@/hooks/useConfirmDialog', () => ({
65
+ useConfirmDialog: () => ({
66
+ confirm: mocks.confirm,
67
+ ConfirmDialog: () => null
68
+ })
69
+ }));
70
+
71
+ vi.mock('@/hooks/useMarketplace', () => ({
72
+ useMarketplaceItems: () => mocks.itemsQuery,
73
+ useMarketplaceInstalled: () => mocks.installedQuery,
74
+ useInstallMarketplaceItem: () => mocks.installMutation,
75
+ useManageMarketplaceItem: () => mocks.manageMutation
76
+ }));
77
+
78
+ function createMarketplaceItem(overrides: Partial<MarketplaceItemSummary> = {}): MarketplaceItemSummary {
79
+ return {
80
+ id: 'skill-web-search',
81
+ slug: 'web-search',
82
+ type: 'skill',
83
+ name: 'Web Search',
84
+ summary: 'Search the web from the marketplace',
85
+ summaryI18n: { en: 'Search the web from the marketplace' },
86
+ tags: ['search'],
87
+ author: 'NextClaw',
88
+ install: {
89
+ kind: 'marketplace',
90
+ spec: '@nextclaw/web-search',
91
+ command: 'nextclaw skills install @nextclaw/web-search'
92
+ },
93
+ updatedAt: '2026-03-17T00:00:00.000Z',
94
+ ...overrides
95
+ };
96
+ }
97
+
98
+ function createItemsQuery(overrides: Partial<Record<string, unknown>> = {}) {
99
+ return {
100
+ data: undefined as MarketplaceListView | undefined,
101
+ isLoading: false,
102
+ isFetching: false,
103
+ isError: false,
104
+ error: null,
105
+ ...overrides
106
+ };
107
+ }
108
+
109
+ function createInstalledQuery(overrides: Partial<Record<string, unknown>> = {}) {
110
+ return {
111
+ data: {
112
+ type: 'skill',
113
+ total: 0,
114
+ specs: [],
115
+ records: []
116
+ } satisfies MarketplaceInstalledView,
117
+ isLoading: false,
118
+ isFetching: false,
119
+ isError: false,
120
+ error: null,
121
+ ...overrides
122
+ };
123
+ }
124
+
125
+ describe('MarketplacePage', () => {
126
+ beforeEach(() => {
127
+ mocks.navigate.mockReset();
128
+ mocks.docOpen.mockReset();
129
+ mocks.confirm.mockReset();
130
+ mocks.installMutation.mutateAsync.mockReset();
131
+ mocks.manageMutation.mutate.mockReset();
132
+ mocks.installMutation.isPending = false;
133
+ mocks.installMutation.variables = undefined;
134
+ mocks.manageMutation.isPending = false;
135
+ mocks.manageMutation.variables = undefined;
136
+ mocks.itemsQuery = createItemsQuery();
137
+ mocks.installedQuery = createInstalledQuery();
138
+ });
139
+
140
+ it('renders skeleton cards during initial skills loading', () => {
141
+ mocks.itemsQuery = createItemsQuery({
142
+ isLoading: true,
143
+ isFetching: true
144
+ });
145
+
146
+ const { container } = render(<MarketplacePage forcedType="skills" />);
147
+
148
+ expect(screen.getByTestId('marketplace-list-skeleton')).toBeTruthy();
149
+ expect(container.querySelectorAll('[data-testid="marketplace-list-skeleton"] > article')).toHaveLength(12);
150
+ });
151
+
152
+ it('keeps loaded cards visible during background refresh', () => {
153
+ mocks.itemsQuery = createItemsQuery({
154
+ data: {
155
+ total: 1,
156
+ page: 1,
157
+ pageSize: 12,
158
+ totalPages: 1,
159
+ sort: 'relevance',
160
+ items: [createMarketplaceItem()]
161
+ } satisfies MarketplaceListView,
162
+ isFetching: true
163
+ });
164
+
165
+ render(<MarketplacePage forcedType="skills" />);
166
+
167
+ expect(screen.queryByTestId('marketplace-list-skeleton')).toBeNull();
168
+ expect(screen.getByText('Web Search')).toBeTruthy();
169
+ });
170
+ });