@nextclaw/ui 0.12.4 → 0.12.5

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 (115) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/assets/{ChannelsList-CobWeI2V.js → ChannelsList-C6-lh55g.js} +2 -2
  3. package/dist/assets/ChatPage-DOW0gPc2.js +45 -0
  4. package/dist/assets/DocBrowser-CGyeswYP.js +1 -0
  5. package/dist/assets/{DocBrowser-NSzgVKka.js → DocBrowser-QUZ3nfmH.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-DpgVdRgk.js → DocBrowserContext-CpiIfhJO.js} +1 -1
  7. package/dist/assets/{LogoBadge-CHS4YNLw.js → LogoBadge-BUK13xK5.js} +1 -1
  8. package/dist/assets/MarketplacePage-BDVwhIYE.js +1 -0
  9. package/dist/assets/MarketplacePage-LnKKL3xK.js +49 -0
  10. package/dist/assets/McpMarketplacePage-BG4T_Pcx.js +40 -0
  11. package/dist/assets/ModelConfig-LtWuogIw.js +1 -0
  12. package/dist/assets/ProviderScopedModelInput-DGn6sFEN.js +1 -0
  13. package/dist/assets/ProvidersList-ma-_MlLo.js +1 -0
  14. package/dist/assets/{RemoteAccessPage-yfbrveNQ.js → RemoteAccessPage-ff15qO-c.js} +1 -1
  15. package/dist/assets/RuntimeConfig-TgPandXF.js +1 -0
  16. package/dist/assets/SearchConfig-C9iBt7pl.js +1 -0
  17. package/dist/assets/{SecretsConfig-CLFSSoTl.js → SecretsConfig-Bew4EF2A.js} +2 -2
  18. package/dist/assets/{SessionsConfig-vYrvc2Fk.js → SessionsConfig-2r2yAGZg.js} +2 -2
  19. package/dist/assets/{book-open-C7TAghTk.js → book-open-CJG8Yz3U.js} +1 -1
  20. package/dist/assets/{chat-session-display-5dVFkJyw.js → chat-session-display-DkAC5OMC.js} +1 -1
  21. package/dist/assets/{chunk-JZWAC4HX-DbL4EmiT.js → chunk-JZWAC4HX-D5b3Iyas.js} +1 -1
  22. package/dist/assets/{config-CMiW0yaK.js → config-zvnxSXSP.js} +1 -1
  23. package/dist/assets/{createLucideIcon-BRLFtf-8.js → createLucideIcon-_FMJqZw2.js} +1 -1
  24. package/dist/assets/{dist-DP-JKR4G.js → dist-B1fpOuON.js} +1 -1
  25. package/dist/assets/{dist-BFc_H-lY.js → dist-BCXX7FD-.js} +2 -2
  26. package/dist/assets/{external-link-BkJkiWbH.js → external-link-b7gAJWYY.js} +1 -1
  27. package/dist/assets/{hash-CbP6-6R9.js → hash-Bhy4TwfZ.js} +1 -1
  28. package/dist/assets/{i18n-C_2dKw6w.js → i18n-DJg9BPYk.js} +1 -1
  29. package/dist/assets/index-BoJbxdvZ.css +1 -0
  30. package/dist/assets/index-CtlT4E9Y.js +6 -0
  31. package/dist/assets/infiniteQueryBehavior-CTcVlD9s.js +1 -0
  32. package/dist/assets/loader-circle-B60I0hEk.js +1 -0
  33. package/dist/assets/{logos-N3dbS6-I.js → logos-GMeYU9vc.js} +1 -1
  34. package/dist/assets/{page-layout-DyuvlNrg.js → page-layout-C8UbWuMt.js} +1 -1
  35. package/dist/assets/plus-CR7RfK3H.js +1 -0
  36. package/dist/assets/{popover-BKKWGUaG.js → popover-8HSx9wQj.js} +1 -1
  37. package/dist/assets/react-BB4jko2M.js +1 -0
  38. package/dist/assets/{refresh-ccw-BGMdiNGq.js → refresh-ccw-CA4_C7Zg.js} +1 -1
  39. package/dist/assets/{save-Dh4GQzzX.js → save-BtvMy4lk.js} +1 -1
  40. package/dist/assets/search-C60UA27E.js +1 -0
  41. package/dist/assets/security-config-BkFDYZ6j.js +1 -0
  42. package/dist/assets/{select-BtIi5fnh.js → select-xp_Ac8ip.js} +1 -1
  43. package/dist/assets/skeleton-uxz_5h3A.js +1 -0
  44. package/dist/assets/{status-dot-C4O-2jZP.js → status-dot-Cn4Pp7DZ.js} +1 -1
  45. package/dist/assets/{switch-DPegGIa_.js → switch-BTi6UOij.js} +1 -1
  46. package/dist/assets/{tabs-custom-x5GZexrF.js → tabs-custom-BiiN8DME.js} +1 -1
  47. package/dist/assets/{trash-2-CU3LYIpQ.js → trash-2-BpsF0N-r.js} +1 -1
  48. package/dist/assets/use-infinite-scroll-loader-C8jBv11-.js +1 -0
  49. package/dist/assets/{useConfirmDialog-S5WsGOGf.js → useConfirmDialog-BJIwUZjH.js} +1 -1
  50. package/dist/assets/{useMutation-DSinpgEq.js → useMutation-BjBOKHj_.js} +1 -1
  51. package/dist/assets/x-BfTu-g7D.js +1 -0
  52. package/dist/index.html +19 -18
  53. package/package.json +4 -4
  54. package/src/account/components/account-panel.tsx +46 -4
  55. package/src/account/managers/account.manager.ts +19 -4
  56. package/src/api/remote.ts +9 -0
  57. package/src/api/remote.types.ts +5 -0
  58. package/src/components/chat/ChatConversationPanel.test.tsx +183 -141
  59. package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +11 -11
  60. package/src/components/chat/adapters/chat-message.adapter.test.ts +43 -6
  61. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +182 -44
  62. package/src/components/chat/adapters/chat-message.session-spawn-tool-card.test.ts +104 -0
  63. package/src/components/chat/chat-child-session-panel.tsx +103 -45
  64. package/src/components/chat/chat-page-runtime.test.ts +16 -19
  65. package/src/components/chat/chat-session-preference-sync.test.ts +13 -0
  66. package/src/components/chat/chat-session-preference-sync.ts +9 -7
  67. package/src/components/chat/hooks/use-chat-session-project.test.tsx +5 -5
  68. package/src/components/chat/hooks/use-chat-session-project.ts +0 -5
  69. package/src/components/chat/hooks/use-chat-session-update.test.tsx +75 -0
  70. package/src/components/chat/hooks/use-chat-session-update.ts +4 -2
  71. package/src/components/chat/managers/chat-session-list.manager.test.ts +45 -5
  72. package/src/components/chat/managers/chat-session-list.manager.ts +18 -4
  73. package/src/components/chat/ncp/NcpChatPage.tsx +32 -51
  74. package/src/components/chat/ncp/ncp-chat-input.manager.ts +3 -5
  75. package/src/components/chat/ncp/ncp-chat-page-data.ts +0 -1
  76. package/src/components/chat/ncp/ncp-chat.presenter.ts +1 -11
  77. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +20 -7
  78. package/src/components/chat/stores/chat-session-list.store.ts +3 -0
  79. package/src/components/chat/useChatSessionTypeState.test.tsx +0 -3
  80. package/src/components/chat/useChatSessionTypeState.ts +3 -5
  81. package/src/components/config/ChannelsList.test.tsx +68 -0
  82. package/src/components/config/ChannelsList.tsx +22 -4
  83. package/src/components/config/ProvidersList.tsx +17 -3
  84. package/src/components/config/providers-list.test.tsx +68 -0
  85. package/src/components/layout/Sidebar.tsx +13 -13
  86. package/src/components/layout/sidebar.layout.test.tsx +32 -1
  87. package/src/components/marketplace/MarketplacePage.tsx +30 -30
  88. package/src/components/marketplace/marketplace-page-parts.tsx +16 -24
  89. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +28 -26
  90. package/src/hooks/marketplace-list-pages.ts +27 -0
  91. package/src/hooks/use-infinite-scroll-loader.ts +88 -0
  92. package/src/hooks/useMarketplace.ts +14 -3
  93. package/src/hooks/useMcpMarketplace.ts +14 -3
  94. package/src/lib/i18n.remote.ts +15 -0
  95. package/dist/assets/ChatPage-ZIdFFVAv.js +0 -43
  96. package/dist/assets/DocBrowser-D55C0iyl.js +0 -1
  97. package/dist/assets/MarketplacePage-BFYsRss_.js +0 -49
  98. package/dist/assets/MarketplacePage-DII-q-Y1.js +0 -1
  99. package/dist/assets/McpMarketplacePage-CPqsGJzz.js +0 -40
  100. package/dist/assets/ModelConfig-Bvuo_IpS.js +0 -1
  101. package/dist/assets/ProviderScopedModelInput-BfY8rGsf.js +0 -1
  102. package/dist/assets/ProvidersList-3tlaqwSS.js +0 -1
  103. package/dist/assets/RuntimeConfig-CAd5Kta3.js +0 -1
  104. package/dist/assets/SearchConfig-DFwgaAa7.js +0 -1
  105. package/dist/assets/index-ChUXhq0G.css +0 -1
  106. package/dist/assets/index-DAE8Srx-.js +0 -6
  107. package/dist/assets/label-D8yyejJS.js +0 -1
  108. package/dist/assets/loader-circle-B0sKKO29.js +0 -1
  109. package/dist/assets/marketplace-localization-CxSTG9wr.js +0 -1
  110. package/dist/assets/plus-CYXs3JtZ.js +0 -1
  111. package/dist/assets/react-8EIEQjMP.js +0 -1
  112. package/dist/assets/search-DOsLw-P9.js +0 -1
  113. package/dist/assets/security-config-CM_tQRXQ.js +0 -1
  114. package/dist/assets/skeleton-GbHLjPC0.js +0 -1
  115. package/dist/assets/x-Bnco_K8b.js +0 -1
