@nextclaw/ui 0.12.4 → 0.12.6

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 (149) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/assets/ChannelsList-D8p4OlM6.js +8 -0
  3. package/dist/assets/ChatPage-A45t1Rmf.js +58 -0
  4. package/dist/assets/DocBrowser-B2MpsnU9.js +1 -0
  5. package/dist/assets/{DocBrowser-NSzgVKka.js → DocBrowser-Cse_F8Nn.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-DpgVdRgk.js → DocBrowserContext-Bai1WU2H.js} +1 -1
  7. package/dist/assets/{LogoBadge-CHS4YNLw.js → LogoBadge-BdxMPc9v.js} +1 -1
  8. package/dist/assets/MarketplacePage-BNZ3Jx5d.js +1 -0
  9. package/dist/assets/MarketplacePage-BbpAkllU.js +49 -0
  10. package/dist/assets/McpMarketplacePage-CxPFOgxv.js +40 -0
  11. package/dist/assets/ModelConfig-3GLqQ5GY.js +1 -0
  12. package/dist/assets/ProviderScopedModelInput-BYNouw-i.js +1 -0
  13. package/dist/assets/ProvidersList-BR1gJ4Dm.js +1 -0
  14. package/dist/assets/{RemoteAccessPage-yfbrveNQ.js → RemoteAccessPage-DyYVWsyK.js} +1 -1
  15. package/dist/assets/RuntimeConfig-ChdfK4Y_.js +1 -0
  16. package/dist/assets/SearchConfig-DTeJvp8m.js +1 -0
  17. package/dist/assets/{SecretsConfig-CLFSSoTl.js → SecretsConfig-CCYO6NcV.js} +2 -2
  18. package/dist/assets/SessionsConfig-Du39vDgt.js +2 -0
  19. package/dist/assets/app-query-client-Dr5d-K8d.js +1 -0
  20. package/dist/assets/{book-open-C7TAghTk.js → book-open-Da4OEPqB.js} +1 -1
  21. package/dist/assets/chat-session-display-CAlPrnlV.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-DbL4EmiT.js → chunk-JZWAC4HX-CoFVxHXV.js} +1 -1
  23. package/dist/assets/client-CSk58DcF.js +7 -0
  24. package/dist/assets/config-D8KzikVB.js +1 -0
  25. package/dist/assets/{createLucideIcon-BRLFtf-8.js → createLucideIcon-83gaZMtv.js} +1 -1
  26. package/dist/assets/desktop-update-config-CfoVwf-w.js +1 -0
  27. package/dist/assets/dist-aTmhMDVh.js +9 -0
  28. package/dist/assets/{dist-DP-JKR4G.js → dist-toEYs-MZ.js} +1 -1
  29. package/dist/assets/{external-link-BkJkiWbH.js → external-link-QQ0TC6X4.js} +1 -1
  30. package/dist/assets/{hash-CbP6-6R9.js → hash-DaFBEkmi.js} +1 -1
  31. package/dist/assets/i18n-C3jb83S6.js +1 -0
  32. package/dist/assets/index-CE4N7ItL.css +1 -0
  33. package/dist/assets/index-riX7Sg0_.js +6 -0
  34. package/dist/assets/infiniteQueryBehavior-BmHX_ayZ.js +1 -0
  35. package/dist/assets/loader-circle-BjMg63eu.js +1 -0
  36. package/dist/assets/{logos-N3dbS6-I.js → logos-Dzlz30M3.js} +1 -1
  37. package/dist/assets/{page-layout-DyuvlNrg.js → page-layout-D2eRufRQ.js} +1 -1
  38. package/dist/assets/plus-CIXME2pD.js +1 -0
  39. package/dist/assets/{popover-BKKWGUaG.js → popover-BSXxm5bj.js} +1 -1
  40. package/dist/assets/{refresh-ccw-BGMdiNGq.js → refresh-ccw-B3zMtN-_.js} +1 -1
  41. package/dist/assets/refresh-cw-DlZkIHnJ.js +1 -0
  42. package/dist/assets/{save-Dh4GQzzX.js → save-Us9fg4Sj.js} +1 -1
  43. package/dist/assets/search-B_Qr0f6C.js +1 -0
  44. package/dist/assets/security-config-BGWYwxNr.js +1 -0
  45. package/dist/assets/{select-BtIi5fnh.js → select-DLYqySQK.js} +1 -1
  46. package/dist/assets/skeleton-CYQJazv6.js +1 -0
  47. package/dist/assets/{status-dot-C4O-2jZP.js → status-dot-DGayudyB.js} +1 -1
  48. package/dist/assets/{switch-DPegGIa_.js → switch-Dz2ScsKx.js} +1 -1
  49. package/dist/assets/{tabs-custom-x5GZexrF.js → tabs-custom-CdKyjiGk.js} +1 -1
  50. package/dist/assets/{trash-2-CU3LYIpQ.js → trash-2-Db-mZOZs.js} +1 -1
  51. package/dist/assets/use-infinite-scroll-loader-DBJX5hj0.js +1 -0
  52. package/dist/assets/{useConfirmDialog-S5WsGOGf.js → useConfirmDialog-DL0a-oGC.js} +1 -1
  53. package/dist/assets/useMutation-BdZm-9PL.js +1 -0
  54. package/dist/assets/x-B8Tho_xC.js +1 -0
  55. package/dist/index.html +20 -18
  56. package/package.json +6 -6
  57. package/src/App.tsx +2 -0
  58. package/src/account/components/account-panel.tsx +46 -4
  59. package/src/account/managers/account.manager.ts +19 -4
  60. package/src/api/raw-client.test.ts +37 -0
  61. package/src/api/raw-client.ts +51 -8
  62. package/src/api/remote.ts +9 -0
  63. package/src/api/remote.types.ts +5 -0
  64. package/src/components/chat/ChatConversationPanel.test.tsx +344 -142
  65. package/src/components/chat/ChatSidebar.test.tsx +109 -4
  66. package/src/components/chat/ChatSidebar.tsx +62 -9
  67. package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +11 -11
  68. package/src/components/chat/adapters/chat-message.adapter.test.ts +43 -6
  69. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +182 -44
  70. package/src/components/chat/adapters/chat-message.session-spawn-tool-card.test.ts +104 -0
  71. package/src/components/chat/chat-child-session-panel.tsx +155 -59
  72. package/src/components/chat/chat-page-runtime.test.ts +16 -19
  73. package/src/components/chat/chat-session-preference-sync.test.ts +13 -0
  74. package/src/components/chat/chat-session-preference-sync.ts +9 -7
  75. package/src/components/chat/chat-sidebar-session-item.tsx +189 -121
  76. package/src/components/chat/containers/chat-message-list.container.test.tsx +21 -3
  77. package/src/components/chat/containers/chat-message-list.container.tsx +14 -0
  78. package/src/components/chat/hooks/use-chat-session-project.test.tsx +5 -5
  79. package/src/components/chat/hooks/use-chat-session-project.ts +0 -5
  80. package/src/components/chat/hooks/use-chat-session-update.test.tsx +75 -0
  81. package/src/components/chat/hooks/use-chat-session-update.ts +4 -2
  82. package/src/components/chat/managers/chat-session-list.manager.test.ts +79 -5
  83. package/src/components/chat/managers/chat-session-list.manager.ts +31 -4
  84. package/src/components/chat/ncp/NcpChatPage.tsx +32 -51
  85. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +1 -1
  86. package/src/components/chat/ncp/ncp-app-client-fetch.ts +45 -5
  87. package/src/components/chat/ncp/ncp-chat-input.manager.ts +3 -5
  88. package/src/components/chat/ncp/ncp-chat-page-data.ts +0 -1
  89. package/src/components/chat/ncp/ncp-chat.presenter.ts +1 -11
  90. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +35 -9
  91. package/src/components/chat/stores/chat-session-list.store.ts +99 -5
  92. package/src/components/chat/useChatSessionTypeState.test.tsx +0 -3
  93. package/src/components/chat/useChatSessionTypeState.ts +3 -5
  94. package/src/components/config/ChannelsList.test.tsx +68 -0
  95. package/src/components/config/ChannelsList.tsx +22 -4
  96. package/src/components/config/ProviderForm.tsx +9 -15
  97. package/src/components/config/ProvidersList.tsx +17 -3
  98. package/src/components/config/desktop-update-config.tsx +230 -0
  99. package/src/components/config/providers-list.test.tsx +68 -0
  100. package/src/components/layout/Sidebar.tsx +19 -14
  101. package/src/components/layout/sidebar.layout.test.tsx +33 -1
  102. package/src/components/marketplace/MarketplacePage.tsx +30 -30
  103. package/src/components/marketplace/marketplace-page-parts.tsx +16 -24
  104. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +28 -26
  105. package/src/desktop/desktop-update.types.ts +36 -0
  106. package/src/desktop/managers/desktop-update.manager.ts +163 -0
  107. package/src/desktop/stores/desktop-update.store.ts +18 -0
  108. package/src/hooks/marketplace-list-pages.ts +27 -0
  109. package/src/hooks/use-infinite-scroll-loader.ts +88 -0
  110. package/src/hooks/useMarketplace.ts +14 -3
  111. package/src/hooks/useMcpMarketplace.ts +14 -3
  112. package/src/lib/desktop-update-labels.utils.ts +72 -0
  113. package/src/lib/i18n.chat.ts +13 -0
  114. package/src/lib/i18n.remote.ts +15 -0
  115. package/src/lib/i18n.ts +3 -9
  116. package/src/lib/ui-document-title.ts +1 -0
  117. package/src/transport/local.transport.ts +57 -18
  118. package/src/vite-env.d.ts +10 -0
  119. package/dist/assets/ChannelsList-CobWeI2V.js +0 -8
  120. package/dist/assets/ChatPage-ZIdFFVAv.js +0 -43
  121. package/dist/assets/DocBrowser-D55C0iyl.js +0 -1
  122. package/dist/assets/MarketplacePage-BFYsRss_.js +0 -49
  123. package/dist/assets/MarketplacePage-DII-q-Y1.js +0 -1
  124. package/dist/assets/McpMarketplacePage-CPqsGJzz.js +0 -40
  125. package/dist/assets/ModelConfig-Bvuo_IpS.js +0 -1
  126. package/dist/assets/ProviderScopedModelInput-BfY8rGsf.js +0 -1
  127. package/dist/assets/ProvidersList-3tlaqwSS.js +0 -1
  128. package/dist/assets/RuntimeConfig-CAd5Kta3.js +0 -1
  129. package/dist/assets/SearchConfig-DFwgaAa7.js +0 -1
  130. package/dist/assets/SessionsConfig-vYrvc2Fk.js +0 -2
  131. package/dist/assets/chat-session-display-5dVFkJyw.js +0 -1
  132. package/dist/assets/config-CMiW0yaK.js +0 -1
  133. package/dist/assets/dist-BFc_H-lY.js +0 -15
  134. package/dist/assets/i18n-C_2dKw6w.js +0 -1
  135. package/dist/assets/index-ChUXhq0G.css +0 -1
  136. package/dist/assets/index-DAE8Srx-.js +0 -6
  137. package/dist/assets/label-D8yyejJS.js +0 -1
  138. package/dist/assets/loader-circle-B0sKKO29.js +0 -1
  139. package/dist/assets/marketplace-localization-CxSTG9wr.js +0 -1
  140. package/dist/assets/plus-CYXs3JtZ.js +0 -1
  141. package/dist/assets/react-8EIEQjMP.js +0 -1
  142. package/dist/assets/search-DOsLw-P9.js +0 -1
  143. package/dist/assets/security-config-CM_tQRXQ.js +0 -1
  144. package/dist/assets/skeleton-GbHLjPC0.js +0 -1
  145. package/dist/assets/useMutation-DSinpgEq.js +0 -1
  146. package/dist/assets/x-Bnco_K8b.js +0 -1
  147. package/src/components/chat/ChatSessionsSidebar.tsx +0 -100
  148. /package/dist/assets/{config-hints-WtpHP_DW.js → config-hints-GSUMvmSo.js} +0 -0
  149. /package/dist/assets/{config-layout-LQ10ozRC.js → config-layout-CgBMG7OL.js} +0 -0
