@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
@@ -100,6 +100,31 @@ describe('ChannelsList', () => {
100
100
  mocks.updateChannelMutateAsync.mockReset();
101
101
  mocks.startChannelAuthMutateAsync.mockReset();
102
102
  mocks.pollChannelAuthMutateAsync.mockReset();
103
+ mocks.configQuery.data = {
104
+ channels: {
105
+ weixin: {
106
+ enabled: false,
107
+ defaultAccountId: '1344b2b24720@im.bot',
108
+ baseUrl: 'https://ilinkai.weixin.qq.com',
109
+ pollTimeoutMs: 35000,
110
+ allowFrom: ['o9cq804svxfyCCTIqzddDqRBeMC0@im.wechat'],
111
+ accounts: {
112
+ '1344b2b24720@im.bot': {
113
+ enabled: true
114
+ }
115
+ }
116
+ }
117
+ }
118
+ };
119
+ mocks.metaQuery.data = {
120
+ channels: [
121
+ {
122
+ name: 'weixin',
123
+ displayName: 'Weixin',
124
+ enabled: false
125
+ }
126
+ ]
127
+ };
103
128
  });
104
129
 
105
130
  it('renders weixin qr auth card and starts channel auth', async () => {
@@ -140,6 +165,49 @@ describe('ChannelsList', () => {
140
165
  });
141
166
  });
142
167
 