@@ -1,4 +1,4 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { render, screen, within } from '@testing-library/react';
2
2
  import { MemoryRouter } from 'react-router-dom';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
4
  import { Sidebar } from '@/components/layout/Sidebar';
@@ -85,6 +85,37 @@ describe('Sidebar', () => {
85
85
  expect(backLink.className).toContain('hover:bg-gray-200/60');
86
86
  });
87
87
 
88
+ it('keeps the settings navigation in the expected product order', () => {
89
+ const { container } = render(
90
+ <MemoryRouter initialEntries={['/model']}>
91
+ <Sidebar mode="settings" />
92
+ </MemoryRouter>
93
+ );
94
+
95
+ const nav = container.querySelector('nav');
96
+ if (!(nav instanceof HTMLElement)) {
97
+ throw new Error('settings nav not found');
98
+ }
99
+
100
+ const linkTexts = within(nav)
101
+ .getAllByRole('link')
102
+ .map((link) => link.textContent?.trim() || '');
103
+
104
+ expect(linkTexts).toEqual([
105
+ 'Model',
106
+ 'Providers',
107
+ 'Channels',
108
+ 'Search Channels',
109
+ 'Plugins',
110
+ 'MCP',
111
+ 'Routing & Runtime',
112
+ 'Remote Access',
113
+ 'Security',
114
+ 'Sessions',
115
+ 'Secrets'
116
+ ]);
117
+ });
118
+
88
119
  it('keeps the footer utilities compact without changing the top header structure', () => {
89
120
  render(
90
121
  <MemoryRouter initialEntries={['/model']}>
@@ -23,7 +23,7 @@ import {
23
23
  import {
24
24
  FilterPanel,
25
25
  MarketplaceListSkeleton,
26
- PaginationBar
26
+ MarketplaceInfiniteScrollStatus
27
27
  } from '@/components/marketplace/marketplace-page-parts';
28
28
  import { buildLocaleFallbacks, pickLocalizedText } from '@/components/marketplace/marketplace-localization';
29
29
  import { t } from '@/lib/i18n';
@@ -31,6 +31,7 @@ import { PageLayout, PageHeader } from '@/components/layout/page-layout';
31
31
  import { cn } from '@/lib/utils';
32
32
  import { useEffect, useMemo, useState } from 'react';
33
33
  import { useNavigate, useParams } from 'react-router-dom';
34
+ import { useInfiniteScrollLoader } from '@/hooks/use-infinite-scroll-loader';
34
35
 
35
36
  const PAGE_SIZE = 12;
36
37
  const SKELETON_CARD_COUNT = PAGE_SIZE;
@@ -452,32 +453,38 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
452
453
  const [query, setQuery] = useState('');
453
454
  const [scope, setScope] = useState<ScopeType>('all');
454
455
  const [sort, setSort] = useState<MarketplaceSort>('relevance');
455
- const [page, setPage] = useState(1);
456
456
  const [installingSpecs, setInstallingSpecs] = useState<ReadonlySet<string>>(new Set());
457
457
  const [managingTargets, setManagingTargets] = useState<ReadonlyMap<string, MarketplaceManageAction>>(new Map());
458
458
 
459
459
  useEffect(() => {
460
460
  const timer = setTimeout(() => {
461
- setPage(1);
462
461
  setQuery(searchText.trim());
463
462
  }, 250);
464
463
  return () => clearTimeout(timer);
465
464
  }, [searchText]);
466
465
 
467
- useEffect(() => {
468
- setPage(1);
469
- }, [typeFilter]);
470
-
471
466
  const installedQuery = useMarketplaceInstalled(typeFilter);
472
467
 
473
468
  const itemsQuery = useMarketplaceItems({
474
469
  q: query || undefined,
475
470
  type: typeFilter,
476
471
  sort,
477
- page,
478
472
  pageSize: PAGE_SIZE
479
473
  });
480
474
 
475
+ const infiniteScroll = useInfiniteScrollLoader({
476
+ disabled: scope !== 'all' || itemsQuery.isError || !itemsQuery.hasNextPage || itemsQuery.isFetchingNextPage,
477
+ onLoadMore: () => itemsQuery.fetchNextPage(),
478
+ watchValue: `${typeFilter}:${scope}:${query}:${sort}:${itemsQuery.data?.loadedItems ?? 0}:${itemsQuery.data?.loadedPages ?? 0}`
479
+ });
480
+
481
+ useEffect(() => {
482
+ const container = infiniteScroll.containerRef.current;
483
+ if (container && typeof container.scrollTo === 'function') {
484
+ container.scrollTo({ top: 0 });
485
+ }
486
+ }, [infiniteScroll.containerRef, query, scope, sort, typeFilter]);
487
+
481
488
  const installMutation = useInstallMarketplaceItem();
482
489
  const manageMutation = useManageMarketplaceItem();
483
490
  const { confirm, ConfirmDialog } = useConfirmDialog();
@@ -529,7 +536,6 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
529
536
  }, [installedRecords, typeFilter, catalogLookup, query, localeFallbacks]);
530
537
 
531
538
  const total = scope === 'installed' ? installedEntries.length : (itemsQuery.data?.total ?? 0);
532
- const totalPages = scope === 'installed' ? 1 : (itemsQuery.data?.totalPages ?? 0);
533
539
  const showCatalogSkeleton = scope === 'all' && itemsQuery.isLoading && !itemsQuery.data;
534
540
  const showInstalledSkeleton = scope === 'installed' && installedQuery.isLoading && !installedQuery.data;
535
541
  const showListSkeleton = showCatalogSkeleton || showInstalledSkeleton;
@@ -727,10 +733,7 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
727
733
  <Tabs
728
734
  tabs={scopeTabs}
729
735
  activeTab={scope}
730
- onChange={(value) => {
731
- setScope(value as ScopeType);
732
- setPage(1);
733
- }}
736
+ onChange={(value) => setScope(value as ScopeType)}
734
737
  className="mb-4"
735
738
  />
736
739
 
@@ -740,10 +743,7 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
740
743
  searchPlaceholder={t(copyKeys.searchPlaceholder)}
741
744
  sort={sort}
742
745
  onSearchTextChange={setSearchText}
743
- onSortChange={(value) => {
744
- setPage(1);
745
- setSort(value);
746
- }}
746
+ onSortChange={setSort}
747
747
  />
748
748
 
749
749
  <section className="flex min-h-0 flex-1 flex-col">
@@ -765,7 +765,11 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
765
765
  </div>
766
766
  )}
