@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
@@ -2,7 +2,6 @@ import {
2
2
  useEffect,
3
3
  useMemo,
4
4
  useRef,
5
- useState,
6
5
  } from "react";
7
6
  import {
8
7
  buildNcpRequestEnvelope,
@@ -22,7 +21,6 @@ import {
22
21
  } from "@/components/chat/chat-session-route";
23
22
  import { useNcpChatPageData } from "@/components/chat/ncp/ncp-chat-page-data";
24
23
  import { NcpChatPresenter } from "@/components/chat/ncp/ncp-chat.presenter";
25
- import { createNcpSessionId } from "@/components/chat/ncp/ncp-session-adapter";
26
24
  import { useNcpSessionConversation } from "@/components/chat/ncp/session-conversation/use-ncp-session-conversation";
27
25
  import { useNcpChatDerivedState, useNcpChatSnapshotSync } from "@/components/chat/ncp/page/ncp-chat-derived-state";
28
26
  import { ChatPresenterProvider } from "@/components/chat/presenter/chat-presenter-context";
@@ -79,26 +77,39 @@ export function buildNcpSendMetadata(payload: {
79
77
  return metadata;
80
78
  }
81
79
 
82
- export function shouldRefreshDraftSessionId(params: {
83
- previousSelectedSessionKey: string | null | undefined;
84
- nextSelectedSessionKey: string | null;
80
+ export function shouldClearPendingProjectRootOverride(params: {
81
+ pendingProjectRoot: string | null;
82
+ pendingProjectRootSessionKey: string | null;
83
+ sessionKey: string | null | undefined;
84
+ selectedSessionProjectRoot: string | null | undefined;
85
85
  }): boolean {
86
+ const {
87
+ pendingProjectRoot,
88
+ pendingProjectRootSessionKey,
89
+ sessionKey,
90
+ selectedSessionProjectRoot,
91
+ } = params;
86
92
  return (
87
- params.nextSelectedSessionKey === null &&
88
- params.previousSelectedSessionKey !== undefined &&
89
- params.previousSelectedSessionKey !== null
93
+ pendingProjectRoot !== null &&
94
+ pendingProjectRootSessionKey !== null &&
95
+ sessionKey === pendingProjectRootSessionKey &&
96
+ (selectedSessionProjectRoot ?? null) === pendingProjectRoot
90
97
  );
91
98
  }
92
99
 
93
100
  export function NcpChatPage({ view }: ChatPageProps) {
94
- const [presenter] = useState(() => new NcpChatPresenter());
95
- const [draftSessionId, setDraftSessionId] = useState(() =>
96
- createNcpSessionId(),
97
- );
101
+ const presenterRef = useRef<NcpChatPresenter | null>(null);
102
+ if (!presenterRef.current) {
103
+ presenterRef.current = new NcpChatPresenter();
104
+ }
105
+ const presenter = presenterRef.current;
98
106
  const query = useChatSessionListStore((state) => state.snapshot.query);
99
107
  const selectedSessionKey = useChatSessionListStore(
100
108
  (state) => state.snapshot.selectedSessionKey,
101
109
  );
110
+ const draftSessionKey = useChatSessionListStore(
111
+ (state) => state.snapshot.draftSessionKey,
112
+ );
102
113
  const selectedAgentId = useChatSessionListStore(
103
114
  (state) => state.snapshot.selectedAgentId,
104
115
  );
@@ -123,21 +134,14 @@ export function NcpChatPage({ view }: ChatPageProps) {
123
134
  }>();
124
135
  const threadRef = useRef<HTMLDivElement | null>(null);
125
136
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
126
- const previousSelectedSessionKeyRef = useRef<string | null | undefined>(
127
- undefined,
128
- );
129
137
  const routeSessionKey = useMemo(
130
138
  () => parseSessionKeyFromRoute(routeSessionIdParam),
131
139
  [routeSessionIdParam],
132
140
  );
133
- const sessionKey = selectedSessionKey ?? draftSessionId;
134
- const hasDraftProjectRootOverride =
135
- pendingProjectRoot !== null &&
136
- pendingProjectRootSessionKey === null &&
137
- selectedSessionKey === null;
141
+ const sessionKey = routeSessionKey ?? selectedSessionKey ?? draftSessionKey;
138
142
  const hasSessionProjectRootOverride =
139
143
  pendingProjectRoot !== null &&
140
- (pendingProjectRootSessionKey === sessionKey || hasDraftProjectRootOverride);
144
+ pendingProjectRootSessionKey === sessionKey;
141
145
  const sessionProjectRootOverride = hasSessionProjectRootOverride
142
146
  ? pendingProjectRoot
143
147
  : undefined;
@@ -168,25 +172,6 @@ export function NcpChatPage({ view }: ChatPageProps) {
168
172
 
169
173
  const agent = useNcpSessionConversation(sessionKey);
170
174
 
171
- useEffect(() => {
172
- presenter.setDraftSessionId(draftSessionId);
173
- }, [draftSessionId, presenter]);
174
-
175
- useEffect(() => {
176
- if (
177
- shouldRefreshDraftSessionId({
178
- previousSelectedSessionKey:
179
- previousSelectedSessionKeyRef.current,
180
- nextSelectedSessionKey: selectedSessionKey,
181
- })
182
- ) {
183
- const nextDraftSessionId = createNcpSessionId();
184
- setDraftSessionId(nextDraftSessionId);
185
- presenter.setDraftSessionId(nextDraftSessionId);
186
- }
187
- previousSelectedSessionKeyRef.current = selectedSessionKey;
188
- }, [presenter, selectedSessionKey]);
189
-
190
175
  const effectiveSessionProjectRoot = hasSessionProjectRootOverride
191
176
  ? pendingProjectRoot
192
177
  : (selectedSession?.projectRoot ?? null);
@@ -215,10 +200,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
215
200
  thinkingLevel: payload.thinkingLevel,
216
201
  sessionType: payload.sessionType,
217
202
  projectRoot:
218
- payload.sessionKey === pendingProjectRootSessionKey ||
219
- (pendingProjectRoot !== null &&
220
- pendingProjectRootSessionKey === null &&
221
- selectedSessionKey === null)
203
+ payload.sessionKey === pendingProjectRootSessionKey
222
204
  ? pendingProjectRoot
223
205
  : (selectedSession?.projectRoot ?? null),
224
206
  requestedSkills: payload.requestedSkills,
@@ -279,15 +261,14 @@ export function NcpChatPage({ view }: ChatPageProps) {
279
261
  ]);
280
262
 
281
263
  useEffect(() => {
282
- const matchesPendingProjectSession =
283
- pendingProjectRootSessionKey === null
284
- ? selectedSessionKey !== null
285
- : pendingProjectRootSessionKey === selectedSession?.key;
286
264
  if (
287
265
  !selectedSession ||
288
- pendingProjectRoot === null ||
289
- !matchesPendingProjectSession ||
290
- (selectedSession.projectRoot ?? null) !== pendingProjectRoot
266
+ !shouldClearPendingProjectRootOverride({
267
+ pendingProjectRoot,
268
+ pendingProjectRootSessionKey,
269
+ sessionKey: selectedSession.key,
270
+ selectedSessionProjectRoot: selectedSession.projectRoot ?? null,
271
+ })
291
272
  ) {
292
273
  return;
293
274
  }
@@ -18,6 +18,7 @@ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-l
18
18
  import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
19
19
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
20
20
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
21
+ import type { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
21
22
  import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
22
23
  import { chatRecentModelsManager } from '@/components/chat/chat-recent-models.manager';
23
24
  import { chatRecentSkillsManager } from '@/components/chat/chat-recent-skills.manager';
@@ -40,7 +41,7 @@ export class NcpChatInputManager {
40
41
  constructor(
41
42
  private uiManager: ChatUiManager,
42
43
  private streamActionsManager: ChatStreamActionsManager,
43
- private getDraftSessionId: () => string
44
+ private sessionListManager: ChatSessionListManager
44
45
  ) {}
45
46
 
46
47
  private hasSnapshotChanges = (patch: Partial<ChatInputSnapshot>): boolean => {
@@ -179,10 +180,7 @@ export class NcpChatInputManager {
179
180
  return;
180
181
  }
181
182
  const { selectedSkills: requestedSkills, composerNodes } = inputSnapshot;
182
- const sessionKey = sessionSnapshot.selectedSessionKey ?? this.getDraftSessionId();
183
- if (!sessionSnapshot.selectedSessionKey) {
184
- this.uiManager.goToSession(sessionKey, { replace: true });
185
- }
183
+ const sessionKey = sessionSnapshot.selectedSessionKey ?? this.sessionListManager.ensureDraftSession(inputSnapshot.selectedSessionType);
186
184
  this.setComposerNodes(createInitialChatComposerNodes());
187
185
  await this.streamActionsManager.sendMessage({
188
186
  message,
@@ -116,7 +116,6 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
116
116
  );
117
117
  const sessionTypeState = useChatSessionTypeState({
118
118
  selectedSession,
119
- selectedSessionKey: params.sessionKey,
120
119
  pendingSessionType: params.pendingSessionType,
121
120
  setPendingSessionType: params.setPendingSessionType,
122
121
  sessionTypesData: sessionTypesQuery.data
@@ -11,21 +11,11 @@ export class NcpChatPresenter {
11
11
  chatInputManager = new NcpChatInputManager(
12
12
  this.chatUiManager,
13
13
  this.chatStreamActionsManager,
14
- () => this.getDraftSessionId()
14
+ this.chatSessionListManager
15
15
  );
16
16
  chatThreadManager = new NcpChatThreadManager(
17
17
  this.chatUiManager,
18
18
  this.chatSessionListManager,
19
19
  this.chatStreamActionsManager
20
20
  );
21
-
22
- private draftSessionId = '';
23
-
24
- setDraftSessionId = (sessionId: string) => {
25
- this.draftSessionId = sessionId;
26
- };
27
-
28
- private getDraftSessionId(): string {
29
- return this.draftSessionId;
30
- }
31
21
  }
@@ -1,15 +1,20 @@
1
- import { useMemo } from 'react';
2
- import type { SessionEntryView } from '@/api/types';
3
- import { sessionDisplayName } from '@/components/chat/chat-session-display';
4
- import { adaptNcpSessionSummaries } from '@/components/chat/ncp/ncp-session-adapter';
5
- import type { ChatChildSessionTab } from '@/components/chat/stores/chat-thread.store';
6
- import { useNcpSessions } from '@/hooks/useConfig';
1
+ import { useMemo } from "react";
2
+ import type { SessionEntryView } from "@/api/types";
3
+ import { sessionDisplayName } from "@/components/chat/chat-session-display";
4
+ import { adaptNcpSessionSummaries } from "@/components/chat/ncp/ncp-session-adapter";
5
+ import { resolveSessionTypeLabel } from "@/components/chat/useChatSessionTypeState";
6
+ import type { ChatChildSessionTab } from "@/components/chat/stores/chat-thread.store";
7
+ import { useNcpSessions } from "@/hooks/useConfig";
7
8
 
8
9
  export type ResolvedChildSessionTab = {
9
10
  sessionKey: string;
10
11
  parentSessionKey: string | null;
11
12
  title: string;
12
13
  agentId: string | null;
14
+ sessionTypeLabel: string | null;
15
+ preferredModel: string | null;
16
+ projectName: string | null;
17
+ projectRoot: string | null;
13
18
  };
14
19
 
15
20
  function resolveChildSessionTitle(
@@ -31,7 +36,9 @@ export function useNcpChildSessionTabsView(
31
36
  const sessionsQuery = useNcpSessions({ limit: 200 });
32
37
 
33
38
  const sessionByKey = useMemo(() => {
34
- const sessions = adaptNcpSessionSummaries(sessionsQuery.data?.sessions ?? []);
39
+ const sessions = adaptNcpSessionSummaries(
40
+ sessionsQuery.data?.sessions ?? [],
41
+ );
35
42
  return new Map(sessions.map((session) => [session.key, session]));
36
43
  }, [sessionsQuery.data?.sessions]);
37
44
 
@@ -45,6 +52,12 @@ export function useNcpChildSessionTabsView(
45
52
  parentSessionKey: tab.parentSessionKey,
46
53
  title: resolveChildSessionTitle(tab, session),
47
54
  agentId,
55
+ sessionTypeLabel: session?.sessionType
56
+ ? resolveSessionTypeLabel(session.sessionType)
57
+ : null,
58
+ preferredModel: session?.preferredModel?.trim() || null,
59
+ projectName: session?.projectName?.trim() || null,
60
+ projectRoot: session?.projectRoot?.trim() || null,
48
61
  };
49
62
  }),
50
63
  [sessionByKey, tabs],
@@ -1,9 +1,11 @@
1
1
  import { create } from 'zustand';
2
+ import { createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
2
3
 
3
4
  export type ChatSessionListMode = 'time-first' | 'project-first';
4
5
 
5
6
  export type ChatSessionListSnapshot = {
6
7
  selectedSessionKey: string | null;
8
+ draftSessionKey: string;
7
9
  selectedAgentId: string;
8
10
  query: string;
9
11
  listMode: ChatSessionListMode;
@@ -16,6 +18,7 @@ type ChatSessionListStore = {
16
18
 
17
19
  const initialSnapshot: ChatSessionListSnapshot = {
18
20
  selectedSessionKey: null,
21
+ draftSessionKey: createNcpSessionId(),
19
22
  selectedAgentId: 'main',
20
23
  query: '',
21
24
  listMode: 'time-first'
@@ -17,7 +17,6 @@ describe('useChatSessionTypeState', () => {
17
17
  const { result } = renderHook(() =>
18
18
  useChatSessionTypeState({
19
19
  selectedSession: null,
20
- selectedSessionKey: null,
21
20
  pendingSessionType: 'codex-sdk',
22
21
  setPendingSessionType,
23
22
  sessionTypesData: {
@@ -40,7 +39,6 @@ describe('useChatSessionTypeState', () => {
40
39
  renderHook(() =>
41
40
  useChatSessionTypeState({
42
41
  selectedSession: null,
43
- selectedSessionKey: null,
44
42
  pendingSessionType: '',
45
43
  setPendingSessionType,
46
44
  sessionTypesData: {
@@ -62,7 +60,6 @@ describe('useChatSessionTypeState', () => {
62
60
  const { result } = renderHook(() =>
63
61
  useChatSessionTypeState({
64
62
  selectedSession: null,
65
- selectedSessionKey: null,
66
63
  pendingSessionType: 'claude',
67
64
  setPendingSessionType,
68
65
  sessionTypesData: {
@@ -22,7 +22,6 @@ export type ChatSessionTypeOption = {
22
22
 
23
23
  type UseChatSessionTypeStateParams = {
24
24
  selectedSession: SessionEntryView | null;
25
- selectedSessionKey: string | null;
26
25
  pendingSessionType: string;
27
26
  setPendingSessionType: Dispatch<SetStateAction<string>>;
28
27
  sessionTypesData?: {
@@ -114,7 +113,6 @@ export function useChatSessionTypeState(params: UseChatSessionTypeStateParams):
114
113
  } {
115
114
  const {
116
115
  selectedSession,
117
- selectedSessionKey,
118
116
  pendingSessionType,
119
117
  setPendingSessionType,
120
118
  sessionTypesData
@@ -164,7 +162,7 @@ export function useChatSessionTypeState(params: UseChatSessionTypeStateParams):
164
162
  );
165
163
 
166
164
  useEffect(() => {
167
- if (selectedSessionKey) {
165
+ if (selectedSession) {
168
166
  return;
169
167
  }
170
168
  const rawPending = typeof pendingSessionType === 'string' ? pendingSessionType.trim() : '';
@@ -181,9 +179,9 @@ export function useChatSessionTypeState(params: UseChatSessionTypeStateParams):
181
179
  return;
182
180
  }
183
181
  setPendingSessionType(defaultSessionType);
184
- }, [defaultSessionType, pendingSessionType, selectedSessionKey, setPendingSessionType]);
182
+ }, [defaultSessionType, pendingSessionType, selectedSession, setPendingSessionType]);
185
183
 
186
- const canEditSessionType = !selectedSessionKey || Boolean(selectedSession?.sessionTypeMutable);
184
+ const canEditSessionType = !selectedSession || Boolean(selectedSession.sessionTypeMutable);
187
185
  const availableSessionTypeSet = useMemo(
188
186
  () => new Set(runtimeSessionTypeOptions.map((option) => option.value)),
189
187
  [runtimeSessionTypeOptions]
@@ -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') {
@@ -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,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
+ });
@@ -80,15 +80,25 @@ 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',
@@ -114,16 +124,6 @@ export function Sidebar({ mode }: SidebarProps) {
114
124
  target: '/secrets',
115
125
  label: t('secrets'),
116
126
  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
127
  }
128
128
  ];
129
129
  const navItems = isSettingsMode ? settingsNavItems : mainNavItems;