168
+ it('keeps Weixin, Feishu, Discord, and QQ at the front of the channel list', async () => {
169
+ const user = userEvent.setup();
170
+ mocks.configQuery.data = {
171
+ channels: {
172
+ telegram: { enabled: false },
173
+ qq: { enabled: false },
174
+ discord: { enabled: false },
175
+ weixin: { enabled: false },
176
+ feishu: { enabled: false }
177
+ }
178
+ } as unknown as typeof mocks.configQuery.data;
179
+ mocks.metaQuery.data = {
180
+ channels: [
181
+ { name: 'telegram', displayName: 'Telegram', enabled: false },
182
+ { name: 'qq', displayName: 'QQ', enabled: false },
183
+ { name: 'discord', displayName: 'Discord', enabled: false },
184
+ { name: 'weixin', displayName: 'Weixin', enabled: false },
185
+ { name: 'feishu', displayName: 'Feishu', enabled: false }
186
+ ]
187
+ } as typeof mocks.metaQuery.data;
188
+
189
+ const { container } = render(<ChannelsList />);
190
+
191
+ await user.click(await screen.findByRole('button', { name: /All Channels/i }));
192
+
193
+ const sidebarSection = container.querySelector('section');
194
+ if (!(sidebarSection instanceof HTMLElement)) {
195
+ throw new Error('channel sidebar not found');
196
+ }
197
+
198
+ const channelButtons = Array.from(sidebarSection.querySelectorAll('button[type="button"]')).filter((button) => (
199
+ ['Weixin', 'Feishu', 'Discord', 'QQ', 'Telegram'].some((label) => button.textContent?.includes(label))
200
+ ));
201
+
202
+ expect(channelButtons.map((button) => button.textContent)).toEqual([
203
+ expect.stringContaining('Weixin'),
204
+ expect.stringContaining('Feishu'),
205
+ expect.stringContaining('Discord'),
206
+ expect.stringContaining('QQ'),
207
+ expect.stringContaining('Telegram')
208
+ ]);
209
+ });
210
+
143
211
  it('saves weixin advanced settings from the advanced section', async () => {
144
212
  const user = userEvent.setup();
145
213
 
@@ -24,6 +24,24 @@ const channelDescriptionKeys: Record<string, string> = {
24
24
  weixin: 'channelDescWeixin'
25
25
  };
26
26
 
27
+ const prioritizedChannelNames = ['weixin', 'feishu', 'discord', 'qq'] as const;
28
+
29
+ function sortChannelsForDisplay<T extends { name: string }>(channels: T[]): T[] {
30
+ const priorityByName = new Map<string, number>(prioritizedChannelNames.map((name, index) => [name, index]));
31
+
32
+ return channels
33
+ .map((channel, index) => ({ channel, index }))
34
+ .sort((left, right) => {
35
+ const leftPriority = priorityByName.get(left.channel.name) ?? Number.POSITIVE_INFINITY;
36
+ const rightPriority = priorityByName.get(right.channel.name) ?? Number.POSITIVE_INFINITY;
37
+ if (leftPriority !== rightPriority) {
38
+ return leftPriority - rightPriority;
39
+ }
40
+ return left.index - right.index;
41
+ })
42
+ .map(({ channel }) => channel);
43
+ }
44
+
27
45
  export function ChannelsList() {
28
46
  const { data: config } = useConfig();
29
47
  const { data: meta } = useConfigMeta();
@@ -32,17 +50,17 @@ export function ChannelsList() {
32
50
  const [selectedChannel, setSelectedChannel] = useState<string | undefined>();
33
51
  const [query, setQuery] = useState('');
34
52
  const uiHints = schema?.uiHints;
35
- const channels = meta?.channels;
53
+ const channels = useMemo(() => sortChannelsForDisplay(meta?.channels ?? []), [meta?.channels]);
36
54
  const channelConfigs = config?.channels;
37
55
 
38
56
  const tabs = [
39
- { id: 'enabled', label: t('channelsTabEnabled'), count: (channels ?? []).filter((c) => channelConfigs?.[c.name]?.enabled).length },
40
- { id: 'all', label: t('channelsTabAll'), count: (channels ?? []).length }
57
+ { id: 'enabled', label: t('channelsTabEnabled'), count: channels.filter((c) => channelConfigs?.[c.name]?.enabled).length },
58
+ { id: 'all', label: t('channelsTabAll'), count: channels.length }
41
59
  ];
42
60
 
43
61
  const filteredChannels = useMemo(() => {
44
62
  const keyword = query.trim().toLowerCase();
45
- return (channels ?? [])
63
+ return channels
46
64
  .filter((channel) => {
47
65
  const enabled = channelConfigs?.[channel.name]?.enabled || false;
48
66
  if (activeTab === 'enabled') {
@@ -143,6 +143,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
143
143
  () => providerAuth?.methods ?? [],
144
144
  [providerAuth?.methods]
145
145
  );
146
+ const supportsWireApi = Boolean(providerSpec?.supportsWireApi) || isCustomProvider;
146
147
  const providerAuthMethodOptions = useMemo(
147
148
  () =>
148
149
  providerAuthMethods.map((method) => ({
@@ -174,25 +175,18 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
174
175
  () => providerAuthMethods.find((method) => method.id === resolvedAuthMethodId),
175
176
  [providerAuthMethods, resolvedAuthMethodId]
176
177
  );
177
- const selectedAuthMethodHint =
178
- selectedAuthMethod?.hint?.[language] || selectedAuthMethod?.hint?.en || '';
179
178
  const shouldUseAuthMethodPills = shouldUsePillSelector({
180
179
  required: providerAuth?.kind === 'device_code',
181
180
  hasDefault: Boolean(providerAuth?.defaultMethodId?.trim()),
182
181
  optionCount: providerAuthMethods.length
183
182
  });
184
- const providerAuthNote =
185
- providerAuth?.note?.[language] ||
186
- providerAuth?.note?.en ||
187
- providerAuth?.displayName ||
188
- '';
189
183
  const wireApiOptions = providerSpec?.wireApiOptions || ['auto', 'chat', 'responses'];
190
184
  const wireApiSelectOptions: PillSelectOption[] = wireApiOptions.map((option) => ({
191
185
  value: option,
192
186
  label: option === 'chat' ? t('wireApiChat') : option === 'responses' ? t('wireApiResponses') : t('wireApiAuto')
193
187
  }));
194
188
  const shouldUseWireApiPills = shouldUsePillSelector({
195
- required: Boolean(providerSpec?.supportsWireApi),
189
+ required: supportsWireApi,
196
190
  hasDefault: typeof providerSpec?.defaultWireApi === 'string' && providerSpec.defaultWireApi.length > 0,
197
191
  optionCount: wireApiSelectOptions.length
198
192
  });
@@ -302,7 +296,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
302
296
  const apiKeyChanged = apiKey.trim().length > 0;
303
297
  const apiBaseChanged = apiBase.trim() !== currentApiBase.trim();
304
298
  const headersChanged = !headersEqual(extraHeaders, currentHeaders);
305
- const wireApiChanged = providerSpec?.supportsWireApi ? wireApi !== currentWireApi : false;
299
+ const wireApiChanged = supportsWireApi ? wireApi !== currentWireApi : false;
306
300
  const modelsChanged = !modelListsEqual(models, currentEditableModels);
307
301
  const modelThinkingChanged = !modelThinkingEqual(modelThinking, currentModelThinking);
308
302
  const displayNameChanged = isCustomProvider
@@ -331,7 +325,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
331
325
  currentApiBase,
332
326
  extraHeaders,
333
327
  currentHeaders,
334
- providerSpec?.supportsWireApi,
328
+ supportsWireApi,
335
329
  wireApi,
336
330
  currentWireApi,
337
331
  models,
@@ -422,7 +416,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
422
416
  payload.extraHeaders = normalizedHeaders;
423
417
  }
424
418
 
425
- if (providerSpec?.supportsWireApi && wireApi !== currentWireApi) {
419
+ if (supportsWireApi && wireApi !== currentWireApi) {
426
420
  payload.wireApi = wireApi;
427
421
  }
428
422
  if (!modelListsEqual(models, currentEditableModels)) {
@@ -450,7 +444,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
450
444
  if (apiKey.trim().length > 0) {
451
445
  payload.apiKey = apiKey.trim();
452
446
  }
453
- if (providerSpec?.supportsWireApi) {
447
+ if (supportsWireApi) {
454
448
  payload.wireApi = wireApi;
455
449
  }
456
450
 
@@ -608,10 +602,10 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
608
602
 
609
603
  <ProviderAuthSection
610
604
  providerAuth={providerAuth}
611
- providerAuthNote={providerAuthNote}
605
+ providerAuthNote={providerAuth?.note?.[language] || providerAuth?.note?.en || providerAuth?.displayName || ''}
612
606
  providerAuthMethodOptions={providerAuthMethodOptions}
613
607
  providerAuthMethodsCount={providerAuthMethods.length}
614
- selectedAuthMethodHint={selectedAuthMethodHint}
608
+ selectedAuthMethodHint={selectedAuthMethod?.hint?.[language] || selectedAuthMethod?.hint?.en || ''}
615
609
  shouldUseAuthMethodPills={shouldUseAuthMethodPills}
616
610
  resolvedAuthMethodId={resolvedAuthMethodId}
617
611
  onAuthMethodChange={setAuthMethodId}
@@ -663,7 +657,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
663
657
  <ProviderAdvancedSettingsSection
664
658
  showAdvanced={showAdvanced}
665
659
  onShowAdvancedChange={setShowAdvanced}
666
- supportsWireApi={Boolean(providerSpec.supportsWireApi)}
660
+ supportsWireApi={supportsWireApi}
667
661
  wireApiLabel={wireApiHint?.label ?? t('wireApi')}
668
662
  wireApi={wireApi}
669
663
  onWireApiChange={setWireApi}
@@ -26,6 +26,20 @@ function formatBasePreview(base?: string | null): string | null {
26
26
  }
27
27
  }
28
28
 
29
+ function sortProvidersForDisplay<T extends { name: string }>(providers: T[]): T[] {
30
+ return providers
31
+ .map((provider, index) => ({ provider, index }))
32
+ .sort((left, right) => {
33
+ const leftPriority = left.provider.name === 'nextclaw' ? 1 : 0;
34
+ const rightPriority = right.provider.name === 'nextclaw' ? 1 : 0;
35
+ if (leftPriority !== rightPriority) {
36
+ return leftPriority - rightPriority;
37
+ }
38
+ return left.index - right.index;
39
+ })
40
+ .map(({ provider }) => provider);
41
+ }
42
+
29
43
  export function ProvidersList() {
30
44
  const { data: config } = useConfig();
31
45
  const { data: meta } = useConfigMeta();
@@ -37,7 +51,7 @@ export function ProvidersList() {
37
51
  const [query, setQuery] = useState('');
38
52
 
39
53
  const uiHints = schema?.uiHints;
40
- const providers = meta?.providers ?? [];
54
+ const providers = useMemo(() => sortProvidersForDisplay(meta?.providers ?? []), [meta?.providers]);
41
55
  const providersConfig = config?.providers ?? {};
42
56
  const configuredCount = providers.filter((provider) => {
43
57
  const current = providersConfig[provider.name];
@@ -50,7 +64,7 @@ export function ProvidersList() {
50
64
  ];
51
65
 
52
66
  const filteredProviders = useMemo(() => {
53
- const baseProviders = meta?.providers ?? [];
67
+ const baseProviders = providers;
54
68
  const baseConfig = config?.providers ?? {};
55
69
  const keyword = query.trim().toLowerCase();
56
70
  return baseProviders
@@ -69,7 +83,7 @@ export function ProvidersList() {
69
83
  const display = (configDisplayName || provider.displayName || provider.name).toLowerCase();
70
84
  return display.includes(keyword) || provider.name.toLowerCase().includes(keyword);
71
85
  });
72
- }, [meta, config, activeTab, query]);
86
+ }, [providers, config, activeTab, query]);
73
87
 
74
88
  useEffect(() => {
75
89
  if (filteredProviders.length === 0) {
@@ -0,0 +1,230 @@
1
+ import { useEffect } from 'react';
2
+ import { Button } from '@/components/ui/button';
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
4
+ import { Label } from '@/components/ui/label';
5
+ import { Switch } from '@/components/ui/switch';
6
+ import { PageHeader, PageLayout } from '@/components/layout/page-layout';
7
+ import { desktopUpdateManager } from '@/desktop/managers/desktop-update.manager';
8
+ import { useDesktopUpdateStore } from '@/desktop/stores/desktop-update.store';
9
+ import { formatDateTime, t } from '@/lib/i18n';
10
+ import { cn } from '@/lib/utils';
11
+ import { Download, ExternalLink, RefreshCw, RotateCw } from 'lucide-react';
12
+
13
+ function formatVersion(value: string | null): string {
14
+ return value?.trim() || '-';
15
+ }
16
+
17
+ function formatLastCheckedAt(value: string | null): string {
18
+ return value ? formatDateTime(value) : '-';
19
+ }
20
+
21
+ function getStatusLabel(status: string): string {
22
+ if (status === 'checking') {
23
+ return t('desktopUpdatesStatusChecking');
24
+ }
25
+ if (status === 'update-available') {
26
+ return t('desktopUpdatesStatusAvailable');
27
+ }
28
+ if (status === 'downloading') {
29
+ return t('desktopUpdatesStatusDownloading');
30
+ }
31
+ if (status === 'downloaded') {
32
+ return t('desktopUpdatesStatusDownloaded');
33
+ }
34
+ if (status === 'up-to-date') {
35
+ return t('desktopUpdatesStatusUpToDate');
36
+ }
37
+ if (status === 'failed') {
38
+ return t('desktopUpdatesStatusFailed');
39
+ }
40
+ return t('desktopUpdatesStatusIdle');
41
+ }
42
+
43
+ function getStatusTone(status: string): string {
44
+ if (status === 'downloaded') {
45
+ return 'bg-emerald-50 text-emerald-700 ring-emerald-100';
46
+ }
47
+ if (status === 'update-available' || status === 'downloading' || status === 'checking') {
48
+ return 'bg-amber-50 text-amber-700 ring-amber-100';
49
+ }
50
+ if (status === 'failed') {
51
+ return 'bg-red-50 text-red-700 ring-red-100';
52
+ }
53
+ return 'bg-gray-100 text-gray-700 ring-gray-200';
54
+ }
55
+
56
+ export function DesktopUpdateConfig() {
57
+ const { supported, initialized, busyAction, snapshot } = useDesktopUpdateStore();
58
+
59
+ useEffect(() => {
60
+ void desktopUpdateManager.start();
61
+ return () => {
62
+ desktopUpdateManager.stop();
63
+ };
64
+ }, []);
65
+
66
+ if (!initialized) {
67
+ return <div className="p-8 text-gray-400">{t('loading')}</div>;
68
+ }
69
+
70
+ if (!supported || !snapshot) {
71
+ return (
72
+ <PageLayout className="space-y-6">
73
+ <PageHeader
74
+ title={t('desktopUpdatesPageTitle')}
75
+ description={t('desktopUpdatesPageDescription')}
76
+ />
77
+ <Card>
78
+ <CardHeader>
79
+ <CardTitle>{t('desktopUpdatesDesktopOnlyTitle')}</CardTitle>
80
+ <CardDescription>{t('desktopUpdatesDesktopOnlyDescription')}</CardDescription>
81
+ </CardHeader>
82
+ <CardContent>
83
+ <p className="text-sm text-gray-500">{t('desktopUpdatesDesktopOnlyFutureHint')}</p>
84
+ </CardContent>
85
+ </Card>
86
+ </PageLayout>
87
+ );
88
+ }
89
+
90
+ const isChecking = busyAction === 'checking';
91
+ const isDownloading = busyAction === 'downloading';
92
+ const isApplying = busyAction === 'applying';
93
+ const isSavingPreferences = busyAction === 'saving-preferences';
94
+ const canDownload = snapshot.status === 'update-available' && !isDownloading && !isApplying;
95
+ const canApply = snapshot.status === 'downloaded' && !isApplying;
96
+
97
+ return (
98
+ <PageLayout className="space-y-6">
99
+ <PageHeader
100
+ title={t('desktopUpdatesPageTitle')}
101
+ description={t('desktopUpdatesPageDescription')}
102
+ actions={(
103
+ <Button
104
+ variant="outline"
105
+ onClick={() => void desktopUpdateManager.checkForUpdates()}
106
+ disabled={isChecking || isDownloading || isApplying}
107
+ >
108
+ <RefreshCw className={cn('mr-2 h-4 w-4', isChecking && 'animate-spin')} />
109
+ {t('desktopUpdatesCheckNow')}
110
+ </Button>
111
+ )}
112
+ />
113
+
114
+ <Card>
115
+ <CardHeader>
116
+ <CardTitle>{t('desktopUpdatesOverviewTitle')}</CardTitle>
117
+ <CardDescription>{t('desktopUpdatesOverviewDescription')}</CardDescription>
118
+ </CardHeader>
119
+ <CardContent className="space-y-5">
120
+ <div className="flex flex-wrap items-center gap-3">
121
+ <span className="text-sm font-medium text-gray-700">{t('desktopUpdatesStatusLabel')}</span>
122
+ <span className={cn('inline-flex rounded-full px-3 py-1 text-xs font-medium ring-1', getStatusTone(snapshot.status))}>
123
+ {getStatusLabel(snapshot.status)}
124
+ </span>
125
+ </div>
126
+
127
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
128
+ <div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
129
+ <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesLauncherVersion')}</p>
130
+ <p className="mt-2 text-base font-semibold text-gray-900">{formatVersion(snapshot.launcherVersion)}</p>
131
+ </div>
132
+ <div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
133
+ <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesCurrentBundleVersion')}</p>
134
+ <p className="mt-2 text-base font-semibold text-gray-900">{formatVersion(snapshot.currentVersion)}</p>
135
+ </div>
136
+ <div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
137
+ <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesAvailableVersion')}</p>
138
+ <p className="mt-2 text-base font-semibold text-gray-900">{formatVersion(snapshot.availableVersion)}</p>
139
+ </div>
140
+ <div className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
141
+ <p className="text-xs font-medium uppercase tracking-[0.08em] text-gray-500">{t('desktopUpdatesLastCheckedAt')}</p>
142
+ <p className="mt-2 text-base font-semibold text-gray-900">{formatLastCheckedAt(snapshot.lastCheckedAt)}</p>
143
+ </div>
144
+ </div>
145
+
146
+ {snapshot.downloadedVersion ? (
147
+ <div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4">
148
+ <p className="text-sm font-semibold text-emerald-800">{t('desktopUpdatesDownloadedBannerTitle')}</p>
149
+ <p className="mt-1 text-sm text-emerald-700">
150
+ {t('desktopUpdatesDownloadedBannerDescription').replace('{version}', snapshot.downloadedVersion)}
151
+ </p>
152
+ </div>
153
+ ) : null}
154
+
155
+ {snapshot.errorMessage ? (
156
+ <div className="rounded-2xl border border-red-200 bg-red-50/70 p-4 text-sm text-red-700">
157
+ {snapshot.errorMessage}
158
+ </div>
159
+ ) : null}
160
+ </CardContent>
161
+ </Card>
162
+
163
+ <Card>
164
+ <CardHeader>
165
+ <CardTitle>{t('desktopUpdatesPreferencesTitle')}</CardTitle>
166
+ <CardDescription>{t('desktopUpdatesPreferencesDescription')}</CardDescription>
167
+ </CardHeader>
168
+ <CardContent className="space-y-5">
169
+ <div className="flex items-start justify-between gap-4 rounded-xl border border-gray-200 p-4">
170
+ <div className="space-y-1">
171
+ <Label>{t('desktopUpdatesAutomaticChecks')}</Label>
172
+ <p className="text-sm text-gray-500">{t('desktopUpdatesAutomaticChecksHelp')}</p>
173
+ </div>
174
+ <Switch
175
+ checked={snapshot.preferences.automaticChecks}
176
+ disabled={isSavingPreferences}
177
+ onCheckedChange={(checked) => void desktopUpdateManager.updatePreferences({ automaticChecks: checked })}
178
+ />
179
+ </div>
180
+
181
+ <div className="flex items-start justify-between gap-4 rounded-xl border border-gray-200 p-4">
182
+ <div className="space-y-1">
183
+ <Label>{t('desktopUpdatesAutoDownload')}</Label>
184
+ <p className="text-sm text-gray-500">{t('desktopUpdatesAutoDownloadHelp')}</p>
185
+ </div>
186
+ <Switch
187
+ checked={snapshot.preferences.autoDownload}
188
+ disabled={isSavingPreferences}
189
+ onCheckedChange={(checked) => void desktopUpdateManager.updatePreferences({ autoDownload: checked })}
190
+ />
191
+ </div>
192
+ </CardContent>
193
+ </Card>
194
+
195
+ <Card>
196
+ <CardHeader>
197
+ <CardTitle>{t('desktopUpdatesActionsTitle')}</CardTitle>
198
+ <CardDescription>{t('desktopUpdatesActionsDescription')}</CardDescription>
199
+ </CardHeader>
200
+ <CardContent className="flex flex-wrap items-center gap-3">
201
+ <Button
202
+ variant="outline"
203
+ onClick={() => void desktopUpdateManager.checkForUpdates()}
204
+ disabled={isChecking || isDownloading || isApplying}
205
+ >
206
+ <RefreshCw className={cn('mr-2 h-4 w-4', isChecking && 'animate-spin')} />
207
+ {t('desktopUpdatesCheckNow')}
208
+ </Button>
209
+
210
+ <Button onClick={() => void desktopUpdateManager.downloadUpdate()} disabled={!canDownload}>
211
+ <Download className={cn('mr-2 h-4 w-4', isDownloading && 'animate-bounce')} />
212
+ {t('desktopUpdatesDownloadNow')}
213
+ </Button>
214
+
215
+ <Button variant="secondary" onClick={() => void desktopUpdateManager.applyDownloadedUpdate()} disabled={!canApply}>
216
+ <RotateCw className={cn('mr-2 h-4 w-4', isApplying && 'animate-spin')} />
217
+ {t('desktopUpdatesApplyNow')}
218
+ </Button>
219
+
220
+ {snapshot.releaseNotesUrl ? (
221
+ <Button variant="ghost" onClick={() => window.open(snapshot.releaseNotesUrl ?? '', '_blank', 'noopener,noreferrer')}>
222
+ <ExternalLink className="mr-2 h-4 w-4" />
223
+ {t('desktopUpdatesReleaseNotes')}
224
+ </Button>
225
+ ) : null}
226
+ </CardContent>
227
+ </Card>
228
+ </PageLayout>
229
+ );
230
+ }
@@ -0,0 +1,68 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { ProvidersList } from '@/components/config/ProvidersList';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ createProviderMutateAsync: vi.fn(),
7
+ configQuery: {
8
+ data: {
9
+ providers: {
10
+ openai: { enabled: true, apiKeySet: true },
11
+ anthropic: { enabled: true, apiKeySet: true },
12
+ nextclaw: { enabled: true, apiKeySet: true }
13
+ }
14
+ },
15
+ isLoading: false
16
+ },
17
+ metaQuery: {
18
+ data: {
19
+ providers: [
20
+ { name: 'nextclaw', displayName: 'NextClaw Builtin', defaultApiBase: 'https://ai-gateway-api.nextclaw.io/v1' },
21
+ { name: 'openai', displayName: 'OpenAI', defaultApiBase: 'https://api.openai.com/v1' },
22
+ { name: 'anthropic', displayName: 'Anthropic', defaultApiBase: 'https://api.anthropic.com/v1' }
23
+ ]
24
+ }
25
+ },
26
+ schemaQuery: {
27
+ data: {
28
+ uiHints: {}
29
+ }
30
+ }
31
+ }));
32
+
33
+ vi.mock('@/hooks/useConfig', () => ({
34
+ useConfig: () => mocks.configQuery,
35
+ useConfigMeta: () => mocks.metaQuery,
36
+ useConfigSchema: () => mocks.schemaQuery,
37
+ useCreateProvider: () => ({
38
+ mutateAsync: mocks.createProviderMutateAsync,
39
+ isPending: false
40
+ })
41
+ }));
42
+
43
+ vi.mock('@/components/config/ProviderForm', () => ({
44
+ ProviderForm: ({ providerName }: { providerName?: string }) => (
45
+ <div data-testid="provider-form">{providerName ?? 'none'}</div>
46
+ )
47
+ }));
48
+
49
+ describe('ProvidersList', () => {
50
+ it('keeps the nextclaw builtin provider at the end of the list', () => {
51
+ const { container } = render(<ProvidersList />);
52
+
53
+ const sidebarSection = container.querySelector('section');
54
+ if (!(sidebarSection instanceof HTMLElement)) {
55
+ throw new Error('provider sidebar not found');
56
+ }
57
+
58
+ const providerButtons = Array.from(sidebarSection.querySelectorAll('button[type="button"]')).filter((button) => (
59
+ ['OpenAI', 'Anthropic', 'NextClaw Builtin'].some((label) => button.textContent?.includes(label))
60
+ ));
61
+
62
+ expect(providerButtons.map((button) => button.textContent)).toEqual([
63
+ expect.stringContaining('OpenAI'),
64
+ expect.stringContaining('Anthropic'),
65
+ expect.stringContaining('NextClaw Builtin')
66
+ ]);
67
+ });
68
+ });
@@ -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, Shield, Wrench, Wifi, Bot } from 'lucide-react';
4
+ import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft, Search, Shield, Wrench, Wifi, Bot, Download } 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';
@@ -80,21 +80,36 @@ export function Sidebar({ mode }: SidebarProps) {
80
80
  label: t('providers'),
81
81
  icon: Sparkles,
82
82
  },
83
+ {
84
+ target: '/channels',
85
+ label: t('channels'),
86
+ icon: MessageSquare,
87
+ },
83
88
  {
84
89
  target: '/search',
85
90
  label: t('searchChannels'),
86
91
  icon: Search,
87
92
  },
88
93
  {
89
- target: '/channels',
90
- label: t('channels'),
91
- icon: MessageSquare,
94
+ target: '/marketplace/plugins',
95
+ label: t('marketplaceFilterPlugins'),
96
+ icon: Plug,
97
+ },
98
+ {
99
+ target: '/marketplace/mcp',
100
+ label: t('marketplaceFilterMcp'),
101
+ icon: Wrench,
92
102
  },
93
103
  {
94
104
  target: '/runtime',
95
105
  label: t('runtime'),
96
106
  icon: GitBranch,
97
107
  },
108
+ {
109
+ target: '/updates',
110
+ label: t('updates'),
111
+ icon: Download,
112
+ },
98
113
  {
99
114
  target: '/remote',
100
115
  label: t('remote'),
@@ -114,16 +129,6 @@ export function Sidebar({ mode }: SidebarProps) {
114
129
  target: '/secrets',
115
130
  label: t('secrets'),
116
131
  icon: KeyRound,
117
- },
118
- {
119
- target: '/marketplace/plugins',
120
- label: t('marketplaceFilterPlugins'),
121
- icon: Plug,
122
- },
123
- {
124
- target: '/marketplace/mcp',
125
- label: t('marketplaceFilterMcp'),
126
- icon: Wrench,
127
132
  }
128
133
  ];
129
134
  const navItems = isSettingsMode ? settingsNavItems : mainNavItems;
@@ -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,38 @@ 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
+ 'Updates',
113
+ 'Remote Access',
114
+ 'Security',
115
+ 'Sessions',
116
+ 'Secrets'
117
+ ]);
118
+ });
119
+
88
120
  it('keeps the footer utilities compact without changing the top header structure', () => {
89
121
  render(
90
122
  <MemoryRouter initialEntries={['/model']}>