767
767
 
768
- <div className="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1" aria-busy={showListSkeleton}>
768
+ <div
769
+ ref={infiniteScroll.containerRef}
770
+ className="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1"
771
+ aria-busy={showListSkeleton || itemsQuery.isFetchingNextPage}
772
+ >
769
773
  <div
770
774
  data-testid={showListSkeleton ? 'marketplace-list-skeleton' : undefined}
771
775
  className="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3"
@@ -807,20 +811,16 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
807
811
  {scope === 'installed' && !showListSkeleton && !installedQuery.isError && installedEntries.length === 0 && (
808
812
  <div className="text-[13px] text-gray-500 py-8 text-center">{t(copyKeys.emptyInstalled)}</div>
809
813
  )}
810
- </div>
811
- </section>
812
814
 
813
- {scope === 'all' && !showCatalogSkeleton && (
814
- <div className="shrink-0">
815
- <PaginationBar
816
- page={page}
817
- totalPages={totalPages}
818
- busy={itemsQuery.isFetching}
819
- onPrev={() => setPage((current) => Math.max(1, current - 1))}
820
- onNext={() => setPage((current) => (totalPages > 0 ? Math.min(totalPages, current + 1) : current + 1))}
821
- />
815
+ {scope === 'all' && !showCatalogSkeleton && !itemsQuery.isError && (
816
+ <MarketplaceInfiniteScrollStatus
817
+ hasMore={Boolean(itemsQuery.hasNextPage)}
818
+ loading={itemsQuery.isFetchingNextPage}
819
+ sentinelRef={infiniteScroll.sentinelRef}
820
+ />
821
+ )}
822
822
  </div>
823
- )}
823
+ </section>
824
824
  <ConfirmDialog />