@@ -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,36 @@
1
+ export type DesktopUpdateStatus =
2
+ | 'idle'
3
+ | 'checking'
4
+ | 'update-available'
5
+ | 'downloading'
6
+ | 'downloaded'
7
+ | 'up-to-date'
8
+ | 'failed';
9
+
10
+ export type DesktopUpdatePreferences = {
11
+ automaticChecks: boolean;
12
+ autoDownload: boolean;
13
+ };
14
+
15
+ export type DesktopUpdateSnapshot = {
16
+ status: DesktopUpdateStatus;
17
+ launcherVersion: string;
18
+ currentVersion: string | null;
19
+ availableVersion: string | null;
20
+ downloadedVersion: string | null;
21
+ releaseNotesUrl: string | null;
22
+ lastCheckedAt: string | null;
23
+ errorMessage: string | null;
24
+ preferences: DesktopUpdatePreferences;
25
+ };
26
+
27
+ export type NextClawDesktopBridge = {
28
+ platform: string;
29
+ version: string;
30
+ getUpdateState: () => Promise<DesktopUpdateSnapshot>;
31
+ checkForUpdates: () => Promise<DesktopUpdateSnapshot>;
32
+ downloadUpdate: () => Promise<DesktopUpdateSnapshot>;
33
+ applyDownloadedUpdate: () => Promise<DesktopUpdateSnapshot>;
34
+ updatePreferences: (preferences: Partial<DesktopUpdatePreferences>) => Promise<DesktopUpdateSnapshot>;
35
+ onUpdateStateChanged: (listener: (snapshot: DesktopUpdateSnapshot) => void) => () => void;
36
+ };
@@ -0,0 +1,163 @@
1
+ import type {
2
+ DesktopUpdatePreferences,
3
+ DesktopUpdateSnapshot,
4
+ NextClawDesktopBridge
5
+ } from '@/desktop/desktop-update.types';
6
+ import { useDesktopUpdateStore } from '@/desktop/stores/desktop-update.store';
7
+ import { t } from '@/lib/i18n';
8
+ import { toast } from 'sonner';
9
+
10
+ type DesktopUpdateBusyAction = 'checking' | 'downloading' | 'applying' | 'saving-preferences';
11
+
12
+ export class DesktopUpdateManager {
13
+ private unsubscribe: (() => void) | null = null;
14
+
15
+ start = async () => {
16
+ const desktopApi = this.getDesktopApi();
17
+ if (!desktopApi) {
18
+ useDesktopUpdateStore.setState({
19
+ supported: false,
20
+ initialized: true,
21
+ snapshot: null
22
+ });
23
+ return;
24
+ }
25
+
26
+ if (!this.unsubscribe) {
27
+ this.unsubscribe = desktopApi.onUpdateStateChanged((snapshot) => {
28
+ useDesktopUpdateStore.setState({
29
+ supported: true,
30
+ initialized: true,
31
+ snapshot
32
+ });
33
+ });
34
+ }
35
+
36
+ useDesktopUpdateStore.setState({
37
+ supported: true,
38
+ initialized: false
39
+ });
40
+
41
+ try {
42
+ const snapshot = await desktopApi.getUpdateState();
43
+ useDesktopUpdateStore.setState({
44
+ supported: true,
45
+ initialized: true,
46
+ snapshot
47
+ });
48
+ } catch (error) {
49
+ useDesktopUpdateStore.setState({
50
+ supported: true,
51
+ initialized: true
52
+ });
53
+ toast.error(`${t('desktopUpdatesLoadFailed')}: ${this.getErrorMessage(error)}`);
54
+ }
55
+ };
56
+
57
+ stop = () => {
58
+ this.unsubscribe?.();
59
+ this.unsubscribe = null;
60
+ };
61
+
62
+ checkForUpdates = async () => {
63
+ let snapshot: DesktopUpdateSnapshot;
64
+ try {
65
+ snapshot = await this.runSnapshotCommand('checking', t('desktopUpdatesCheckFailed'), async (desktopApi) => {
66
+ return await desktopApi.checkForUpdates();
67
+ });
68
+ } catch {
69
+ return;
70
+ }
71
+
72
+ if (snapshot.status === 'up-to-date') {
73
+ toast.success(t('desktopUpdatesAlreadyLatest'));
74
+ return;
75
+ }
76
+ if (snapshot.status === 'update-available') {
77
+ toast.success(
78
+ t('desktopUpdatesAvailable').replace('{version}', snapshot.availableVersion ?? t('desktopUpdatesUnknownVersion'))
79
+ );
80
+ return;
81
+ }
82
+ if (snapshot.status === 'downloaded') {
83
+ toast.success(t('desktopUpdatesReadyToApply'));
84
+ return;
85
+ }
86
+ if (snapshot.status === 'failed' && snapshot.errorMessage) {
87
+ toast.error(snapshot.errorMessage);
88
+ }
89
+ };
90
+
91
+ downloadUpdate = async () => {
92
+ let snapshot: DesktopUpdateSnapshot;
93
+ try {
94
+ snapshot = await this.runSnapshotCommand('downloading', t('desktopUpdatesDownloadFailed'), async (desktopApi) => {
95
+ return await desktopApi.downloadUpdate();
96
+ });
97
+ } catch {
98
+ return;
99
+ }
100
+
101
+ if (snapshot.status === 'downloaded') {
102
+ toast.success(t('desktopUpdatesReadyToApply'));
103
+ }
104
+ };
105
+
106
+ applyDownloadedUpdate = async () => {
107
+ try {
108
+ await this.runSnapshotCommand('applying', t('desktopUpdatesApplyFailed'), async (desktopApi) => {
109
+ return await desktopApi.applyDownloadedUpdate();
110
+ });
111
+ } catch {
112
+ return;
113
+ }
114
+ };
115
+
116
+ updatePreferences = async (preferences: Partial<DesktopUpdatePreferences>) => {
117
+ try {
118
+ await this.runSnapshotCommand(
119
+ 'saving-preferences',
120
+ t('desktopUpdatesPreferencesFailed'),
121
+ async (desktopApi) => await desktopApi.updatePreferences(preferences)
122
+ );
123
+ } catch {
124
+ return;
125
+ }
126
+ };
127
+
128
+ private runSnapshotCommand = async (
129
+ busyAction: DesktopUpdateBusyAction,
130
+ fallbackMessage: string,
131
+ job: (desktopApi: NextClawDesktopBridge) => Promise<DesktopUpdateSnapshot>
132
+ ): Promise<DesktopUpdateSnapshot> => {
133
+ const desktopApi = this.getDesktopApi();
134
+ if (!desktopApi) {
135
+ throw new Error(t('desktopUpdatesDesktopOnlyDescription'));
136
+ }
137
+
138
+ useDesktopUpdateStore.setState({ busyAction });
139
+ try {
140
+ const snapshot = await job(desktopApi);
141
+ useDesktopUpdateStore.setState({ snapshot });
142
+ return snapshot;
143
+ } catch (error) {
144
+ toast.error(`${fallbackMessage}: ${this.getErrorMessage(error)}`);
145
+ throw error;
146
+ } finally {
147
+ useDesktopUpdateStore.setState({ busyAction: null });
148
+ }
149
+ };
150
+
151
+ private getDesktopApi = (): NextClawDesktopBridge | null => {
152
+ if (typeof window === 'undefined') {
153
+ return null;
154
+ }
155
+ return window.nextclawDesktop ?? null;
156
+ };
157
+
158
+ private getErrorMessage = (error: unknown): string => {
159
+ return error instanceof Error ? error.message : t('error');
160
+ };
161
+ }
162
+
163
+ export const desktopUpdateManager = new DesktopUpdateManager();
@@ -0,0 +1,18 @@
1
+ import type { DesktopUpdateSnapshot } from '@/desktop/desktop-update.types';
2
+ import { create } from 'zustand';
3
+
4
+ type DesktopUpdateBusyAction = 'checking' | 'downloading' | 'applying' | 'saving-preferences' | null;
5
+
6
+ type DesktopUpdateStoreState = {
7
+ supported: boolean;
8
+ initialized: boolean;
9
+ busyAction: DesktopUpdateBusyAction;
10
+ snapshot: DesktopUpdateSnapshot | null;
11
+ };
12
+
13
+ export const useDesktopUpdateStore = create<DesktopUpdateStoreState>(() => ({
14
+ supported: false,
15
+ initialized: false,
16
+ busyAction: null,
17
+ snapshot: null
18
+ }));
@@ -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 }) {