825
825
  </PageLayout>
826
826
  );
@@ -3,6 +3,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
3
3
  import { Skeleton } from '@/components/ui/skeleton';
4
4
  import { t } from '@/lib/i18n';
5
5
  import { PackageSearch } from 'lucide-react';
6
+ import type { Ref } from 'react';
6
7
 
7
8
  export function FilterPanel(props: {
8
9
  scope: 'all' | 'installed';
@@ -71,32 +72,23 @@ export function MarketplaceListSkeleton(props: {
71
72
  );
72
73
  }
73
74
 
74
- export function PaginationBar(props: {
75
- page: number;
76
- totalPages: number;
77
- busy: boolean;
78
- onPrev: () => void;
79
- onNext: () => void;
75
+ export function MarketplaceInfiniteScrollStatus(props: {
76
+ hasMore: boolean;
77
+ loading: boolean;
78
+ sentinelRef: Ref<HTMLDivElement>;
80
79
  }) {
80
+ if (!props.hasMore && !props.loading) {
81
+ return null;
82
+ }
83
+
81
84
  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>
85
+ <div className="py-4">
86
+ {props.hasMore && <div ref={props.sentinelRef} className="h-1 w-full" aria-hidden="true" />}
87
+ {props.loading && (
88
+ <div data-testid="marketplace-loading-more" className="pt-3 text-center text-xs text-gray-500">
89
+ {t('loading')}
90
+ </div>
91
+ )}
100
92
  </div>
101
93
  );
102
94
  }
@@ -36,6 +36,8 @@ import {
36
36
  } from '@/components/marketplace/marketplace-localization';
37
37
  import { t } from '@/lib/i18n';
38
38
  import { cn } from '@/lib/utils';
39
+ import { MarketplaceInfiniteScrollStatus } from '@/components/marketplace/marketplace-page-parts';
40
+ import { useInfiniteScrollLoader } from '@/hooks/use-infinite-scroll-loader';
39
41
 
40
42
  type ScopeType = 'catalog' | 'installed';
41
43
 
@@ -256,7 +258,6 @@ export function McpMarketplacePage() {
256
258
  const [searchText, setSearchText] = useState('');
257
259
  const [query, setQuery] = useState('');
258
260
  const [sort, setSort] = useState<MarketplaceSort>('relevance');
259
- const [page, setPage] = useState(1);
260
261
  const [installingItem, setInstallingItem] = useState<MarketplaceItemSummary | null>(null);
261
262
  const [doctorTarget, setDoctorTarget] = useState<string | null>(null);
262
263
  const [doctorResult, setDoctorResult] = useState<MarketplaceMcpDoctorResult | null>(null);
@@ -267,7 +268,6 @@ export function McpMarketplacePage() {
267
268
 
268
269
  useEffect(() => {
269
270
  const timer = window.setTimeout(() => {
270
- setPage(1);
271
271
  setQuery(searchText.trim());
272
272
  }, 250);
273
273
  return () => window.clearTimeout(timer);
@@ -276,11 +276,23 @@ export function McpMarketplacePage() {
276
276
  const itemsQuery = useMcpMarketplaceItems({
277
277
  q: query || undefined,
278
278
  sort,
279
- page,
280
279
  pageSize: PAGE_SIZE
281
280
  });
282
281
  const installedQuery = useMcpMarketplaceInstalled();
283
282
 
283
+ const infiniteScroll = useInfiniteScrollLoader({
284
+ disabled: scope !== 'catalog' || itemsQuery.isError || !itemsQuery.hasNextPage || itemsQuery.isFetchingNextPage,
285
+ onLoadMore: () => itemsQuery.fetchNextPage(),
286
+ watchValue: `${scope}:${query}:${sort}:${itemsQuery.data?.loadedItems ?? 0}:${itemsQuery.data?.loadedPages ?? 0}`
287
+ });
288
+
289
+ useEffect(() => {
290
+ const container = infiniteScroll.containerRef.current;
291
+ if (container && typeof container.scrollTo === 'function') {
292
+ container.scrollTo({ top: 0 });
293
+ }
294
+ }, [infiniteScroll.containerRef, query, scope, sort]);
295
+
284
296
  const installMutation = useInstallMcpMarketplaceItem();
285
297
  const manageMutation = useManageMcpMarketplaceItem();
286
298
  const doctorMutation = useMutation({
@@ -502,7 +514,11 @@ export function McpMarketplacePage() {
502
514
  </span>
503
515
  </div>
504
516
 
505
- <div className="min-h-0 flex-1 overflow-y-auto pr-1">
517
+ <div
518
+ ref={infiniteScroll.containerRef}
519
+ className="min-h-0 flex-1 overflow-y-auto pr-1"
520
+ aria-busy={itemsQuery.isLoading || itemsQuery.isFetchingNextPage}
521
+ >
506
522
  <div className={cn('grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3')}>
507
523
  {scope === 'catalog' && itemsQuery.isLoading && Array.from({ length: 6 }, (_, index) => (
508
524
  <div key={index} className="rounded-2xl border border-gray-200/70 bg-white p-4 shadow-sm">
@@ -533,30 +549,16 @@ export function McpMarketplacePage() {
533
549
  {scope === 'installed' && installedRecords.length === 0 && (
534
550
  <div className="py-8 text-center text-sm text-gray-500">{t('marketplaceNoInstalledMcp')}</div>
535
551
  )}
536
- </div>
537
- </section>
538
552
 
539
- {scope === 'catalog' && (
540
- <div className="mt-4 flex items-center justify-end gap-2">
541
- <button
542
- className="h-8 rounded-xl border border-gray-200/80 px-3 text-sm text-gray-600 disabled:opacity-40"
543
- onClick={() => setPage((current) => Math.max(1, current - 1))}
544
- disabled={page <= 1 || itemsQuery.isFetching}
545
- >
546
- {t('prev')}
547
- </button>
548
- <div className="min-w-20 text-center text-sm text-gray-600">
549
- {itemsQuery.data?.totalPages ? `${page} / ${itemsQuery.data.totalPages}` : '0 / 0'}
550
- </div>
551
- <button
552
- className="h-8 rounded-xl border border-gray-200/80 px-3 text-sm text-gray-600 disabled:opacity-40"
553
- onClick={() => setPage((current) => current + 1)}
554
- disabled={!itemsQuery.data?.totalPages || page >= itemsQuery.data.totalPages || itemsQuery.isFetching}
555
- >
556
- {t('next')}
557
- </button>
553
+ {scope === 'catalog' && !itemsQuery.isError && (
554
+ <MarketplaceInfiniteScrollStatus
555
+ hasMore={Boolean(itemsQuery.hasNextPage)}
556
+ loading={itemsQuery.isFetchingNextPage}
557
+ sentinelRef={infiniteScroll.sentinelRef}
558
+ />
559
+ )}
558
560
  </div>
559
- )}
561
+ </section>
560
562
 
561
563
  <InstallDialog
562
564
  item={installingItem}
@@ -0,0 +1,27 @@
1
+ import type { InfiniteData } from '@tanstack/react-query';
2
+ import type { MarketplaceListView } from '@/api/types';
3
+
4
+ export type InfiniteMarketplaceListView = MarketplaceListView & {
5
+ pages: MarketplaceListView[];
6
+ loadedItems: number;
7
+ loadedPages: number;
8
+ };
9
+
10
+ export function collapseMarketplaceListPages(
11
+ data: InfiniteData<MarketplaceListView> | undefined
12
+ ): InfiniteMarketplaceListView | undefined {
13
+ if (!data || data.pages.length === 0) {
14
+ return undefined;
15
+ }
16
+
17
+ const items = data.pages.flatMap((page) => page.items);
18
+ const lastPage = data.pages[data.pages.length - 1];
19
+
20
+ return {
21
+ ...lastPage,
22
+ items,
23
+ pages: data.pages,
24
+ loadedItems: items.length,
25
+ loadedPages: data.pages.length
26
+ };
27
+ }
@@ -0,0 +1,88 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ const DEFAULT_THRESHOLD_PX = 160;
4
+
5
+ type UseInfiniteScrollLoaderParams = {
6
+ disabled: boolean;
7
+ onLoadMore: () => Promise<unknown> | unknown;
8
+ thresholdPx?: number;
9
+ watchValue?: string | number;
10
+ };
11
+
12
+ export function useInfiniteScrollLoader(params: UseInfiniteScrollLoaderParams) {
13
+ const containerRef = useRef<HTMLDivElement | null>(null);
14
+ const sentinelRef = useRef<HTMLDivElement | null>(null);
15
+ const onLoadMoreRef = useRef(params.onLoadMore);
16
+ const loadingRef = useRef(false);
17
+
18
+ useEffect(() => {
19
+ onLoadMoreRef.current = params.onLoadMore;
20
+ }, [params.onLoadMore]);
21
+
22
+ useEffect(() => {
23
+ if (params.disabled) {
24
+ loadingRef.current = false;
25
+ }
26
+ }, [params.disabled]);
27
+
28
+ useEffect(() => {
29
+ const container = containerRef.current;
30
+ const sentinel = sentinelRef.current;
31
+ const thresholdPx = params.thresholdPx ?? DEFAULT_THRESHOLD_PX;
32
+
33
+ if (params.disabled || !container || !sentinel) {
34
+ return;
35
+ }
36
+
37
+ const triggerLoadMore = () => {
38
+ if (loadingRef.current || params.disabled) {
39
+ return;
40
+ }
41
+
42
+ loadingRef.current = true;
43
+ Promise.resolve(onLoadMoreRef.current()).finally(() => {
44
+ loadingRef.current = false;
45
+ });
46
+ };
47
+
48
+ const maybeLoadMore = () => {
49
+ const remainingDistance = sentinel.getBoundingClientRect().top - container.getBoundingClientRect().bottom;
50
+ if (remainingDistance <= thresholdPx) {
51
+ triggerLoadMore();
52
+ }
53
+ };
54
+
55
+ if (typeof IntersectionObserver === 'function') {
56
+ const observer = new IntersectionObserver(
57
+ (entries) => {
58
+ if (entries.some((entry) => entry.isIntersecting)) {
59
+ triggerLoadMore();
60
+ }
61
+ },
62
+ {
63
+ root: container,
64
+ rootMargin: `0px 0px ${thresholdPx}px 0px`
65
+ }
66
+ );
67
+
68
+ observer.observe(sentinel);
69
+ maybeLoadMore();
70
+
71
+ return () => {
72
+ observer.disconnect();
73
+ };
74
+ }
75
+
76
+ container.addEventListener('scroll', maybeLoadMore, { passive: true });
77
+ maybeLoadMore();
78
+
79
+ return () => {
80
+ container.removeEventListener('scroll', maybeLoadMore);
81
+ };
82
+ }, [params.disabled, params.thresholdPx, params.watchValue]);
83
+
84
+ return {
85
+ containerRef,
86
+ sentinelRef
87
+ };
88
+ }
@@ -1,4 +1,4 @@
1
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
1
+ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
2
  import { toast } from 'sonner';
3
3
  import { t } from '@/lib/i18n';
4
4
  import {
@@ -20,11 +20,15 @@ import type {
20
20
  MarketplaceItemType,
21
21
  MarketplaceManageRequest
22
22
  } from '@/api/types';
23
+ import { collapseMarketplaceListPages } from '@/hooks/marketplace-list-pages';
24
+ import { useMemo } from 'react';
23
25
 
24
26
  export function useMarketplaceItems(params: MarketplaceListParams) {
25
- return useQuery({
27
+ const query = useInfiniteQuery({
26
28
  queryKey: ['marketplace-items', params],
27
- queryFn: () => fetchMarketplaceItems(params),
29
+ initialPageParam: 1,
30
+ queryFn: ({ pageParam }) => fetchMarketplaceItems({ ...params, page: pageParam }),
31
+ getNextPageParam: (lastPage) => (lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined),
28
32
  placeholderData: (previousData, previousQuery) => {
29
33
  const previousParams = previousQuery?.queryKey?.[1];
30
34
  if (!previousParams || typeof previousParams !== 'object' || previousParams === null) {
@@ -36,6 +40,13 @@ export function useMarketplaceItems(params: MarketplaceListParams) {
36
40
  },
37
41
  staleTime: 15_000
38
42
  });
43
+
44
+ const data = useMemo(() => collapseMarketplaceListPages(query.data), [query.data]);
45
+
46
+ return {
47
+ ...query,
48
+ data
49
+ };
39
50
  }
40
51
 
41
52
  export function useMarketplaceRecommendations(type: MarketplaceItemType, params: { scene?: string; limit?: number }) {
@@ -1,4 +1,4 @@
1
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
1
+ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
2
  import { toast } from 'sonner';
3
3
  import {
4
4
  doctorMcpMarketplaceItem,
@@ -12,13 +12,24 @@ import {
12
12
  type McpMarketplaceListParams
13
13
  } from '@/api/mcp-marketplace';
14
14
  import { t } from '@/lib/i18n';
15
+ import { collapseMarketplaceListPages } from '@/hooks/marketplace-list-pages';
16
+ import { useMemo } from 'react';
15
17
 
16
18
  export function useMcpMarketplaceItems(params: McpMarketplaceListParams) {
17
- return useQuery({
19
+ const query = useInfiniteQuery({
18
20
  queryKey: ['marketplace-mcp-items', params],
19
- queryFn: () => fetchMcpMarketplaceItems(params),
21
+ initialPageParam: 1,
22
+ queryFn: ({ pageParam }) => fetchMcpMarketplaceItems({ ...params, page: pageParam }),
23
+ getNextPageParam: (lastPage) => (lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined),
20
24
  staleTime: 15_000
21
25
  });
26
+
27
+ const data = useMemo(() => collapseMarketplaceListPages(query.data), [query.data]);
28
+
29
+ return {
30
+ ...query,
31
+ data
32
+ };
22
33
  }
23
34
 
24
35
  export function useMcpMarketplaceInstalled() {
@@ -168,6 +168,21 @@ export const REMOTE_LABELS: Record<string, { zh: string; en: string }> = {
168
168
  en: 'Authorize this device in your browser and connect it to the NextClaw platform.'
169
169
  },
170
170
  remoteAccountEmail: { zh: '邮箱', en: 'Email' },
171
+ remoteAccountUsername: { zh: '用户名', en: 'Username' },
172
+ remoteAccountUsernameRequiredTitle: { zh: '发布 skill 前需要先设置用户名', en: 'Set a username before publishing skills' },
173
+ remoteAccountUsernameRequiredDescription: {
174
+ zh: '平台账号已经登录,但还没有正式用户名。设置后,你的个人 skill 会使用 `@username/skill-name` 作为发布标识。',
175
+ en: 'Your platform account is already signed in, but it does not have a username yet. Once set, your personal skills will publish as `@username/skill-name`.'
176
+ },
177
+ remoteAccountUsernamePlaceholder: { zh: '例如:alice-dev', en: 'For example: alice-dev' },
178
+ remoteAccountUsernameSave: { zh: '保存用户名', en: 'Save Username' },
179
+ remoteAccountUsernameSaving: { zh: '保存中...', en: 'Saving...' },
180
+ remoteAccountUsernameSetSuccess: { zh: '用户名已保存', en: 'Username saved' },
181
+ remoteAccountUsernameSetFailed: { zh: '用户名保存失败', en: 'Failed to save username' },
182
+ remoteAccountUsernameLockedHelp: {
183
+ zh: '当前版本先支持首次设置用户名;设置后暂不支持改名。',
184
+ en: 'This version supports initial username setup only. Renaming is not available yet.'
185
+ },
171
186
  remoteAccountRole: { zh: '角色', en: 'Role' },
172
187
  remoteApiBase: { zh: 'API Base', en: 'API Base' },
173
188
  remoteBrowserAuthTitle: { zh: '浏览器授权登录', en: 'Browser Authorization' },